Skip to main content

studio_worker/ui/
page.rs

1//! The pages on the navigation rail.  Pure data so the contract is
2//! testable without egui in scope.
3
4/// Tracing-free, egui-free: which page the window shows.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
6pub enum Page {
7    /// What runs and what ran: the page the window opens on.
8    #[default]
9    Jobs,
10    Models,
11    /// The worker's state, registration, hardware and version.
12    Worker,
13    Logs,
14    Config,
15}
16
17impl Page {
18    /// Rail order; `Ctrl+1` … `Ctrl+5` follow it.
19    pub const ALL: [Page; 5] = [
20        Page::Jobs,
21        Page::Models,
22        Page::Worker,
23        Page::Logs,
24        Page::Config,
25    ];
26
27    pub fn label(self) -> &'static str {
28        match self {
29            Page::Jobs => "Jobs",
30            Page::Models => "Models",
31            Page::Worker => "Worker",
32            Page::Logs => "Logs",
33            Page::Config => "Config",
34        }
35    }
36
37    /// One line for the rail's hover text.
38    pub fn hint(self) -> &'static str {
39        match self {
40            Page::Jobs => "What runs now and what ran",
41            Page::Models => "Load and unload models",
42            Page::Worker => "State, registration, hardware and version",
43            Page::Logs => "Everything the daemon logs",
44            Page::Config => "Worker settings and this window",
45        }
46    }
47
48    /// The page `Ctrl+<digit>` picks (1-based).
49    pub fn from_digit(digit: u8) -> Option<Self> {
50        Self::ALL.get(usize::from(digit).checked_sub(1)?).copied()
51    }
52
53    /// 1-based position on the rail.
54    pub fn digit(self) -> u8 {
55        Self::ALL.iter().position(|p| *p == self).unwrap_or(0) as u8 + 1
56    }
57
58    /// Parse a page name (case-insensitive), for `STUDIO_WORKER_UI_PAGE`.
59    pub fn parse(name: &str) -> Option<Self> {
60        let name = name.trim();
61        Self::ALL
62            .into_iter()
63            .find(|p| p.label().eq_ignore_ascii_case(name))
64    }
65
66    /// The page on open: `STUDIO_WORKER_UI_PAGE` (screenshots, headless
67    /// inspection) or [`Page::Jobs`].
68    pub fn initial() -> Self {
69        Self::initial_from(std::env::var("STUDIO_WORKER_UI_PAGE").ok().as_deref())
70    }
71
72    pub fn initial_from(env: Option<&str>) -> Self {
73        env.and_then(Self::parse).unwrap_or_default()
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn the_rail_lists_every_page_in_order() {
83        let labels: Vec<&str> = Page::ALL.iter().map(|p| p.label()).collect();
84        assert_eq!(labels, ["Jobs", "Models", "Worker", "Logs", "Config"]);
85    }
86
87    #[test]
88    fn the_window_opens_on_jobs() {
89        assert_eq!(Page::default(), Page::Jobs);
90        assert_eq!(Page::initial_from(None), Page::Jobs);
91        assert_eq!(Page::initial_from(Some("nonsense")), Page::Jobs);
92        assert_eq!(Page::initial_from(Some("worker")), Page::Worker);
93    }
94
95    #[test]
96    fn names_parse_case_insensitively_and_old_tabs_are_gone() {
97        for page in Page::ALL {
98            assert_eq!(Page::parse(page.label()), Some(page));
99            assert_eq!(Page::parse(&page.label().to_uppercase()), Some(page));
100            assert!(!page.hint().is_empty());
101        }
102        assert_eq!(Page::parse(" logs "), Some(Page::Logs));
103        assert_eq!(Page::parse("status"), None);
104        assert_eq!(Page::parse("about"), None);
105    }
106
107    #[test]
108    fn digits_pick_pages_in_rail_order() {
109        for page in Page::ALL {
110            assert_eq!(Page::from_digit(page.digit()), Some(page));
111        }
112        assert_eq!(Page::from_digit(1), Some(Page::Jobs));
113        assert_eq!(Page::from_digit(0), None);
114        assert_eq!(Page::from_digit(6), None);
115    }
116}