Skip to main content

rsynko_ui/
screen.rs

1use crate::*;
2use alux_ext::ext;
3use rsynko_manager::*;
4
5/// Specifies the presentation vocabulary every renderer supplies.
6///
7/// A composition states what a screen says by naming these constructors; a renderer states what
8/// they look like. Nothing here names a color, a widget, a border, or a terminal.
9pub trait ScreenSyntax {
10    /// Represents one styled run of text.
11    type Text;
12    /// Represents one composed line.
13    type Line;
14    /// Represents one collection row, which may state several lines.
15    type Row;
16    /// Represents the content of one page.
17    type Body;
18    /// Represents one whole screen.
19    type Screen;
20
21    /// States one run of text carrying one weight.
22    fn text(&self, content: impl Into<String>, emphasis: Emphasis) -> Self::Text;
23
24    /// States one completed share as a bar of fixed width.
25    fn gauge(&self, gauge: Gauge) -> Self::Text;
26
27    /// Composes runs into one line.
28    fn line(&self, runs: impl Iterator<Item = Self::Text>) -> Self::Line;
29
30    /// States one line whose text is being edited in place, with its cursor.
31    fn edited_line(
32        &self,
33        prefix: impl Into<String>,
34        text: &str,
35        cursor: usize,
36        emphasis: Emphasis,
37    ) -> Self::Line;
38
39    /// Composes lines into one row.
40    fn row(&self, lines: impl Iterator<Item = Self::Line>) -> Self::Row;
41
42    /// States a named, ordered collection of rows, and which one the cursor rests on.
43    ///
44    /// A renderer with less room than the rows need keeps the one the cursor rests on in view,
45    /// which it can only do by being told which that is.
46    fn rows(
47        &self,
48        title: impl Into<String>,
49        focused: Option<usize>,
50        rows: impl Iterator<Item = Self::Row>,
51    ) -> Self::Body;
52
53    /// States one named message standing in for content there is none of yet.
54    fn message(
55        &self,
56        title: impl Into<String>,
57        message: impl Into<String>,
58        emphasis: Emphasis,
59    ) -> Self::Body;
60
61    /// States a named text draft under edit, with its cursor, what to write in it, and the
62    /// shapes of what may be written.
63    ///
64    /// The examples stand beside the draft rather than inside it: they are read while writing,
65    /// not written over.
66    fn draft(
67        &self,
68        title: impl Into<String>,
69        placeholder: impl Into<String>,
70        text: &str,
71        cursor: usize,
72        examples: impl Iterator<Item = (String, String)>,
73    ) -> Self::Body;
74
75    /// States a named record, quiet and read in order.
76    fn record(&self, title: impl Into<String>, notes: impl Iterator<Item = String>) -> Self::Body;
77
78    /// States one text a reader takes away, with nothing drawn around it.
79    ///
80    /// A reader copies this the way they copy anything else in a terminal, by selecting it, so
81    /// nothing is drawn beside it that selecting would take along: no border, no label, and no
82    /// break the text did not already have. What does not fit is left to the terminal to wrap.
83    fn verbatim(&self, text: impl Into<String>) -> Self::Body;
84
85    /// Composes one page from what names it, what it holds, and what can be done to it.
86    fn screen(
87        &self,
88        header: Self::Line,
89        body: Self::Body,
90        status: impl Into<String>,
91        footer: Self::Line,
92    ) -> Self::Screen;
93
94    /// Observes what one composed screen states, one entry per stated line.
95    ///
96    /// Every renderer can say what its screen reads as, which is what makes a presentation law
97    /// checkable against the renderer that will actually draw it.
98    fn screen_text(&self, screen: &Self::Screen) -> impl Iterator<Item = String>;
99}
100
101/// Names the application one screen belongs to.
102///
103/// A screen states what is running it, because a reader looking at one wants to know which thing
104/// and which of its versions they are looking at. Which application that is belongs to whoever
105/// runs the manager, not to the manager.
106#[derive(Clone, Copy, Debug, PartialEq, Eq)]
107pub struct Application<'a> {
108    /// Names the application as it names itself.
109    pub name: &'a str,
110    /// States which version of it is running.
111    pub version: &'a str,
112}
113
114/// Names the bar width every progress gauge is drawn in.
115pub const GAUGE_WIDTH: usize = 8;
116
117/// Names the column budget a compact row gives a title.
118pub const COMPACT_TITLE_COLUMNS: usize = 34;
119
120/// Names the column budget an expanded heading gives a title.
121pub const EXPANDED_TITLE_COLUMNS: usize = 46;
122
123/// Names the column budget every field label is stated in.
124pub const FIELD_LABEL_COLUMNS: usize = 11;
125
126/// Names the column budget one field value is stated in before it continues underneath itself.
127pub const FIELD_VALUE_COLUMNS: usize = 83;
128
129/// Names the column budget one offered choice states its own name in.
130pub const CHOICE_KEY_COLUMNS: usize = 15;
131
132/// Marks the value the cursor rests on.
133pub const CURSOR_MARK: &str = "▸";
134
135/// Marks the one request whose details are open.
136pub const EXPANDED_MARK: &str = "▾";
137
138/// Separates the pages one page rests under, from the outermost inward.
139pub const PATH_SEPARATOR: &str = "  ›  ";
140
141/// States the mode a request is in while it will only say what it would do.
142pub const REHEARSING_MODE: &str = "Dry run — changes nothing";
143
144/// States the mode a request is in once the next run of it will write.
145///
146/// The mark asks for its emoji presentation, and that is what makes it safe to state. A warning
147/// sign on its own is ambiguous — a font may draw it one column wide or two, and a renderer
148/// measuring it has no way to know which — so the row it sits in may or may not fit. Asked for as
149/// an emoji it is two columns wide to everyone who measures it, and every layer agrees.
150pub const ARMED_MODE: &str = "\u{26A0}\u{FE0F} Real run — writes files";
151
152/// States the shapes a source may be written in, and what each of them means.
153///
154/// One line names one request. A line naming two ends names a transfer between them, which is
155/// how a whole transfer command reads as well.
156pub const SUBMISSION_EXAMPLES: [(&str, &str); 6] = [
157    (
158        "/home/dev/photos/2026",
159        "a folder here, into a folder of the same name",
160    ),
161    (
162        "backup@nas.local:/volume1/photos  /home/dev/photos",
163        "a folder on another machine, into a folder here",
164    ),
165    (
166        "/home/dev/photos  backup@nas.local:/volume1/photos",
167        "the same transfer, the other way around",
168    ),
169    (
170        "rsync -a nas.local:/srv/data /mnt/data",
171        "a whole command, read as the two ends it names",
172    ),
173    (
174        "https://www.youtube.com/watch?v=VIDEO_ID",
175        "a web address a source recognizes, fetched instead",
176    ),
177    (
178        "https://x.com/user/status/1234567890",
179        "a tweet, taking the media it carries",
180    ),
181];
182
183/// Composes every manager page from what the manager states about itself.
184#[ext(name = ManagerScreenExt)]
185pub impl<This> This
186where
187    This: NavigationStateAlg
188        + QueueCatalogAlg
189        + DetailSelectionAlg
190        + ManagerStatusAlg
191        + TextEditorStateAlg
192        + DraftStateAlg
193        + OutputDraftAlg
194        + InputDraftAlg,
195    This::Entry: QueueEntryAlg
196        + RequestOptionsAlg
197        + TransferViewAlg
198        + FormatChoiceViewAlg<Format: FormatDescriptionAlg>
199        + RehearsalViewAlg<Change: PlannedChangeAlg>,
200    This::Id: Copy + Eq,
201{
202    /// States the whole screen the current page denotes.
203    fn screen<Syntax>(&self, syntax: &Syntax, application: Application<'_>) -> Syntax::Screen
204    where
205        Syntax: ScreenSyntax,
206    {
207        let header = self.header_line(syntax, application);
208        let footer = self.footer_line(syntax);
209        let status = self.manager_message().unwrap_or("Actions").to_owned();
210        syntax.screen(header, self.body(syntax), status, footer)
211    }
212
213    /// States what the current page belongs to and the path that reached it.
214    fn header_line<Syntax>(&self, syntax: &Syntax, application: Application<'_>) -> Syntax::Line
215    where
216        Syntax: ScreenSyntax,
217    {
218        // Every page rests under the collection, and the collection names itself: repeating it
219        // here would say the same thing twice on every screen and add nothing on any.
220        let path = self
221            .breadcrumbs()
222            .into_iter()
223            .skip(1)
224            .map(|breadcrumb| breadcrumb.label)
225            .collect::<Vec<_>>()
226            .join(PATH_SEPARATOR);
227        syntax.line(
228            [
229                syntax.text(format!(" {} ", application.name), Emphasis::Name),
230                syntax.text(format!(" {} ", application.version), Emphasis::Muted),
231                syntax.text(format!(" {path}"), Emphasis::Plain),
232            ]
233            .into_iter(),
234        )
235    }
236
237    /// States every menu entry the page offers, the unavailable ones stated quietly.
238    fn footer_line<Syntax>(&self, syntax: &Syntax) -> Syntax::Line
239    where
240        Syntax: ScreenSyntax,
241    {
242        let runs = self
243            .menu_items()
244            .enumerate()
245            .flat_map(|(index, item)| {
246                let emphasis = match item.availability {
247                    ActionAvailability::Enabled => Emphasis::Plain,
248                    ActionAvailability::Disabled => Emphasis::Muted,
249                };
250                let separator = (index > 0).then(|| syntax.text("  ", Emphasis::Plain));
251                separator
252                    .into_iter()
253                    .chain([syntax.text(item.label(), emphasis)])
254            })
255            .collect::<Vec<_>>();
256        syntax.line(runs.into_iter())
257    }
258
259    /// States what the current page holds.
260    fn body<Syntax>(&self, syntax: &Syntax) -> Syntax::Body
261    where
262        Syntax: ScreenSyntax,
263    {
264        match self.page() {
265            ManagerPage::Collection => self.collection_body(syntax, None),
266            ManagerPage::Details(id) | ManagerPage::Output(id) | ManagerPage::Input(id) => {
267                self.collection_body(syntax, Some(id))
268            }
269            ManagerPage::AddSources => self.draft_body(syntax),
270            ManagerPage::Formats(id) => self.formats_body(syntax, id),
271            ManagerPage::Log(id) => self.record_body(syntax, id),
272            ManagerPage::Report(id) => self.report_body(syntax, id),
273            ManagerPage::Command(id) => self.command_body(syntax, id),
274        }
275    }
276
277    /// States the collection, one row per request, at most one of them expanded.
278    fn collection_body<Syntax>(&self, syntax: &Syntax, expanded: Option<This::Id>) -> Syntax::Body
279    where
280        Syntax: ScreenSyntax,
281    {
282        let ids = self.queue_ids().collect::<Vec<_>>();
283        let title = format!("{COLLECTION} ({})", ids.len());
284        if ids.is_empty() {
285            let add = self
286                .action_keys(ManagerAction::AddSources)
287                .next()
288                .map_or_else(|| UNSTATED.to_owned(), Keystroke::label);
289            let empty = syntax.row(
290                [syntax.line(
291                    [syntax.text(
292                        format!("  No sources. Press [{add}] to add or paste sources."),
293                        Emphasis::Plain,
294                    )]
295                    .into_iter(),
296                )]
297                .into_iter(),
298            );
299            return syntax.rows(title, None, [empty].into_iter());
300        }
301        let focused = self
302            .selected_queue_id()
303            .and_then(|selected| ids.iter().position(|id| *id == selected));
304        let rows = ids
305            .into_iter()
306            .filter_map(|id| {
307                let entry = self.queue_entry(id)?;
308                Some(if expanded == Some(id) {
309                    self.expanded_row(syntax, id, entry)
310                } else {
311                    self.compact_row(syntax, id, entry)
312                })
313            })
314            .collect::<Vec<_>>();
315        syntax.rows(title, focused, rows.into_iter())
316    }
317
318    /// States one request in one line: how far it has come, what it is, and where it stands.
319    fn compact_row<Syntax>(&self, syntax: &Syntax, id: This::Id, entry: &This::Entry) -> Syntax::Row
320    where
321        Syntax: ScreenSyntax,
322    {
323        let selected = self.selected_queue_id() == Some(id);
324        let emphasis = if selected {
325            Emphasis::Selected
326        } else {
327            entry.phase().phase_emphasis()
328        };
329        let marker = if selected { "▸" } else { " " };
330        let tail = format!(
331            " {:>3}% {:<width$} {:<12} {}",
332            entry.percent().unwrap_or(0),
333            elided(entry.label(), COMPACT_TITLE_COLUMNS),
334            entry.phase().phase_label(),
335            self.transfer_summary_text(entry),
336            width = COMPACT_TITLE_COLUMNS,
337        );
338        syntax.row(
339            [syntax.line(
340                [
341                    syntax.text(
342                        format!("{marker} {} ", entry.phase().phase_marker()),
343                        emphasis,
344                    ),
345                    syntax.gauge(Gauge::of(entry.percent().unwrap_or(0), GAUGE_WIDTH)),
346                    syntax.text(tail, emphasis),
347                ]
348                .into_iter(),
349            )]
350            .into_iter(),
351        )
352    }
353
354    /// States everything one request holds, and everything that can be done to it.
355    fn expanded_row<Syntax>(
356        &self,
357        syntax: &Syntax,
358        id: This::Id,
359        entry: &This::Entry,
360    ) -> Syntax::Row
361    where
362        Syntax: ScreenSyntax,
363    {
364        let selected = self.selected_queue_id() == Some(id);
365        let control = self.selected_detail_control();
366        let heading = if selected && control.is_none() {
367            Emphasis::Selected
368        } else {
369            Emphasis::Heading
370        };
371        let percent = entry.percent().unwrap_or(0);
372        let head = syntax.line(
373            [
374                syntax.text(
375                    format!(
376                        "{} {} ",
377                        if selected { EXPANDED_MARK } else { " " },
378                        entry.phase().phase_marker()
379                    ),
380                    heading,
381                ),
382                syntax.gauge(Gauge::of(percent, GAUGE_WIDTH)),
383                syntax.text(
384                    format!(
385                        " {percent:>3}% {}  {}",
386                        elided(entry.label(), EXPANDED_TITLE_COLUMNS),
387                        entry.phase().phase_label()
388                    ),
389                    heading,
390                ),
391            ]
392            .into_iter(),
393        );
394        let offered = entry.detail_controls();
395        let mut lines = vec![
396            head,
397            self.input_line(syntax, id, entry),
398            self.output_line(syntax, id, entry),
399        ];
400        // What the choice is called depends on what is being chosen: one representation of a
401        // media item, or one way of transferring a folder.
402        if entry.performer() == Performer::Retrieval || entry.chosen_choice().is_some() {
403            lines.push(choice_line(
404                syntax,
405                DetailControl::Format,
406                choice_label(entry.performer()),
407                self.format_choice_parts(entry),
408                control,
409            ));
410        }
411        // A request performed by naming a program shows the command Space would run, so the
412        // fields above are read as what builds it. It is long, and the row states as much of it
413        // as a column holds; activating the row states the whole of it, on its own.
414        if let Some(command) = entry.stated_command() {
415            lines.push(control_line(
416                syntax,
417                DetailControl::Command,
418                DetailControl::Command.control_label(),
419                elided(&command, FIELD_VALUE_COLUMNS),
420                control,
421            ));
422        }
423        // What the request is comes first, then how far along it is: the fields above build the
424        // command, and the fields below watch it run.
425        lines.extend([
426            field_line(syntax, "State", entry.phase().phase_label()),
427            field_line(syntax, "Downloaded", progress_text(entry)),
428            field_line(syntax, "Speed", speed_text(entry)),
429            field_line(syntax, "Elapsed", duration_label(entry.transfer_elapsed())),
430            field_line(syntax, "Estimated", estimate_text(entry)),
431        ]);
432        if let Some(failure) = entry.transfer_summary() {
433            lines.push(failure_line(syntax, failure));
434        } else if let Some(detail) = entry.transfer_detail() {
435            lines.push(field_line(syntax, "Note", detail));
436        }
437        if offered.contains(&DetailControl::Report) {
438            lines.push(control_line(
439                syntax,
440                DetailControl::Report,
441                DetailControl::Report.control_label(),
442                report_summary(entry),
443                control,
444            ));
445        }
446        // The record is a field stating its most recent note; activating it opens the whole record.
447        let latest = entry.download_log().last().unwrap_or_default();
448        lines.push(control_line(
449            syntax,
450            DetailControl::Log,
451            DetailControl::Log.control_label(),
452            latest.trim().to_owned(),
453            control,
454        ));
455        let dry_run = entry.dry_run();
456        lines.extend(
457            offered
458                .into_iter()
459                .filter(|offered| !offered.states_value())
460                .map(|offered| action_line(syntax, offered, control, dry_run)),
461        );
462        syntax.row(lines.into_iter())
463    }
464
465    /// States where the request comes from, or the draft renaming that.
466    fn input_line<Syntax>(&self, syntax: &Syntax, id: This::Id, entry: &This::Entry) -> Syntax::Line
467    where
468        Syntax: ScreenSyntax,
469    {
470        let label = end_labels(entry.output_naming()).0;
471        if self.page() == ManagerPage::Input(id)
472            && let Some((text, cursor)) = self.active_text_editor()
473        {
474            return syntax.edited_line(field_prefix(true, label), text, cursor, Emphasis::Selected);
475        }
476        // An input nothing may change is stated, not offered.
477        if entry.detail_controls().contains(&DetailControl::Input) {
478            return control_line(
479                syntax,
480                DetailControl::Input,
481                label,
482                entry.source(),
483                self.selected_detail_control(),
484            );
485        }
486        field_line(syntax, label, entry.source())
487    }
488
489    /// States where the request will be published, or the draft renaming it.
490    fn output_line<Syntax>(
491        &self,
492        syntax: &Syntax,
493        id: This::Id,
494        entry: &This::Entry,
495    ) -> Syntax::Line
496    where
497        Syntax: ScreenSyntax,
498    {
499        let control = self.selected_detail_control();
500        if self.page() == ManagerPage::Output(id)
501            && let Some((text, cursor)) = self.active_text_editor()
502        {
503            return syntax.edited_line(
504                field_prefix(true, end_labels(entry.output_naming()).1),
505                text,
506                cursor,
507                Emphasis::Selected,
508            );
509        }
510        let output = entry
511            .transfer_destination()
512            .or_else(|| entry.output())
513            .map_or_else(
514                || "derived from media ID".to_owned(),
515                |path| path.display().to_string(),
516            );
517        control_line(
518            syntax,
519            DetailControl::Output,
520            end_labels(entry.output_naming()).1,
521            output,
522            control,
523        )
524    }
525
526    /// States what the request chose: what it is called, and what choosing it does.
527    ///
528    /// A choice a reader would not recognize by name says what it does, and says it right after
529    /// its name: the two are one phrase, and a column between them would break it in half.
530    fn format_choice_parts(&self, entry: &This::Entry) -> (String, String) {
531        let Some(fixed) = entry.chosen_choice() else {
532            return (
533                entry.media_streams().map_or_else(
534                    || "the folder as it stands".to_owned(),
535                    |streams| format!("Best matching {}", streams.streams_label()),
536                ),
537                String::new(),
538            );
539        };
540        entry
541            .described_formats()
542            .find(|format| format.format_identity() == fixed)
543            .map_or_else(
544                || {
545                    (
546                        fixed.to_owned(),
547                        entry.choice_summary(fixed).unwrap_or_default().to_owned(),
548                    )
549                },
550                |format| (format.format_label(), String::new()),
551            )
552    }
553
554    /// States the whole command one request would run, and nothing else.
555    fn command_body<Syntax>(&self, syntax: &Syntax, id: This::Id) -> Syntax::Body
556    where
557        Syntax: ScreenSyntax,
558    {
559        self.queue_entry(id)
560            .and_then(QueueEntryAlg::stated_command)
561            .map_or_else(
562                || syntax.message("Command", "This request runs no command", Emphasis::Muted),
563                |command| syntax.verbatim(command),
564            )
565    }
566
567    /// States what a transfer has moved and how quickly, in one phrase.
568    fn transfer_summary_text(&self, entry: &This::Entry) -> String {
569        let speed = speed_text(entry);
570        if speed == UNSTATED {
571            progress_text(entry)
572        } else {
573            format!("{}  {speed}", progress_text(entry))
574        }
575    }
576
577    /// States the choices one request offers: the roles first, then everything discovered.
578    fn formats_body<Syntax>(&self, syntax: &Syntax, id: This::Id) -> Syntax::Body
579    where
580        Syntax: ScreenSyntax,
581    {
582        let Some(entry) = self.queue_entry(id) else {
583            return syntax.message("Formats", "Queue entry no longer exists", Emphasis::Muted);
584        };
585        // A folder offers every way it may be transferred, and never waits to find out what they
586        // are: only a media item has a catalog to discover.
587        let discovered = entry.performer() == Performer::Retrieval;
588        match entry.discovery() {
589            DiscoveryState::Unrequested | DiscoveryState::Waiting if discovered => {
590                return syntax.message(
591                    "Formats",
592                    "Waiting to inspect source formats…",
593                    Emphasis::Plain,
594                );
595            }
596            DiscoveryState::Inspecting if discovered => {
597                return syntax.message("Formats", "Inspecting source formats…", Emphasis::Plain);
598            }
599            DiscoveryState::Failed(message) if discovered => {
600                return syntax.message("Format inspection failed", message, Emphasis::Failed);
601            }
602            DiscoveryState::Unrequested
603            | DiscoveryState::Waiting
604            | DiscoveryState::Inspecting
605            | DiscoveryState::Failed(_)
606            | DiscoveryState::Described => {}
607        }
608        // A media item offers its preferred roles before every representation discovered for it;
609        // a folder offers only the ways it may be transferred.
610        let offered = if discovered {
611            entry
612                .offered_streams()
613                .into_iter()
614                .map(|streams| format!("Best {}", streams.streams_label()))
615                .chain(entry.described_formats().map(FormatLabelExt::format_label))
616                .collect::<Vec<_>>()
617        } else {
618            entry
619                .selectable_choices()
620                .map(|choice| choice_text(entry, choice))
621                .collect::<Vec<_>>()
622        };
623        let chosen = self.chosen_choice_row(entry, discovered);
624        let rows =
625            offered
626                .into_iter()
627                .enumerate()
628                .map(|(index, label)| {
629                    let selected = index == chosen;
630                    let marker = if selected {
631                        format!("{CURSOR_MARK} ")
632                    } else {
633                        "  ".to_owned()
634                    };
635                    let emphasis = if selected {
636                        Emphasis::Selected
637                    } else {
638                        Emphasis::Plain
639                    };
640                    syntax.row(
641                        [syntax
642                            .line([syntax.text(format!("{marker}{label}"), emphasis)].into_iter())]
643                        .into_iter(),
644                    )
645                })
646                .collect::<Vec<_>>();
647
648        syntax.rows(
649            format!("{} ({})", choice_label(entry.performer()), rows.len()),
650            Some(chosen),
651            rows.into_iter(),
652        )
653    }
654
655    /// States which offered row the request's current choice rests on.
656    fn chosen_choice_row(&self, entry: &This::Entry, discovered: bool) -> usize {
657        let Some(fixed) = entry.chosen_choice() else {
658            return entry
659                .offered_streams()
660                .into_iter()
661                .position(|streams| Some(streams) == entry.media_streams())
662                .unwrap_or(0);
663        };
664        let stated = entry
665            .selectable_choices()
666            .position(|choice| choice == fixed)
667            .unwrap_or(0);
668        // A media item states its roles before its representations; a folder states no roles.
669        if discovered {
670            stated + entry.offered_streams().len()
671        } else {
672            stated
673        }
674    }
675
676    /// States what the rehearsal said one request would do.
677    fn report_body<Syntax>(&self, syntax: &Syntax, id: This::Id) -> Syntax::Body
678    where
679        Syntax: ScreenSyntax,
680    {
681        self.queue_entry(id).map_or_else(
682            || syntax.message("Report", "Queue entry no longer exists", Emphasis::Muted),
683            |entry| report_body(syntax, entry),
684        )
685    }
686
687    /// States everything observed about one request, in the order it was observed.
688    fn record_body<Syntax>(&self, syntax: &Syntax, id: This::Id) -> Syntax::Body
689    where
690        Syntax: ScreenSyntax,
691    {
692        let Some(entry) = self.queue_entry(id) else {
693            return syntax.message("Log", "Queue entry no longer exists", Emphasis::Muted);
694        };
695        let notes = entry.download_log().map(str::to_owned).collect::<Vec<_>>();
696        syntax.record(format!("Log ({})", notes.len()), notes.into_iter())
697    }
698
699    /// States the sources draft under edit, and the shapes a source may be written in.
700    fn draft_body<Syntax>(&self, syntax: &Syntax) -> Syntax::Body
701    where
702        Syntax: ScreenSyntax,
703    {
704        let (text, cursor) = self.active_text_editor().unwrap_or(("", 0));
705        syntax.draft(
706            "Sources — editing",
707            "Enter or paste one source per line…",
708            text,
709            cursor,
710            SUBMISSION_EXAMPLES
711                .into_iter()
712                .map(|(shape, means)| ((*shape).to_owned(), (*means).to_owned())),
713        )
714    }
715}
716
717/// States everything one transfer would do, the changes it would make before what it would leave.
718fn report_body<Syntax, Entry>(syntax: &Syntax, entry: &Entry) -> Syntax::Body
719where
720    Syntax: ScreenSyntax,
721    Entry: RehearsalViewAlg,
722    Entry::Change: PlannedChangeAlg,
723{
724    match entry.rehearsal() {
725        RehearsalState::Unrehearsed => {
726            return syntax.message(
727                "Report",
728                "This request has not been rehearsed yet.",
729                Emphasis::Plain,
730            );
731        }
732        RehearsalState::Rehearsing => {
733            return syntax.message(
734                "Report",
735                "Stating what the transfer would do…",
736                Emphasis::Plain,
737            );
738        }
739        RehearsalState::Failed(message) => {
740            return syntax.message("Rehearsal failed", message, Emphasis::Failed);
741        }
742        RehearsalState::Reported => {}
743    }
744    let mut changes = entry.planned_changes().collect::<Vec<_>>();
745    // What the transfer would alter is read first; what it would leave stands under it.
746    changes.sort_by_key(|change| usize::from(!change.change_kind().alters()));
747    let altered = changes
748        .iter()
749        .filter(|change| change.change_kind().alters())
750        .count();
751    let rows = changes
752        .iter()
753        .map(|change| {
754            let kind = change.change_kind();
755            syntax.row(
756                [syntax.line(
757                    [syntax.text(
758                        format!(
759                            "  {} {:<9} {:>10}  {}",
760                            kind.change_marker(),
761                            kind.change_label(),
762                            change
763                                .change_size()
764                                .map_or_else(|| UNSTATED.to_owned(), bytes_label),
765                            change.change_path()
766                        ),
767                        kind.change_emphasis(),
768                    )]
769                    .into_iter(),
770                )]
771                .into_iter(),
772            )
773        })
774        .collect::<Vec<_>>();
775    syntax.rows(
776        format!("Report ({altered} of {} would change)", changes.len()),
777        None,
778        rows.into_iter(),
779    )
780}
781
782/// States one field naming a value nothing can be done to.
783fn field_line<Syntax>(syntax: &Syntax, label: &str, value: impl Into<String>) -> Syntax::Line
784where
785    Syntax: ScreenSyntax,
786{
787    syntax.line(
788        [
789            syntax.text(
790                format!("    {label:<FIELD_LABEL_COLUMNS$}"),
791                Emphasis::Label,
792            ),
793            syntax.text(value, Emphasis::Plain),
794        ]
795        .into_iter(),
796    )
797}
798
799/// States why a transfer failed.
800fn failure_line<Syntax>(syntax: &Syntax, failure: &str) -> Syntax::Line
801where
802    Syntax: ScreenSyntax,
803{
804    syntax.line(
805        [
806            syntax.text(
807                format!("    {:<FIELD_LABEL_COLUMNS$}", "Error"),
808                Emphasis::Failed,
809            ),
810            syntax.text(failure, Emphasis::Failed),
811        ]
812        .into_iter(),
813    )
814}
815
816/// States one field a cursor can rest on, naming the value it holds.
817///
818/// A value that can no longer be changed is marked by nothing at all: the cursor cannot reach it,
819/// and a field the cursor walks past is already saying so.
820fn control_line<Syntax>(
821    syntax: &Syntax,
822    control: DetailControl,
823    label: &str,
824    value: impl Into<String>,
825    selected: Option<DetailControl>,
826) -> Syntax::Line
827where
828    Syntax: ScreenSyntax,
829{
830    let chosen = selected == Some(control);
831    let emphasis = match (chosen, control) {
832        (true, _) => Emphasis::Selected,
833        // The record states its latest note without competing with the values above it.
834        (false, DetailControl::Log) => Emphasis::Muted,
835        (false, _) => Emphasis::Plain,
836    };
837    syntax.line(
838        [syntax.text(
839            format!("{}{}", field_prefix(chosen, label), value.into()),
840            emphasis,
841        )]
842        .into_iter(),
843    )
844}
845
846/// States one editable control whose value names a choice and then says what choosing it does.
847///
848/// The name is what a reader picks between, so it carries the weight; what it does follows it
849/// directly, quietly, as the rest of the same phrase.
850fn choice_line<Syntax>(
851    syntax: &Syntax,
852    control: DetailControl,
853    label: &str,
854    (choice, summary): (String, String),
855    selected: Option<DetailControl>,
856) -> Syntax::Line
857where
858    Syntax: ScreenSyntax,
859{
860    let chosen = selected == Some(control);
861    // The cursor takes the whole row; otherwise the name labels what follows it, and what
862    // follows it stays quiet.
863    let (field, name, rest) = if chosen {
864        (Emphasis::Selected, Emphasis::Selected, Emphasis::Selected)
865    } else {
866        (Emphasis::Plain, Emphasis::Label, Emphasis::Muted)
867    };
868    let mut runs = vec![
869        syntax.text(field_prefix(chosen, label), field),
870        syntax.text(choice, name),
871    ];
872    if !summary.is_empty() {
873        runs.push(syntax.text(format!(" {summary}"), rest));
874    }
875    syntax.line(runs.into_iter())
876}
877
878/// States one action a cursor can rest on.
879///
880/// The rehearsal row is the exception: it states which mode the request is *in* rather than what
881/// pressing it does, and carries the weight of being in it. Rehearsing is safe; being armed is a
882/// caution. Activating the row moves between them.
883fn action_line<Syntax>(
884    syntax: &Syntax,
885    control: DetailControl,
886    selected: Option<DetailControl>,
887    dry_run: Option<bool>,
888) -> Syntax::Line
889where
890    Syntax: ScreenSyntax,
891{
892    let chosen = selected == Some(control);
893    let (label, consequence) = match (control, dry_run) {
894        (DetailControl::DryRun, Some(true)) => (REHEARSING_MODE, Emphasis::Safe),
895        (DetailControl::DryRun, Some(false) | None) => (ARMED_MODE, Emphasis::Caution),
896        (control, _) => (control.control_label(), Emphasis::Plain),
897    };
898    let emphasis = if chosen {
899        Emphasis::Selected
900    } else {
901        consequence
902    };
903    syntax
904        .line([syntax.text(format!("  {} [{label}]", cursor_marker(chosen)), emphasis)].into_iter())
905}
906
907/// States what a rehearsal found, counted by what it would do.
908fn report_summary<Entry>(entry: &Entry) -> String
909where
910    Entry: RehearsalViewAlg,
911    Entry::Change: PlannedChangeAlg,
912{
913    match entry.rehearsal() {
914        RehearsalState::Unrehearsed => "not rehearsed yet".to_owned(),
915        RehearsalState::Rehearsing => "stating what would happen…".to_owned(),
916        RehearsalState::Failed(message) => format!("rehearsal failed: {message}"),
917        RehearsalState::Reported => {
918            let counted = ChangeKind::REPORTED
919                .into_iter()
920                .filter_map(|kind| {
921                    let count = entry
922                        .planned_changes()
923                        .filter(|change| change.change_kind() == kind)
924                        .count();
925                    (count > 0).then(|| format!("{count} {}", kind.change_label()))
926                })
927                .collect::<Vec<_>>();
928            if counted.is_empty() {
929                "nothing to transfer".to_owned()
930            } else {
931                counted.join(", ")
932            }
933        }
934    }
935}
936
937/// States the cursor and the label one selectable field begins with.
938fn field_prefix(chosen: bool, label: &str) -> String {
939    format!("  {} {label:<FIELD_LABEL_COLUMNS$}", cursor_marker(chosen))
940}
941
942/// Names the two ends of one request the way a reader reads them.
943///
944/// A media item is fetched from a source and written to a file this application names; a transfer
945/// goes from an input to an output, both of them stated by whoever asked for it.
946const fn end_labels(naming: OutputNaming) -> (&'static str, &'static str) {
947    match naming {
948        OutputNaming::Portable => ("Source", "File name"),
949        OutputNaming::Stated => ("Input", "Output"),
950    }
951}
952
953/// Names what a request chooses between, which is not the same thing for every request.
954const fn choice_label(performer: Performer) -> &'static str {
955    match performer {
956        Performer::Retrieval => "Format",
957        Performer::Program => "Transfer",
958    }
959}
960
961/// States one offered choice in aligned columns, so alternatives are compared by reading down.
962fn choice_text<Entry>(entry: &Entry, choice: &str) -> String
963where
964    Entry: RequestOptionsAlg,
965{
966    entry.choice_summary(choice).map_or_else(
967        || choice.to_owned(),
968        |summary| format!("{choice:<CHOICE_KEY_COLUMNS$} {summary}"),
969    )
970}
971
972/// States whether the cursor rests here.
973///
974/// The mark is a small triangle rather than a large one: the large triangles carry an emoji
975/// presentation, which a reader's font may draw two columns wide and shift the row it marks.
976const fn cursor_marker(chosen: bool) -> &'static str {
977    if chosen { "▸" } else { " " }
978}
979
980/// States how much of the transfer has arrived.
981fn progress_text<Entry>(entry: &Entry) -> String
982where
983    Entry: TransferViewAlg,
984{
985    match entry.transfer_total() {
986        Some(total) => format!(
987            "{} / {}",
988            bytes_label(entry.transferred()),
989            bytes_label(total)
990        ),
991        None if entry.transferred() > 0 => {
992            format!("{} downloaded", bytes_label(entry.transferred()))
993        }
994        None => UNSTATED.to_owned(),
995    }
996}
997
998/// States how quickly the transfer is arriving.
999fn speed_text<Entry>(entry: &Entry) -> String
1000where
1001    Entry: TransferViewAlg,
1002{
1003    entry.bytes_per_second().map_or_else(
1004        || UNSTATED.to_owned(),
1005        |speed| format!("{}/s", bytes_label(speed)),
1006    )
1007}
1008
1009/// States how long the transfer still needs.
1010fn estimate_text<Entry>(entry: &Entry) -> String
1011where
1012    Entry: TransferViewAlg,
1013{
1014    entry
1015        .estimated_remaining()
1016        .map_or_else(|| UNSTATED.to_owned(), duration_label)
1017}