Skip to main content

rsynko_ui/
vocabulary.rs

1use alux_ext::ext;
2use rsynko_manager::*;
3use std::time::Duration;
4
5/// Denotes how much weight one piece of stated text carries.
6///
7/// Emphasis is presentation meaning, not mechanism: a renderer chooses the color, weight, or
8/// inversion carrying it, and two renderers may disagree about that and still agree here.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum Emphasis {
11    /// States ordinary content.
12    Plain,
13    /// States what the whole screen belongs to.
14    Name,
15    /// States what names the content under it.
16    Heading,
17    /// States what names one value beside it.
18    Label,
19    /// States the value the cursor rests on.
20    Selected,
21    /// States present but deliberately quiet content.
22    Muted,
23    /// States work in progress.
24    Running,
25    /// States work deliberately suspended.
26    Held,
27    /// States work reaching its final step.
28    Finishing,
29    /// States work that succeeded.
30    Succeeded,
31    /// States work that failed.
32    Failed,
33    /// States an action that cannot destroy anything.
34    Safe,
35    /// States an action that arms a change to somebody's files.
36    Caution,
37}
38
39/// Names the lifecycle phases a reader sees.
40#[ext(name = PhaseVocabularyExt)]
41pub impl TransferPhase {
42    /// Names the phase the way a reader reads it.
43    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    /// States how much weight the phase carries.
58    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    /// States whether an interpreter is currently working on the entry.
70    ///
71    /// The marker is filled while something is happening to the request and open while the
72    /// request rests, which is the one distinction a reader makes at a glance.
73    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/// Names the details controls a reader sees.
85#[ext(name = ControlVocabularyExt)]
86pub impl DetailControl {
87    /// Names the control the way a reader reads it.
88    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    /// States whether the control is shown as a field stating a value rather than as an action.
104    fn states_value(self) -> bool {
105        matches!(self, Self::Input | Self::Output | Self::Format | Self::Command | Self::Report | Self::Log)
106    }
107}
108
109/// Names the stream roles a reader chooses between.
110#[ext(name = StreamVocabularyExt)]
111pub impl MediaStreams {
112    /// Names the roles the way a reader reads them.
113    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/// Names what a rehearsal states would happen.
123#[ext(name = ChangeVocabularyExt)]
124pub impl ChangeKind {
125    /// Names the change the way a reader reads it.
126    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    /// Marks the change so a reader tells one from another down a column.
136    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    /// States how much weight the change carries.
146    fn change_emphasis(self) -> Emphasis {
147        match self {
148            Self::Create => Emphasis::Safe,
149            Self::Update => Emphasis::Caution,
150            Self::Delete => Emphasis::Failed,
151            // What stays as it is is the part of a report a reader skims past.
152            Self::Unchanged => Emphasis::Muted,
153        }
154    }
155}
156
157/// Names the meanings Space carries.
158#[ext(name = SpaceVocabularyExt)]
159pub impl SpaceAction {
160    /// Names the transition the way a reader reads it.
161    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
171/// States a value no observation supplied.
172pub const UNSTATED: &str = "—";
173
174/// States a byte count in the largest unit stating it in full.
175#[must_use]
176pub fn bytes_label(bytes: u64) -> String {
177    /// Names each unit, every one a power of 1024 above the last.
178    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/// States a duration in minutes and seconds, and in hours once it reaches one.
195#[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/// States text within a column budget, ending elided text with an ellipsis.
205#[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    /// Names every character this vocabulary states that is not plain ASCII.
220    ///
221    /// A terminal lays out one column per character, and so does the reader of any transcript of
222    /// one. What matters is not how wide a character is but whether everyone measuring it agrees:
223    /// a symbol that a font may draw as a coloured emoji is one nobody can measure, and the row
224    /// it sits in stops fitting. A symbol asked for as an emoji, by the selector that follows it,
225    /// is two columns wide to everyone. Adding a character here is a decision to check which of
226    /// those it is, not a formality: `🔒` was ambiguous and had to be taken back out.
227    const STATED: [char; 21] = [
228        '█', '▏', '▎', '▍', '▌', '▋', '▊', '▉', // the eighths a gauge fills
229        '○', '●', '◌', // whether an interpreter is working on a request
230        '▸', '▾', // where the cursor rests, and which request is open
231        '›', // the separator between the pages a page rests under
232        '—', // what nothing was stated about
233        '↑', '↓', '←', '→', // the keys a menu names
234        '\u{26A0}', '\u{FE0F}', // a warning, and the selector making it two columns wide
235    ];
236
237    /// States every word this vocabulary states, so nothing states a character unnoticed.
238    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}