1use alux_ext::ext;
2use rsynko_manager::*;
3use std::time::Duration;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum Emphasis {
11 Plain,
13 Name,
15 Heading,
17 Label,
19 Selected,
21 Muted,
23 Running,
25 Held,
27 Finishing,
29 Succeeded,
31 Failed,
33 Safe,
35 Caution,
37}
38
39#[ext(name = PhaseVocabularyExt)]
41pub impl TransferPhase {
42 fn phase_label(self) -> &'static str {
44 match self {
45 Self::Ready => "Ready",
46 Self::Waiting => "Waiting",
47 Self::Rehearsing => "Rehearsing",
48 Self::Extracting => "Extracting",
49 Self::Downloading => "Downloading",
50 Self::Paused => "Paused",
51 Self::Publishing => "Publishing",
52 Self::Complete => "Complete",
53 Self::Failed => "Failed",
54 }
55 }
56
57 fn phase_emphasis(self) -> Emphasis {
59 match self {
60 Self::Ready | Self::Waiting => Emphasis::Plain,
61 Self::Rehearsing | Self::Extracting | Self::Downloading => Emphasis::Running,
62 Self::Paused => Emphasis::Held,
63 Self::Publishing => Emphasis::Finishing,
64 Self::Complete => Emphasis::Succeeded,
65 Self::Failed => Emphasis::Failed,
66 }
67 }
68
69 fn phase_marker(self) -> &'static str {
74 match self {
75 Self::Ready | Self::Paused => "○",
76 Self::Rehearsing => "◌",
77 Self::Waiting | Self::Extracting | Self::Downloading | Self::Publishing | Self::Complete | Self::Failed => {
78 "●"
79 }
80 }
81 }
82}
83
84#[ext(name = ControlVocabularyExt)]
86pub impl DetailControl {
87 fn control_label(self) -> &'static str {
89 match self {
90 Self::Input => "Input",
91 Self::Output => "File name",
92 Self::Format => "Format",
93 Self::Command => "Command",
94 Self::Restart => "Restart",
95 Self::DryRun => "Dry run",
96 Self::Report => "Report",
97 Self::Log => "Log",
98 Self::Duplicate => "Duplicate",
99 Self::Delete => "Delete",
100 }
101 }
102
103 fn states_value(self) -> bool {
105 matches!(self, Self::Input | Self::Output | Self::Format | Self::Command | Self::Report | Self::Log)
106 }
107}
108
109#[ext(name = StreamVocabularyExt)]
111pub impl MediaStreams {
112 fn streams_label(self) -> &'static str {
114 match self {
115 Self::AudioVideo => "audio + video",
116 Self::Audio => "audio only",
117 Self::Video => "video only",
118 }
119 }
120}
121
122#[ext(name = ChangeVocabularyExt)]
124pub impl ChangeKind {
125 fn change_label(self) -> &'static str {
127 match self {
128 Self::Create => "new",
129 Self::Update => "updated",
130 Self::Delete => "deleted",
131 Self::Unchanged => "unchanged",
132 }
133 }
134
135 fn change_marker(self) -> &'static str {
137 match self {
138 Self::Create => "+",
139 Self::Update => "~",
140 Self::Delete => "-",
141 Self::Unchanged => "=",
142 }
143 }
144
145 fn change_emphasis(self) -> Emphasis {
147 match self {
148 Self::Create => Emphasis::Safe,
149 Self::Update => Emphasis::Caution,
150 Self::Delete => Emphasis::Failed,
151 Self::Unchanged => Emphasis::Muted,
153 }
154 }
155}
156
157#[ext(name = SpaceVocabularyExt)]
159pub impl SpaceAction {
160 fn space_label(self) -> &'static str {
162 match self {
163 Self::Start => "Start",
164 Self::Rehearse => "Dry run",
165 Self::Pause => "Pause",
166 Self::Resume => "Resume",
167 }
168 }
169}
170
171pub const UNSTATED: &str = "—";
173
174#[must_use]
176pub fn bytes_label(bytes: u64) -> String {
177 const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
179 let mut divisor = 1_u64;
180 let mut unit = 0_usize;
181 while bytes / divisor >= 1024 && unit < UNITS.len() - 1 {
182 divisor = divisor.saturating_mul(1024);
183 unit += 1;
184 }
185 if unit == 0 {
186 format!("{bytes} {}", UNITS[unit])
187 } else {
188 let whole = bytes / divisor;
189 let tenth = bytes % divisor / (divisor / 10);
190 format!("{whole}.{tenth} {}", UNITS[unit])
191 }
192}
193
194#[must_use]
196pub fn duration_label(duration: Duration) -> String {
197 let seconds = duration.as_secs();
198 let hours = seconds / 3600;
199 let minutes = seconds % 3600 / 60;
200 let seconds = seconds % 60;
201 if hours > 0 { format!("{hours}:{minutes:02}:{seconds:02}") } else { format!("{minutes:02}:{seconds:02}") }
202}
203
204#[must_use]
206pub fn elided(text: &str, columns: usize) -> String {
207 if text.chars().count() <= columns {
208 return text.to_owned();
209 }
210 text.chars().take(columns.saturating_sub(1)).collect::<String>() + "…"
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use crate::{ARMED_MODE, CURSOR_MARK, EXPANDED_MARK, Gauge, Key, PATH_SEPARATOR, REHEARSING_MODE, UNSTATED};
217 use rsynko_manager::{ChangeKind, DetailControl, MediaStreams, SpaceAction, TransferPhase};
218
219 const STATED: [char; 21] = [
228 '█', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '○', '●', '◌', '▸', '▾', '›', '—', '↑', '↓', '←', '→', '\u{26A0}', '\u{FE0F}', ];
236
237 fn stated_words() -> Vec<String> {
239 let phases = TransferPhase::LIFECYCLE.into_iter().flat_map(|phase| [phase.phase_label(), phase.phase_marker()]);
240 let changes = ChangeKind::REPORTED.into_iter().flat_map(|kind| [kind.change_label(), kind.change_marker()]);
241 let streams = MediaStreams::OFFERED.into_iter().map(MediaStreams::streams_label);
242 let controls = [
243 DetailControl::Input,
244 DetailControl::Output,
245 DetailControl::Format,
246 DetailControl::Command,
247 DetailControl::Restart,
248 DetailControl::DryRun,
249 DetailControl::Report,
250 DetailControl::Log,
251 DetailControl::Duplicate,
252 DetailControl::Delete,
253 ]
254 .into_iter()
255 .map(DetailControl::control_label);
256 let spaces = [SpaceAction::Start, SpaceAction::Rehearse, SpaceAction::Pause, SpaceAction::Resume]
257 .into_iter()
258 .map(SpaceAction::space_label);
259 phases
260 .chain(changes)
261 .chain(streams)
262 .chain(controls)
263 .chain(spaces)
264 .chain([UNSTATED, REHEARSING_MODE, ARMED_MODE])
265 .map(str::to_owned)
266 .chain(Gauge::LEADING.iter().map(|leading| (*leading).to_owned()))
267 .chain((0..=100_u16).map(|percent| Gauge::of(percent, 8).text()))
268 .chain([Key::Up, Key::Down, Key::Left, Key::Right].map(Key::label))
269 .chain([CURSOR_MARK, EXPANDED_MARK, PATH_SEPARATOR].map(str::to_owned))
270 .collect()
271 }
272
273 #[test]
274 fn every_stated_character_is_one_a_reader_has_been_told_about() {
275 let unexpected = stated_words()
276 .iter()
277 .flat_map(|word| word.chars().collect::<Vec<_>>())
278 .filter(|stated| !stated.is_ascii() && !STATED.contains(stated))
279 .collect::<Vec<_>>();
280
281 assert!(unexpected.is_empty(), "these are stated without having been checked for width: {unexpected:?}");
282 }
283}