use crate::*;
use alux_ext::ext;
use rsynko_manager::*;
pub trait ScreenSyntax {
type Text;
type Line;
type Row;
type Body;
type Screen;
fn text(&self, content: impl Into<String>, emphasis: Emphasis) -> Self::Text;
fn gauge(&self, gauge: Gauge) -> Self::Text;
fn line(&self, runs: impl Iterator<Item = Self::Text>) -> Self::Line;
fn edited_line(&self, prefix: impl Into<String>, text: &str, cursor: usize, emphasis: Emphasis) -> Self::Line;
fn row(&self, lines: impl Iterator<Item = Self::Line>) -> Self::Row;
fn rows(
&self,
title: impl Into<String>,
focused: Option<usize>,
rows: impl Iterator<Item = Self::Row>,
) -> Self::Body;
fn message(&self, title: impl Into<String>, message: impl Into<String>, emphasis: Emphasis) -> Self::Body;
fn draft(
&self,
title: impl Into<String>,
placeholder: impl Into<String>,
text: &str,
cursor: usize,
examples: impl Iterator<Item = (String, String)>,
) -> Self::Body;
fn record(&self, title: impl Into<String>, notes: impl Iterator<Item = String>) -> Self::Body;
fn verbatim(&self, text: impl Into<String>) -> Self::Body;
fn screen(
&self,
header: Self::Line,
body: Self::Body,
status: impl Into<String>,
footer: Self::Line,
) -> Self::Screen;
fn screen_text(&self, screen: &Self::Screen) -> impl Iterator<Item = String>;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Application<'a> {
pub name: &'a str,
pub version: &'a str,
}
pub const GAUGE_WIDTH: usize = 8;
pub const COMPACT_TITLE_COLUMNS: usize = 34;
pub const EXPANDED_TITLE_COLUMNS: usize = 46;
pub const FIELD_LABEL_COLUMNS: usize = 11;
pub const FIELD_VALUE_COLUMNS: usize = 83;
pub const CHOICE_KEY_COLUMNS: usize = 15;
pub const CURSOR_MARK: &str = "▸";
pub const EXPANDED_MARK: &str = "▾";
pub const PATH_SEPARATOR: &str = " › ";
pub const REHEARSING_MODE: &str = "Dry run — changes nothing";
pub const ARMED_MODE: &str = "\u{26A0}\u{FE0F} Real run — writes files";
pub const SUBMISSION_EXAMPLES: [(&str, &str); 6] = [
("/home/dev/photos/2026", "a folder here, into a folder of the same name"),
("backup@nas.local:/volume1/photos /home/dev/photos", "a folder on another machine, into a folder here"),
("/home/dev/photos backup@nas.local:/volume1/photos", "the same transfer, the other way around"),
("rsync -a nas.local:/srv/data /mnt/data", "a whole command, read as the two ends it names"),
("https://www.youtube.com/watch?v=VIDEO_ID", "a web address a source recognizes, fetched instead"),
("https://x.com/user/status/1234567890", "a tweet, taking the media it carries"),
];
#[ext(name = ManagerScreenExt)]
pub impl<This> This
where
This: NavigationStateAlg
+ QueueCatalogAlg
+ DetailSelectionAlg
+ ManagerStatusAlg
+ TextEditorStateAlg
+ DraftStateAlg
+ OutputDraftAlg
+ InputDraftAlg,
This::Entry: QueueEntryAlg
+ RequestOptionsAlg
+ TransferViewAlg
+ FormatChoiceViewAlg<Format: FormatDescriptionAlg>
+ RehearsalViewAlg<Change: PlannedChangeAlg>,
This::Id: Copy + Eq,
{
fn screen<Syntax>(&self, syntax: &Syntax, application: Application<'_>) -> Syntax::Screen
where
Syntax: ScreenSyntax,
{
let header = self.header_line(syntax, application);
let footer = self.footer_line(syntax);
let status = self.manager_message().unwrap_or("Actions").to_owned();
syntax.screen(header, self.body(syntax), status, footer)
}
fn header_line<Syntax>(&self, syntax: &Syntax, application: Application<'_>) -> Syntax::Line
where
Syntax: ScreenSyntax,
{
let path = self
.breadcrumbs()
.into_iter()
.skip(1)
.map(|breadcrumb| breadcrumb.label)
.collect::<Vec<_>>()
.join(PATH_SEPARATOR);
syntax.line(
[
syntax.text(format!(" {} ", application.name), Emphasis::Name),
syntax.text(format!(" {} ", application.version), Emphasis::Muted),
syntax.text(format!(" {path}"), Emphasis::Plain),
]
.into_iter(),
)
}
fn footer_line<Syntax>(&self, syntax: &Syntax) -> Syntax::Line
where
Syntax: ScreenSyntax,
{
let runs = self
.menu_items()
.enumerate()
.flat_map(|(index, item)| {
let emphasis = match item.availability {
ActionAvailability::Enabled => Emphasis::Plain,
ActionAvailability::Disabled => Emphasis::Muted,
};
let separator = (index > 0).then(|| syntax.text(" ", Emphasis::Plain));
separator.into_iter().chain([syntax.text(item.label(), emphasis)])
})
.collect::<Vec<_>>();
syntax.line(runs.into_iter())
}
fn body<Syntax>(&self, syntax: &Syntax) -> Syntax::Body
where
Syntax: ScreenSyntax,
{
match self.page() {
ManagerPage::Collection => self.collection_body(syntax, None),
ManagerPage::Details(id) | ManagerPage::Output(id) | ManagerPage::Input(id) => {
self.collection_body(syntax, Some(id))
}
ManagerPage::AddSources => self.draft_body(syntax),
ManagerPage::Formats(id) => self.formats_body(syntax, id),
ManagerPage::Log(id) => self.record_body(syntax, id),
ManagerPage::Report(id) => self.report_body(syntax, id),
ManagerPage::Command(id) => self.command_body(syntax, id),
}
}
fn collection_body<Syntax>(&self, syntax: &Syntax, expanded: Option<This::Id>) -> Syntax::Body
where
Syntax: ScreenSyntax,
{
let ids = self.queue_ids().collect::<Vec<_>>();
let title = format!("{COLLECTION} ({})", ids.len());
if ids.is_empty() {
let add = self
.action_keys(ManagerAction::AddSources)
.next()
.map_or_else(|| UNSTATED.to_owned(), Keystroke::label);
let empty = syntax.row(
[syntax.line(
[syntax.text(format!(" No sources. Press [{add}] to add or paste sources."), Emphasis::Plain)]
.into_iter(),
)]
.into_iter(),
);
return syntax.rows(title, None, [empty].into_iter());
}
let focused = self.selected_queue_id().and_then(|selected| ids.iter().position(|id| *id == selected));
let rows = ids
.into_iter()
.filter_map(|id| {
let entry = self.queue_entry(id)?;
Some(if expanded == Some(id) {
self.expanded_row(syntax, id, entry)
} else {
self.compact_row(syntax, id, entry)
})
})
.collect::<Vec<_>>();
syntax.rows(title, focused, rows.into_iter())
}
fn compact_row<Syntax>(&self, syntax: &Syntax, id: This::Id, entry: &This::Entry) -> Syntax::Row
where
Syntax: ScreenSyntax,
{
let selected = self.selected_queue_id() == Some(id);
let emphasis = if selected { Emphasis::Selected } else { entry.phase().phase_emphasis() };
let marker = if selected { "▸" } else { " " };
let tail = format!(
" {:>3}% {:<width$} {:<12} {}",
entry.percent().unwrap_or(0),
elided(entry.label(), COMPACT_TITLE_COLUMNS),
entry.phase().phase_label(),
self.transfer_summary_text(entry),
width = COMPACT_TITLE_COLUMNS,
);
syntax.row(
[syntax.line(
[
syntax.text(format!("{marker} {} ", entry.phase().phase_marker()), emphasis),
syntax.gauge(Gauge::of(entry.percent().unwrap_or(0), GAUGE_WIDTH)),
syntax.text(tail, emphasis),
]
.into_iter(),
)]
.into_iter(),
)
}
fn expanded_row<Syntax>(&self, syntax: &Syntax, id: This::Id, entry: &This::Entry) -> Syntax::Row
where
Syntax: ScreenSyntax,
{
let selected = self.selected_queue_id() == Some(id);
let control = self.selected_detail_control();
let heading = if selected && control.is_none() { Emphasis::Selected } else { Emphasis::Heading };
let percent = entry.percent().unwrap_or(0);
let head = syntax.line(
[
syntax.text(
format!("{} {} ", if selected { EXPANDED_MARK } else { " " }, entry.phase().phase_marker()),
heading,
),
syntax.gauge(Gauge::of(percent, GAUGE_WIDTH)),
syntax.text(
format!(
" {percent:>3}% {} {}",
elided(entry.label(), EXPANDED_TITLE_COLUMNS),
entry.phase().phase_label()
),
heading,
),
]
.into_iter(),
);
let offered = entry.detail_controls();
let mut lines = vec![head, self.input_line(syntax, id, entry), self.output_line(syntax, id, entry)];
if entry.performer() == Performer::Retrieval || entry.chosen_choice().is_some() {
lines.push(choice_line(
syntax,
DetailControl::Format,
choice_label(entry.performer()),
self.format_choice_parts(entry),
control,
));
}
if let Some(command) = entry.stated_command() {
lines.push(control_line(
syntax,
DetailControl::Command,
DetailControl::Command.control_label(),
elided(&command, FIELD_VALUE_COLUMNS),
control,
));
}
lines.extend([
field_line(syntax, "State", entry.phase().phase_label()),
field_line(syntax, "Downloaded", progress_text(entry)),
field_line(syntax, "Speed", speed_text(entry)),
field_line(syntax, "Elapsed", duration_label(entry.transfer_elapsed())),
field_line(syntax, "Estimated", estimate_text(entry)),
]);
if let Some(failure) = entry.transfer_summary() {
lines.push(failure_line(syntax, failure));
} else if let Some(detail) = entry.transfer_detail() {
lines.push(field_line(syntax, "Note", detail));
}
if offered.contains(&DetailControl::Report) {
lines.push(control_line(
syntax,
DetailControl::Report,
DetailControl::Report.control_label(),
report_summary(entry),
control,
));
}
let latest = entry.download_log().last().unwrap_or_default();
lines.push(control_line(
syntax,
DetailControl::Log,
DetailControl::Log.control_label(),
latest.trim().to_owned(),
control,
));
let dry_run = entry.dry_run();
lines.extend(
offered
.into_iter()
.filter(|offered| !offered.states_value())
.map(|offered| action_line(syntax, offered, control, dry_run)),
);
syntax.row(lines.into_iter())
}
fn input_line<Syntax>(&self, syntax: &Syntax, id: This::Id, entry: &This::Entry) -> Syntax::Line
where
Syntax: ScreenSyntax,
{
let label = end_labels(entry.output_naming()).0;
if self.page() == ManagerPage::Input(id)
&& let Some((text, cursor)) = self.active_text_editor()
{
return syntax.edited_line(field_prefix(true, label), text, cursor, Emphasis::Selected);
}
if entry.detail_controls().contains(&DetailControl::Input) {
return control_line(syntax, DetailControl::Input, label, entry.source(), self.selected_detail_control());
}
field_line(syntax, label, entry.source())
}
fn output_line<Syntax>(&self, syntax: &Syntax, id: This::Id, entry: &This::Entry) -> Syntax::Line
where
Syntax: ScreenSyntax,
{
let control = self.selected_detail_control();
if self.page() == ManagerPage::Output(id)
&& let Some((text, cursor)) = self.active_text_editor()
{
return syntax.edited_line(
field_prefix(true, end_labels(entry.output_naming()).1),
text,
cursor,
Emphasis::Selected,
);
}
let output = entry
.transfer_destination()
.or_else(|| entry.output())
.map_or_else(|| "derived from media ID".to_owned(), |path| path.display().to_string());
control_line(syntax, DetailControl::Output, end_labels(entry.output_naming()).1, output, control)
}
fn format_choice_parts(&self, entry: &This::Entry) -> (String, String) {
let Some(fixed) = entry.chosen_choice() else {
return (
entry.media_streams().map_or_else(
|| "the folder as it stands".to_owned(),
|streams| format!("Best matching {}", streams.streams_label()),
),
String::new(),
);
};
entry.described_formats().find(|format| format.format_identity() == fixed).map_or_else(
|| (fixed.to_owned(), entry.choice_summary(fixed).unwrap_or_default().to_owned()),
|format| (format.format_label(), String::new()),
)
}
fn command_body<Syntax>(&self, syntax: &Syntax, id: This::Id) -> Syntax::Body
where
Syntax: ScreenSyntax,
{
self.queue_entry(id).and_then(QueueEntryAlg::stated_command).map_or_else(
|| syntax.message("Command", "This request runs no command", Emphasis::Muted),
|command| syntax.verbatim(command),
)
}
fn transfer_summary_text(&self, entry: &This::Entry) -> String {
let speed = speed_text(entry);
if speed == UNSTATED { progress_text(entry) } else { format!("{} {speed}", progress_text(entry)) }
}
fn formats_body<Syntax>(&self, syntax: &Syntax, id: This::Id) -> Syntax::Body
where
Syntax: ScreenSyntax,
{
let Some(entry) = self.queue_entry(id) else {
return syntax.message("Formats", "Queue entry no longer exists", Emphasis::Muted);
};
let discovered = entry.performer() == Performer::Retrieval;
match entry.discovery() {
DiscoveryState::Unrequested | DiscoveryState::Waiting if discovered => {
return syntax.message("Formats", "Waiting to inspect source formats…", Emphasis::Plain);
}
DiscoveryState::Inspecting if discovered => {
return syntax.message("Formats", "Inspecting source formats…", Emphasis::Plain);
}
DiscoveryState::Failed(message) if discovered => {
return syntax.message("Format inspection failed", message, Emphasis::Failed);
}
DiscoveryState::Unrequested
| DiscoveryState::Waiting
| DiscoveryState::Inspecting
| DiscoveryState::Failed(_)
| DiscoveryState::Described => {}
}
let offered = if discovered {
entry
.offered_streams()
.into_iter()
.map(|streams| format!("Best {}", streams.streams_label()))
.chain(entry.described_formats().map(FormatLabelExt::format_label))
.collect::<Vec<_>>()
} else {
entry.selectable_choices().map(|choice| choice_text(entry, choice)).collect::<Vec<_>>()
};
let chosen = self.chosen_choice_row(entry, discovered);
let rows = offered
.into_iter()
.enumerate()
.map(|(index, label)| {
let selected = index == chosen;
let marker = if selected { format!("{CURSOR_MARK} ") } else { " ".to_owned() };
let emphasis = if selected { Emphasis::Selected } else { Emphasis::Plain };
syntax.row([syntax.line([syntax.text(format!("{marker}{label}"), emphasis)].into_iter())].into_iter())
})
.collect::<Vec<_>>();
syntax.rows(format!("{} ({})", choice_label(entry.performer()), rows.len()), Some(chosen), rows.into_iter())
}
fn chosen_choice_row(&self, entry: &This::Entry, discovered: bool) -> usize {
let Some(fixed) = entry.chosen_choice() else {
return entry
.offered_streams()
.into_iter()
.position(|streams| Some(streams) == entry.media_streams())
.unwrap_or(0);
};
let stated = entry.selectable_choices().position(|choice| choice == fixed).unwrap_or(0);
if discovered { stated + entry.offered_streams().len() } else { stated }
}
fn report_body<Syntax>(&self, syntax: &Syntax, id: This::Id) -> Syntax::Body
where
Syntax: ScreenSyntax,
{
self.queue_entry(id).map_or_else(
|| syntax.message("Report", "Queue entry no longer exists", Emphasis::Muted),
|entry| report_body(syntax, entry),
)
}
fn record_body<Syntax>(&self, syntax: &Syntax, id: This::Id) -> Syntax::Body
where
Syntax: ScreenSyntax,
{
let Some(entry) = self.queue_entry(id) else {
return syntax.message("Log", "Queue entry no longer exists", Emphasis::Muted);
};
let notes = entry.download_log().map(str::to_owned).collect::<Vec<_>>();
syntax.record(format!("Log ({})", notes.len()), notes.into_iter())
}
fn draft_body<Syntax>(&self, syntax: &Syntax) -> Syntax::Body
where
Syntax: ScreenSyntax,
{
let (text, cursor) = self.active_text_editor().unwrap_or(("", 0));
syntax.draft(
"Sources — editing",
"Enter or paste one source per line…",
text,
cursor,
SUBMISSION_EXAMPLES.into_iter().map(|(shape, means)| ((*shape).to_owned(), (*means).to_owned())),
)
}
}
fn report_body<Syntax, Entry>(syntax: &Syntax, entry: &Entry) -> Syntax::Body
where
Syntax: ScreenSyntax,
Entry: RehearsalViewAlg,
Entry::Change: PlannedChangeAlg,
{
match entry.rehearsal() {
RehearsalState::Unrehearsed => {
return syntax.message("Report", "This request has not been rehearsed yet.", Emphasis::Plain);
}
RehearsalState::Rehearsing => {
return syntax.message("Report", "Stating what the transfer would do…", Emphasis::Plain);
}
RehearsalState::Failed(message) => {
return syntax.message("Rehearsal failed", message, Emphasis::Failed);
}
RehearsalState::Reported => {}
}
let mut changes = entry.planned_changes().collect::<Vec<_>>();
changes.sort_by_key(|change| usize::from(!change.change_kind().alters()));
let altered = changes.iter().filter(|change| change.change_kind().alters()).count();
let rows = changes
.iter()
.map(|change| {
let kind = change.change_kind();
syntax.row(
[syntax.line(
[syntax.text(
format!(
" {} {:<9} {:>10} {}",
kind.change_marker(),
kind.change_label(),
change.change_size().map_or_else(|| UNSTATED.to_owned(), bytes_label),
change.change_path()
),
kind.change_emphasis(),
)]
.into_iter(),
)]
.into_iter(),
)
})
.collect::<Vec<_>>();
syntax.rows(format!("Report ({altered} of {} would change)", changes.len()), None, rows.into_iter())
}
fn field_line<Syntax>(syntax: &Syntax, label: &str, value: impl Into<String>) -> Syntax::Line
where
Syntax: ScreenSyntax,
{
syntax.line(
[
syntax.text(format!(" {label:<FIELD_LABEL_COLUMNS$}"), Emphasis::Label),
syntax.text(value, Emphasis::Plain),
]
.into_iter(),
)
}
fn failure_line<Syntax>(syntax: &Syntax, failure: &str) -> Syntax::Line
where
Syntax: ScreenSyntax,
{
syntax.line(
[
syntax.text(format!(" {:<FIELD_LABEL_COLUMNS$}", "Error"), Emphasis::Failed),
syntax.text(failure, Emphasis::Failed),
]
.into_iter(),
)
}
fn control_line<Syntax>(
syntax: &Syntax,
control: DetailControl,
label: &str,
value: impl Into<String>,
selected: Option<DetailControl>,
) -> Syntax::Line
where
Syntax: ScreenSyntax,
{
let chosen = selected == Some(control);
let emphasis = match (chosen, control) {
(true, _) => Emphasis::Selected,
(false, DetailControl::Log) => Emphasis::Muted,
(false, _) => Emphasis::Plain,
};
syntax.line([syntax.text(format!("{}{}", field_prefix(chosen, label), value.into()), emphasis)].into_iter())
}
fn choice_line<Syntax>(
syntax: &Syntax,
control: DetailControl,
label: &str,
(choice, summary): (String, String),
selected: Option<DetailControl>,
) -> Syntax::Line
where
Syntax: ScreenSyntax,
{
let chosen = selected == Some(control);
let (field, name, rest) = if chosen {
(Emphasis::Selected, Emphasis::Selected, Emphasis::Selected)
} else {
(Emphasis::Plain, Emphasis::Label, Emphasis::Muted)
};
let mut runs = vec![syntax.text(field_prefix(chosen, label), field), syntax.text(choice, name)];
if !summary.is_empty() {
runs.push(syntax.text(format!(" {summary}"), rest));
}
syntax.line(runs.into_iter())
}
fn action_line<Syntax>(
syntax: &Syntax,
control: DetailControl,
selected: Option<DetailControl>,
dry_run: Option<bool>,
) -> Syntax::Line
where
Syntax: ScreenSyntax,
{
let chosen = selected == Some(control);
let (label, consequence) = match (control, dry_run) {
(DetailControl::DryRun, Some(true)) => (REHEARSING_MODE, Emphasis::Safe),
(DetailControl::DryRun, Some(false) | None) => (ARMED_MODE, Emphasis::Caution),
(control, _) => (control.control_label(), Emphasis::Plain),
};
let emphasis = if chosen { Emphasis::Selected } else { consequence };
syntax.line([syntax.text(format!(" {} [{label}]", cursor_marker(chosen)), emphasis)].into_iter())
}
fn report_summary<Entry>(entry: &Entry) -> String
where
Entry: RehearsalViewAlg,
Entry::Change: PlannedChangeAlg,
{
match entry.rehearsal() {
RehearsalState::Unrehearsed => "not rehearsed yet".to_owned(),
RehearsalState::Rehearsing => "stating what would happen…".to_owned(),
RehearsalState::Failed(message) => format!("rehearsal failed: {message}"),
RehearsalState::Reported => {
let counted = ChangeKind::REPORTED
.into_iter()
.filter_map(|kind| {
let count = entry.planned_changes().filter(|change| change.change_kind() == kind).count();
(count > 0).then(|| format!("{count} {}", kind.change_label()))
})
.collect::<Vec<_>>();
if counted.is_empty() { "nothing to transfer".to_owned() } else { counted.join(", ") }
}
}
}
fn field_prefix(chosen: bool, label: &str) -> String {
format!(" {} {label:<FIELD_LABEL_COLUMNS$}", cursor_marker(chosen))
}
const fn end_labels(naming: OutputNaming) -> (&'static str, &'static str) {
match naming {
OutputNaming::Portable => ("Source", "File name"),
OutputNaming::Stated => ("Input", "Output"),
}
}
const fn choice_label(performer: Performer) -> &'static str {
match performer {
Performer::Retrieval => "Format",
Performer::Program => "Transfer",
}
}
fn choice_text<Entry>(entry: &Entry, choice: &str) -> String
where
Entry: RequestOptionsAlg,
{
entry
.choice_summary(choice)
.map_or_else(|| choice.to_owned(), |summary| format!("{choice:<CHOICE_KEY_COLUMNS$} {summary}"))
}
const fn cursor_marker(chosen: bool) -> &'static str {
if chosen { "▸" } else { " " }
}
fn progress_text<Entry>(entry: &Entry) -> String
where
Entry: TransferViewAlg,
{
match entry.transfer_total() {
Some(total) => format!("{} / {}", bytes_label(entry.transferred()), bytes_label(total)),
None if entry.transferred() > 0 => {
format!("{} downloaded", bytes_label(entry.transferred()))
}
None => UNSTATED.to_owned(),
}
}
fn speed_text<Entry>(entry: &Entry) -> String
where
Entry: TransferViewAlg,
{
entry.bytes_per_second().map_or_else(|| UNSTATED.to_owned(), |speed| format!("{}/s", bytes_label(speed)))
}
fn estimate_text<Entry>(entry: &Entry) -> String
where
Entry: TransferViewAlg,
{
entry.estimated_remaining().map_or_else(|| UNSTATED.to_owned(), duration_label)
}