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