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)
}