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
78            | Self::Extracting
79            | Self::Downloading
80            | Self::Publishing
81            | Self::Complete
82            | Self::Failed => "●",
83        }
84    }
85}
86
87/// Names the details controls a reader sees.
88#[ext(name = ControlVocabularyExt)]
89pub impl DetailControl {
90    /// Names the control the way a reader reads it.
91    fn control_label(self) -> &'static str {
92        match self {
93            Self::Input => "Input",
94            Self::Output => "File name",
95            Self::Format => "Format",
96            Self::Command => "Command",
97            Self::Restart => "Restart",
98            Self::DryRun => "Dry run",
99            Self::Report => "Report",
100            Self::Log => "Log",
101            Self::Duplicate => "Duplicate",
102            Self::Delete => "Delete",
103        }
104    }
105
106    /// States whether the control is shown as a field stating a value rather than as an action.
107    fn states_value(self) -> bool {
108        matches!(
109            self,
110            Self::Input | Self::Output | Self::Format | Self::Command | Self::Report | Self::Log
111        )
112    }
113}
114
115/// Names the stream roles a reader chooses between.
116#[ext(name = StreamVocabularyExt)]
117pub impl MediaStreams {
118    /// Names the roles the way a reader reads them.
119    fn streams_label(self) -> &'static str {
120        match self {
121            Self::AudioVideo => "audio + video",
122            Self::Audio => "audio only",
123            Self::Video => "video only",
124        }
125    }
126}
127
128/// Names what a rehearsal states would happen.
129#[ext(name = ChangeVocabularyExt)]
130pub impl ChangeKind {
131    /// Names the change the way a reader reads it.
132    fn change_label(self) -> &'static str {
133        match self {
134            Self::Create => "new",
135            Self::Update => "updated",
136            Self::Delete => "deleted",
137            Self::Unchanged => "unchanged",
138        }
139    }
140
141    /// Marks the change so a reader tells one from another down a column.
142    fn change_marker(self) -> &'static str {
143        match self {
144            Self::Create => "+",
145            Self::Update => "~",
146            Self::Delete => "-",
147            Self::Unchanged => "=",
148        }
149    }
150
151    /// States how much weight the change carries.
152    fn change_emphasis(self) -> Emphasis {
153        match self {
154            Self::Create => Emphasis::Safe,
155            Self::Update => Emphasis::Caution,
156            Self::Delete => Emphasis::Failed,
157            // What stays as it is is the part of a report a reader skims past.
158            Self::Unchanged => Emphasis::Muted,
159        }
160    }
161}
162
163/// Names the meanings Space carries.
164#[ext(name = SpaceVocabularyExt)]
165pub impl SpaceAction {
166    /// Names the transition the way a reader reads it.
167    fn space_label(self) -> &'static str {
168        match self {
169            Self::Start => "Start",
170            Self::Rehearse => "Dry run",
171            Self::Pause => "Pause",
172            Self::Resume => "Resume",
173        }
174    }
175}
176
177/// States a value no observation supplied.
178pub const UNSTATED: &str = "—";
179
180/// States a byte count in the largest unit stating it in full.
181#[must_use]
182pub fn bytes_label(bytes: u64) -> String {
183    /// Names each unit, every one a power of 1024 above the last.
184    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
185    let mut divisor = 1_u64;
186    let mut unit = 0_usize;
187    while bytes / divisor >= 1024 && unit < UNITS.len() - 1 {
188        divisor = divisor.saturating_mul(1024);
189        unit += 1;
190    }
191    if unit == 0 {
192        format!("{bytes} {}", UNITS[unit])
193    } else {
194        let whole = bytes / divisor;
195        let tenth = bytes % divisor / (divisor / 10);
196        format!("{whole}.{tenth} {}", UNITS[unit])
197    }
198}
199
200/// States a duration in minutes and seconds, and in hours once it reaches one.
201#[must_use]
202pub fn duration_label(duration: Duration) -> String {
203    let seconds = duration.as_secs();
204    let hours = seconds / 3600;
205    let minutes = seconds % 3600 / 60;
206    let seconds = seconds % 60;
207    if hours > 0 {
208        format!("{hours}:{minutes:02}:{seconds:02}")
209    } else {
210        format!("{minutes:02}:{seconds:02}")
211    }
212}
213
214/// States text within a column budget, ending elided text with an ellipsis.
215#[must_use]
216pub fn elided(text: &str, columns: usize) -> String {
217    if text.chars().count() <= columns {
218        return text.to_owned();
219    }
220    text.chars()
221        .take(columns.saturating_sub(1))
222        .collect::<String>()
223        + "…"
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::{
230        ARMED_MODE, CURSOR_MARK, EXPANDED_MARK, Gauge, Key, PATH_SEPARATOR, REHEARSING_MODE,
231        UNSTATED,
232    };
233    use rsynko_manager::{ChangeKind, DetailControl, MediaStreams, SpaceAction, TransferPhase};
234
235    /// Names every character this vocabulary states that is not plain ASCII.
236    ///
237    /// A terminal lays out one column per character, and so does the reader of any transcript of
238    /// one. What matters is not how wide a character is but whether everyone measuring it agrees:
239    /// a symbol that a font may draw as a coloured emoji is one nobody can measure, and the row
240    /// it sits in stops fitting. A symbol asked for as an emoji, by the selector that follows it,
241    /// is two columns wide to everyone. Adding a character here is a decision to check which of
242    /// those it is, not a formality: `🔒` was ambiguous and had to be taken back out.
243    const STATED: [char; 21] = [
244        '█', '▏', '▎', '▍', '▌', '▋', '▊', '▉', // the eighths a gauge fills
245        '○', '●', '◌', // whether an interpreter is working on a request
246        '▸', '▾', // where the cursor rests, and which request is open
247        '›', // the separator between the pages a page rests under
248        '—', // what nothing was stated about
249        '↑', '↓', '←', '→', // the keys a menu names
250        '\u{26A0}', '\u{FE0F}', // a warning, and the selector making it two columns wide
251    ];
252
253    /// States every word this vocabulary states, so nothing states a character unnoticed.
254    fn stated_words() -> Vec<String> {
255        let phases = TransferPhase::LIFECYCLE
256            .into_iter()
257            .flat_map(|phase| [phase.phase_label(), phase.phase_marker()]);
258        let changes = ChangeKind::REPORTED
259            .into_iter()
260            .flat_map(|kind| [kind.change_label(), kind.change_marker()]);
261        let streams = MediaStreams::OFFERED
262            .into_iter()
263            .map(MediaStreams::streams_label);
264        let controls = [
265            DetailControl::Input,
266            DetailControl::Output,
267            DetailControl::Format,
268            DetailControl::Command,
269            DetailControl::Restart,
270            DetailControl::DryRun,
271            DetailControl::Report,
272            DetailControl::Log,
273            DetailControl::Duplicate,
274            DetailControl::Delete,
275        ]
276        .into_iter()
277        .map(DetailControl::control_label);
278        let spaces = [
279            SpaceAction::Start,
280            SpaceAction::Rehearse,
281            SpaceAction::Pause,
282            SpaceAction::Resume,
283        ]
284        .into_iter()
285        .map(SpaceAction::space_label);
286        phases
287            .chain(changes)
288            .chain(streams)
289            .chain(controls)
290            .chain(spaces)
291            .chain([UNSTATED, REHEARSING_MODE, ARMED_MODE])
292            .map(str::to_owned)
293            .chain(Gauge::LEADING.iter().map(|leading| (*leading).to_owned()))
294            .chain((0..=100_u16).map(|percent| Gauge::of(percent, 8).text()))
295            .chain([Key::Up, Key::Down, Key::Left, Key::Right].map(Key::label))
296            .chain([CURSOR_MARK, EXPANDED_MARK, PATH_SEPARATOR].map(str::to_owned))
297            .collect()
298    }
299
300    #[test]
301    fn every_stated_character_is_one_a_reader_has_been_told_about() {
302        let unexpected = stated_words()
303            .iter()
304            .flat_map(|word| word.chars().collect::<Vec<_>>())
305            .filter(|stated| !stated.is_ascii() && !STATED.contains(stated))
306            .collect::<Vec<_>>();
307
308        assert!(
309            unexpected.is_empty(),
310            "these are stated without having been checked for width: {unexpected:?}"
311        );
312    }
313}