1use crate::*;
2use alux_ext::ext;
3use rsynko_manager::*;
4
5pub trait ScreenSyntax {
10 type Text;
12 type Line;
14 type Row;
16 type Body;
18 type Screen;
20
21 fn text(&self, content: impl Into<String>, emphasis: Emphasis) -> Self::Text;
23
24 fn gauge(&self, gauge: Gauge) -> Self::Text;
26
27 fn line(&self, runs: impl Iterator<Item = Self::Text>) -> Self::Line;
29
30 fn edited_line(
32 &self,
33 prefix: impl Into<String>,
34 text: &str,
35 cursor: usize,
36 emphasis: Emphasis,
37 ) -> Self::Line;
38
39 fn row(&self, lines: impl Iterator<Item = Self::Line>) -> Self::Row;
41
42 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 fn message(
55 &self,
56 title: impl Into<String>,
57 message: impl Into<String>,
58 emphasis: Emphasis,
59 ) -> Self::Body;
60
61 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 fn record(&self, title: impl Into<String>, notes: impl Iterator<Item = String>) -> Self::Body;
77
78 fn verbatim(&self, text: impl Into<String>) -> Self::Body;
84
85 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 fn screen_text(&self, screen: &Self::Screen) -> impl Iterator<Item = String>;
99}
100
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
107pub struct Application<'a> {
108 pub name: &'a str,
110 pub version: &'a str,
112}
113
114pub const GAUGE_WIDTH: usize = 8;
116
117pub const COMPACT_TITLE_COLUMNS: usize = 34;
119
120pub const EXPANDED_TITLE_COLUMNS: usize = 46;
122
123pub const FIELD_LABEL_COLUMNS: usize = 11;
125
126pub const FIELD_VALUE_COLUMNS: usize = 83;
128
129pub const CHOICE_KEY_COLUMNS: usize = 15;
131
132pub const CURSOR_MARK: &str = "▸";
134
135pub const EXPANDED_MARK: &str = "▾";
137
138pub const PATH_SEPARATOR: &str = " › ";
140
141pub const REHEARSING_MODE: &str = "Dry run — changes nothing";
143
144pub const ARMED_MODE: &str = "\u{26A0}\u{FE0F} Real run — writes files";
151
152pub 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#[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 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 fn header_line<Syntax>(&self, syntax: &Syntax, application: Application<'_>) -> Syntax::Line
215 where
216 Syntax: ScreenSyntax,
217 {
218 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if discovered {
670 stated + entry.offered_streams().len()
671 } else {
672 stated
673 }
674 }
675
676 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 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 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
717fn 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 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
782fn 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
799fn 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
816fn 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 (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
846fn 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 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
878fn 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
907fn 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
937fn field_prefix(chosen: bool, label: &str) -> String {
939 format!(" {} {label:<FIELD_LABEL_COLUMNS$}", cursor_marker(chosen))
940}
941
942const 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
953const fn choice_label(performer: Performer) -> &'static str {
955 match performer {
956 Performer::Retrieval => "Format",
957 Performer::Program => "Transfer",
958 }
959}
960
961fn 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
972const fn cursor_marker(chosen: bool) -> &'static str {
977 if chosen { "▸" } else { " " }
978}
979
980fn 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
998fn 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
1009fn 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}