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(&self, prefix: impl Into<String>, text: &str, cursor: usize, emphasis: Emphasis) -> Self::Line;
32
33 fn row(&self, lines: impl Iterator<Item = Self::Line>) -> Self::Row;
35
36 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 fn message(&self, title: impl Into<String>, message: impl Into<String>, emphasis: Emphasis) -> Self::Body;
49
50 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 fn record(&self, title: impl Into<String>, notes: impl Iterator<Item = String>) -> Self::Body;
66
67 fn verbatim(&self, text: impl Into<String>) -> Self::Body;
73
74 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 fn screen_text(&self, screen: &Self::Screen) -> impl Iterator<Item = String>;
88}
89
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub struct Application<'a> {
97 pub name: &'a str,
99 pub version: &'a str,
101}
102
103pub const GAUGE_WIDTH: usize = 8;
105
106pub const COMPACT_TITLE_COLUMNS: usize = 34;
108
109pub const EXPANDED_TITLE_COLUMNS: usize = 46;
111
112pub const FIELD_LABEL_COLUMNS: usize = 11;
114
115pub const FIELD_VALUE_COLUMNS: usize = 83;
117
118pub const CHOICE_KEY_COLUMNS: usize = 15;
120
121pub const CURSOR_MARK: &str = "▸";
123
124pub const EXPANDED_MARK: &str = "▾";
126
127pub const PATH_SEPARATOR: &str = " › ";
129
130pub const REHEARSING_MODE: &str = "Dry run — changes nothing";
132
133pub const ARMED_MODE: &str = "\u{26A0}\u{FE0F} Real run — writes files";
140
141pub 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#[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 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 fn header_line<Syntax>(&self, syntax: &Syntax, application: Application<'_>) -> Syntax::Line
186 where
187 Syntax: ScreenSyntax,
188 {
189 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if discovered { stated + entry.offered_streams().len() } else { stated }
549 }
550
551 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 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 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
590fn 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 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
638fn 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
652fn 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
666fn 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 (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
690fn 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 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
719fn 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
743fn 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
766fn field_prefix(chosen: bool, label: &str) -> String {
768 format!(" {} {label:<FIELD_LABEL_COLUMNS$}", cursor_marker(chosen))
769}
770
771const 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
782const fn choice_label(performer: Performer) -> &'static str {
784 match performer {
785 Performer::Retrieval => "Format",
786 Performer::Program => "Transfer",
787 }
788}
789
790fn 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
800const fn cursor_marker(chosen: bool) -> &'static str {
805 if chosen { "▸" } else { " " }
806}
807
808fn 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
822fn 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
830fn estimate_text<Entry>(entry: &Entry) -> String
832where
833 Entry: TransferViewAlg,
834{
835 entry.estimated_remaining().map_or_else(|| UNSTATED.to_owned(), duration_label)
836}