max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+693 insertions,
-130 deletions
| @@ -826,6 +826,25 @@ | |||
| 826 | 826 | "icu_properties", | |
| 827 | 827 | ] | |
| 828 | 828 | ||
| 829 | + | [[package]] | |
| 830 | + | name = "include_dir" | |
| 831 | + | version = "0.7.4" | |
| 832 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 833 | + | checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" | |
| 834 | + | dependencies = [ | |
| 835 | + | "include_dir_macros", | |
| 836 | + | ] | |
| 837 | + | ||
| 838 | + | [[package]] | |
| 839 | + | name = "include_dir_macros" | |
| 840 | + | version = "0.7.4" | |
| 841 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 842 | + | checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" | |
| 843 | + | dependencies = [ | |
| 844 | + | "proc-macro2", | |
| 845 | + | "quote", | |
| 846 | + | ] | |
| 847 | + | ||
| 829 | 848 | [[package]] | |
| 830 | 849 | name = "indexmap" | |
| 831 | 850 | version = "2.14.0" | |
| @@ -1035,6 +1054,34 @@ | |||
| 1035 | 1054 | "winapi", | |
| 1036 | 1055 | ] | |
| 1037 | 1056 | ||
| 1057 | + | [[package]] | |
| 1058 | + | name = "makeover" | |
| 1059 | + | version = "2.4.1" | |
| 1060 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1061 | + | checksum = "b0a796c931419e54dd71a8aaa7d62230730e958f3101d37d4c1d3ab7b541b38e" | |
| 1062 | + | dependencies = [ | |
| 1063 | + | "include_dir", | |
| 1064 | + | "serde", | |
| 1065 | + | "toml", | |
| 1066 | + | ] | |
| 1067 | + | ||
| 1068 | + | [[package]] | |
| 1069 | + | name = "makeover-layout" | |
| 1070 | + | version = "0.12.0" | |
| 1071 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1072 | + | checksum = "58edd16523115ed4c9ca6de016693300ac95cf1bb0bd8ccf7fd246213102a7ff" | |
| 1073 | + | ||
| 1074 | + | [[package]] | |
| 1075 | + | name = "makeover-tui" | |
| 1076 | + | version = "0.12.0" | |
| 1077 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1078 | + | checksum = "88982f3e29d40336e9748e9adafa88278d123c42875aad178e0cea640a128d57" | |
| 1079 | + | dependencies = [ | |
| 1080 | + | "makeover", | |
| 1081 | + | "makeover-layout", | |
| 1082 | + | "ratatui", | |
| 1083 | + | ] | |
| 1084 | + | ||
| 1038 | 1085 | [[package]] | |
| 1039 | 1086 | name = "memchr" | |
| 1040 | 1087 | version = "2.8.3" | |
| @@ -1159,6 +1206,8 @@ | |||
| 1159 | 1206 | dependencies = [ | |
| 1160 | 1207 | "anyhow", | |
| 1161 | 1208 | "chrono", | |
| 1209 | + | "makeover", | |
| 1210 | + | "makeover-tui", | |
| 1162 | 1211 | "ops-status", | |
| 1163 | 1212 | "ratatui", | |
| 1164 | 1213 | "reqwest", | |
| @@ -2853,6 +2902,18 @@ | |||
| 2853 | 2902 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2854 | 2903 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
| 2855 | 2904 | ||
| 2905 | + | [[patch.unused]] | |
| 2906 | + | name = "docengine" | |
| 2907 | + | version = "0.4.0" | |
| 2908 | + | ||
| 2909 | + | [[patch.unused]] | |
| 2910 | + | name = "synckit-client" | |
| 2911 | + | version = "0.8.0" | |
| 2912 | + | ||
| 2913 | + | [[patch.unused]] | |
| 2914 | + | name = "synckit-config" | |
| 2915 | + | version = "0.2.0" | |
| 2916 | + | ||
| 2856 | 2917 | [[patch.unused]] | |
| 2857 | 2918 | name = "kberg" | |
| 2858 | 2919 | version = "0.1.0" | |
| @@ -2866,13 +2927,25 @@ | |||
| 2866 | 2927 | version = "0.4.0" | |
| 2867 | 2928 | ||
| 2868 | 2929 | [[patch.unused]] | |
| 2869 | - | name = "docengine" | |
| 2870 | - | version = "0.4.0" | |
| 2930 | + | name = "quasi-axum" | |
| 2931 | + | version = "0.1.0" | |
| 2871 | 2932 | ||
| 2872 | 2933 | [[patch.unused]] | |
| 2873 | - | name = "synckit-client" | |
| 2874 | - | version = "0.8.0" | |
| 2934 | + | name = "quasi-http" | |
| 2935 | + | version = "0.1.0" | |
| 2875 | 2936 | ||
| 2876 | 2937 | [[patch.unused]] | |
| 2877 | - | name = "synckit-config" | |
| 2878 | - | version = "0.2.0" | |
| 2938 | + | name = "quasi-router" | |
| 2939 | + | version = "0.1.0" | |
| 2940 | + | ||
| 2941 | + | [[patch.unused]] | |
| 2942 | + | name = "quasi-store" | |
| 2943 | + | version = "0.1.0" | |
| 2944 | + | ||
| 2945 | + | [[patch.unused]] | |
| 2946 | + | name = "quasi-tauri" | |
| 2947 | + | version = "0.1.0" | |
| 2948 | + | ||
| 2949 | + | [[patch.unused]] | |
| 2950 | + | name = "quasi-webview" | |
| 2951 | + | version = "0.1.0" |
| @@ -14,6 +14,11 @@ | |||
| 14 | 14 | # crossterm comes from ratatui's own re-export (`ratatui::crossterm`, 0.29) so | |
| 15 | 15 | # the two cannot drift into a version mismatch. | |
| 16 | 16 | ratatui = "0.30.2" | |
| 17 | + | # The family's design system. `makeover` loads and resolves a theme file; | |
| 18 | + | # `makeover-tui` is the terminal renderer, and its `theme` feature is the | |
| 19 | + | # intents-to-ratatui-colours mapping. No hex value is written in this crate. | |
| 20 | + | makeover = "2.4.1" | |
| 21 | + | makeover-tui = { version = "0.12.0", features = ["theme"] } | |
| 17 | 22 | tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "time", "sync", "signal"] } | |
| 18 | 23 | # `rustls-no-provider` rather than `rustls`: the latter is an alias for | |
| 19 | 24 | # `__rustls-aws-lc-rs`, which links a C crypto backend. The provider is ring |
| @@ -21,6 +21,14 @@ | |||
| 21 | 21 | # snapshot nobody had refreshed in forty days. | |
| 22 | 22 | stale_after_secs = 60 | |
| 23 | 23 | ||
| 24 | + | # Which theme to render in. Unset means follow the terminal: the viewer reads | |
| 25 | + | # COLORFGBG and picks a light or dark theme to match, so it does not paint a | |
| 26 | + | # light palette onto a night session. Set it to a theme id to pin one instead, | |
| 27 | + | # e.g. "makenotwork", "carbonfox", "nord". Own themes go in | |
| 28 | + | # ~/.config/ops-viewer/themes/ and outrank the bundled set of the same id. | |
| 29 | + | # | |
| 30 | + | # theme = "system" | |
| 31 | + | ||
| 24 | 32 | # Each URL below is the address that daemon's own deploy example binds, which is | |
| 25 | 33 | # the file to check when a source reads as unreachable. A daemon bound to | |
| 26 | 34 | # loopback is only visible to a viewer on the same host. |
| @@ -26,6 +26,15 @@ | |||
| 26 | 26 | /// Fallback for any source that does not set its own. | |
| 27 | 27 | #[serde(default = "default_stale")] | |
| 28 | 28 | pub stale_after_secs: u64, | |
| 29 | + | /// The theme to render in: `"system"` to follow the terminal, or a theme | |
| 30 | + | /// id to pin. | |
| 31 | + | /// | |
| 32 | + | /// Unprefixed `theme`, which is the family convention's key name (wiki | |
| 33 | + | /// `makeover-app-convention`) — this file is already scoped to the viewer, | |
| 34 | + | /// so a `viewer-theme` would be saying it twice. Absent reads as "follow | |
| 35 | + | /// the terminal" rather than as a pin on whatever the first run guessed. | |
| 36 | + | #[serde(default)] | |
| 37 | + | pub theme: Option<String>, | |
| 29 | 38 | #[serde(default, rename = "source")] | |
| 30 | 39 | pub sources: Vec<Source>, | |
| 31 | 40 | } | |
| @@ -134,6 +143,13 @@ | |||
| 134 | 143 | Ok(()) | |
| 135 | 144 | } | |
| 136 | 145 | ||
| 146 | + | /// What the operator chose, which is not the same as what is rendered: a | |
| 147 | + | /// standing "follow the terminal" resolves differently as the terminal | |
| 148 | + | /// changes, and a pinned id does not. | |
| 149 | + | pub(crate) fn theme_selection(&self) -> makeover::ThemeSelection { | |
| 150 | + | makeover::ThemeSelection::parse(self.theme.as_deref()) | |
| 151 | + | } | |
| 152 | + | ||
| 137 | 153 | /// Staleness limit for one source: its own, else the global default. | |
| 138 | 154 | pub(crate) fn stale_after(&self, source: &Source) -> chrono::TimeDelta { | |
| 139 | 155 | let secs = source.stale_after_secs.unwrap_or(self.stale_after_secs); | |
| @@ -227,6 +243,38 @@ | |||
| 227 | 243 | assert_eq!(cfg.stale_after(&cfg.sources[1]).num_seconds(), 600); | |
| 228 | 244 | } | |
| 229 | 245 | ||
| 246 | + | #[test] | |
| 247 | + | fn an_absent_theme_key_follows_the_terminal_and_an_id_pins_one() { | |
| 248 | + | let (_dir, path) = write( | |
| 249 | + | r#" | |
| 250 | + | [[source]] | |
| 251 | + | name = "sando" | |
| 252 | + | url = "http://fw13:8080" | |
| 253 | + | "#, | |
| 254 | + | ); | |
| 255 | + | let cfg = Config::load(&path).unwrap(); | |
| 256 | + | assert_eq!( | |
| 257 | + | cfg.theme_selection(), | |
| 258 | + | makeover::ThemeSelection::Follow, | |
| 259 | + | "a config that says nothing about theming must track the terminal, \ | |
| 260 | + | not pin whatever the first run guessed", | |
| 261 | + | ); | |
| 262 | + | ||
| 263 | + | let (_dir, path) = write( | |
| 264 | + | r#" | |
| 265 | + | theme = "carbonfox" | |
| 266 | + | ||
| 267 | + | [[source]] | |
| 268 | + | name = "sando" | |
| 269 | + | url = "http://fw13:8080" | |
| 270 | + | "#, | |
| 271 | + | ); | |
| 272 | + | assert_eq!( | |
| 273 | + | Config::load(&path).unwrap().theme_selection(), | |
| 274 | + | makeover::ThemeSelection::Fixed("carbonfox".into()), | |
| 275 | + | ); | |
| 276 | + | } | |
| 277 | + | ||
| 230 | 278 | #[test] | |
| 231 | 279 | fn an_empty_config_is_rejected_rather_than_showing_an_empty_screen() { | |
| 232 | 280 | let (_dir, path) = write("stale_after_secs = 60\n"); |
| @@ -24,6 +24,7 @@ | |||
| 24 | 24 | mod model; | |
| 25 | 25 | mod poll; | |
| 26 | 26 | mod render; | |
| 27 | + | mod theme; | |
| 27 | 28 | mod tls; | |
| 28 | 29 | mod value; | |
| 29 | 30 | ||
| @@ -51,6 +52,9 @@ | |||
| 51 | 52 | ||
| 52 | 53 | let path = config_path()?; | |
| 53 | 54 | let cfg = Config::load(&path)?; | |
| 55 | + | // Before the terminal is taken over, so a theme problem is an error on a | |
| 56 | + | // normal screen rather than one printed into a restored alternate buffer. | |
| 57 | + | let theme = theme::load(&cfg.theme_selection())?; | |
| 54 | 58 | ||
| 55 | 59 | let runtime = tokio::runtime::Runtime::new().context("starting the async runtime")?; | |
| 56 | 60 | let _guard = runtime.enter(); | |
| @@ -72,6 +76,7 @@ | |||
| 72 | 76 | let outcome = run( | |
| 73 | 77 | &mut terminal, | |
| 74 | 78 | model, | |
| 79 | + | &theme, | |
| 75 | 80 | updates, | |
| 76 | 81 | cfg.sources, | |
| 77 | 82 | action_tx, | |
| @@ -100,6 +105,7 @@ | |||
| 100 | 105 | fn run( | |
| 101 | 106 | terminal: &mut ratatui::DefaultTerminal, | |
| 102 | 107 | mut model: Model, | |
| 108 | + | theme: &makeover_tui::Theme, | |
| 103 | 109 | mut updates: mpsc::Receiver<poll::Update>, | |
| 104 | 110 | sources: Vec<Source>, | |
| 105 | 111 | action_tx: mpsc::Sender<exec::Outcome>, | |
| @@ -107,7 +113,7 @@ | |||
| 107 | 113 | ) -> Result<()> { | |
| 108 | 114 | loop { | |
| 109 | 115 | let now = Utc::now(); | |
| 110 | - | terminal.draw(|frame| render::render(&model, now, frame))?; | |
| 116 | + | terminal.draw(|frame| render::render(&model, theme, now, frame))?; | |
| 111 | 117 | ||
| 112 | 118 | // Drain everything the pollers have produced without blocking, so a | |
| 113 | 119 | // burst of updates costs one redraw rather than one each. |
| @@ -1,23 +1,31 @@ | |||
| 1 | 1 | //! Drawing the model. | |
| 2 | 2 | //! | |
| 3 | - | //! `render(model, now, frame)` is a pure function of the model and the clock. | |
| 4 | - | //! `now` is threaded through rather than read from the system clock precisely | |
| 5 | - | //! so that this stays true: with an ambient clock, every snapshot test becomes | |
| 6 | - | //! time-dependent and the whole surface stops being verifiable without a live | |
| 7 | - | //! daemon. That is unpleasant to retrofit and trivial to keep. | |
| 3 | + | //! `render(model, theme, now, frame)` is a pure function of the model, the | |
| 4 | + | //! theme and the clock. `now` is threaded through rather than read from the | |
| 5 | + | //! system clock precisely so that this stays true: with an ambient clock, every | |
| 6 | + | //! snapshot test becomes time-dependent and the whole surface stops being | |
| 7 | + | //! verifiable without a live daemon. That is unpleasant to retrofit and trivial | |
| 8 | + | //! to keep. The theme is threaded the same way and for the same reason. | |
| 9 | + | //! | |
| 10 | + | //! Every colour here is a makeover intent (`crate::theme`), never a literal. | |
| 11 | + | //! The intent names what a thing IS — a danger, a muted label, the surface a | |
| 12 | + | //! modal floats on — and which colour that is belongs to the theme file. What | |
| 13 | + | //! the terminal costs is geometry rather than colour, so nothing below leans on | |
| 14 | + | //! a hue to carry a distinction that the layout should be carrying. | |
| 8 | 15 | ||
| 9 | 16 | use chrono::{DateTime, Utc}; | |
| 17 | + | use makeover_tui::Theme; | |
| 10 | 18 | use ops_status::{Method, Node}; | |
| 11 | 19 | use ratatui::Frame; | |
| 12 | 20 | use ratatui::layout::{Constraint, Layout, Rect}; | |
| 13 | - | use ratatui::style::{Color, Modifier, Style, Stylize}; | |
| 21 | + | use ratatui::style::{Modifier, Style}; | |
| 14 | 22 | use ratatui::text::{Line, Span}; | |
| 15 | 23 | use ratatui::widgets::{Block, Cell, Clear, List, ListItem, Paragraph, Row, Table, Tabs}; | |
| 16 | 24 | ||
| 17 | 25 | use crate::model::{Model, Prompt, SourceState, Tab}; | |
| 18 | 26 | use crate::value; | |
| 19 | 27 | ||
| 20 | - | pub(crate) fn render(model: &Model, now: DateTime<Utc>, frame: &mut Frame) { | |
| 28 | + | pub(crate) fn render(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame) { | |
| 21 | 29 | let [header, body, footer] = Layout::vertical([ | |
| 22 | 30 | Constraint::Length(1), | |
| 23 | 31 | Constraint::Min(1), | |
| @@ -25,50 +33,95 @@ | |||
| 25 | 33 | ]) | |
| 26 | 34 | .areas(frame.area()); | |
| 27 | 35 | ||
| 28 | - | render_header(model, now, frame, header); | |
| 36 | + | render_header(model, theme, now, frame, header); | |
| 29 | 37 | match model.tab { | |
| 30 | - | Tab::Rollup => render_rollup(model, now, frame, body), | |
| 38 | + | Tab::Rollup => render_rollup(model, theme, now, frame, body), | |
| 31 | 39 | Tab::Source(i) => match model.sources.get(i) { | |
| 32 | - | Some(source) => render_source(source, now, frame, body), | |
| 33 | - | None => frame.render_widget(Paragraph::new("no such source"), body), | |
| 40 | + | Some(source) => render_source(source, theme, now, frame, body), | |
| 41 | + | None => frame.render_widget(Paragraph::new("no such source").style(muted(theme)), body), | |
| 34 | 42 | }, | |
| 35 | 43 | } | |
| 36 | 44 | // A prompt floats over whatever tab is showing: the state behind it keeps | |
| 37 | 45 | // updating on every poll, which is the point of not blocking on the modal. | |
| 38 | 46 | if model.prompt.is_some() { | |
| 39 | - | render_prompt(model, frame, body); | |
| 47 | + | render_prompt(model, theme, frame, body); | |
| 40 | 48 | } | |
| 41 | - | render_footer(model, frame, footer); | |
| 49 | + | render_footer(model, theme, frame, footer); | |
| 42 | 50 | } | |
| 43 | 51 | ||
| 44 | - | fn render_header(model: &Model, now: DateTime<Utc>, frame: &mut Frame, area: Rect) { | |
| 52 | + | /// Secondary text: labels, hints, the things you read past. | |
| 53 | + | fn muted(theme: &Theme) -> Style { | |
| 54 | + | Style::default().fg(theme.content_muted) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// The selected row, on every list and table here. | |
| 58 | + | /// | |
| 59 | + | /// A raised surface rather than a reversed one. Reversing swaps a span's own | |
| 60 | + | /// foreground into the background, so a selected `FAIL` row used to paint its | |
| 61 | + | /// danger colour behind the text and lose the one signal the row carries; | |
| 62 | + | /// giving selection a surface of its own leaves every status colour intact on | |
| 63 | + | /// top of it. | |
| 64 | + | fn selected(theme: &Theme) -> Style { | |
| 65 | + | Style::default() | |
| 66 | + | .bg(theme.surface_raised) | |
| 67 | + | .fg(theme.content_primary) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// A bordered container: the frame in the theme's border colour, the title in | |
| 71 | + | /// secondary content so it reads as a label on the box rather than as content. | |
| 72 | + | fn container(theme: &Theme, title: &'static str) -> Block<'static> { | |
| 73 | + | Block::bordered() | |
| 74 | + | .border_style(Style::default().fg(theme.line_border)) | |
| 75 | + | .title(Span::styled( | |
| 76 | + | title, | |
| 77 | + | Style::default().fg(theme.content_secondary), | |
| 78 | + | )) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | fn render_header(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame, area: Rect) { | |
| 45 | 82 | let worst = model.worst(now); | |
| 46 | 83 | let [mark, tabs] = Layout::horizontal([Constraint::Length(6), Constraint::Min(1)]).areas(area); | |
| 47 | 84 | ||
| 85 | + | // The rollup mark is a filled chip: the worst status is the one thing on | |
| 86 | + | // screen that has to be readable from across the room, so it takes the | |
| 87 | + | // status colour as a background rather than as text. | |
| 88 | + | let status = value::status_style(theme, worst); | |
| 48 | 89 | frame.render_widget( | |
| 49 | 90 | Paragraph::new(Span::styled( | |
| 50 | 91 | format!(" {} ", value::status_mark(worst)), | |
| 51 | - | value::status_style(worst).add_modifier(Modifier::REVERSED), | |
| 92 | + | Style::default() | |
| 93 | + | .bg(status.fg.unwrap_or(theme.content_primary)) | |
| 94 | + | .fg(theme.surface_page) | |
| 95 | + | .add_modifier(Modifier::BOLD), | |
| 52 | 96 | )), | |
| 53 | 97 | mark, | |
| 54 | 98 | ); | |
| 55 | 99 | frame.render_widget( | |
| 56 | 100 | Tabs::new(model.tab_titles()) | |
| 57 | 101 | .select(model.tab_index()) | |
| 58 | - | .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) | |
| 102 | + | .style(muted(theme)) | |
| 103 | + | .highlight_style(selected(theme).add_modifier(Modifier::BOLD)) | |
| 59 | 104 | .divider(" "), | |
| 60 | 105 | tabs, | |
| 61 | 106 | ); | |
| 62 | 107 | } | |
| 63 | 108 | ||
| 64 | - | fn render_footer(model: &Model, frame: &mut Frame, area: Rect) { | |
| 109 | + | fn render_footer(model: &Model, theme: &Theme, frame: &mut Frame, area: Rect) { | |
| 65 | 110 | let text = match &model.message { | |
| 66 | - | Some(message) => Span::from(format!(" {message}")).yellow(), | |
| 67 | - | None => { | |
| 68 | - | Span::from(" tab/shift-tab switch up/down move enter open 1-9 jump q quit").dim() | |
| 69 | - | } | |
| 111 | + | // A fired action's outcome, which is transient and worth noticing. | |
| 112 | + | Some(message) => Span::styled( | |
| 113 | + | format!(" {message}"), | |
| 114 | + | Style::default().fg(theme.status_warning), | |
| 115 | + | ), | |
| 116 | + | None => Span::styled( | |
| 117 | + | " tab/shift-tab switch up/down move enter open 1-9 jump q quit", | |
| 118 | + | muted(theme), | |
| 119 | + | ), | |
| 70 | 120 | }; | |
| 71 | - | frame.render_widget(Paragraph::new(Line::from(text)), area); | |
| 121 | + | frame.render_widget( | |
| 122 | + | Paragraph::new(Line::from(text)).style(Style::default().bg(theme.surface_sunken)), | |
| 123 | + | area, | |
| 124 | + | ); | |
| 72 | 125 | } | |
| 73 | 126 | ||
| 74 | 127 | // --------------------------------------------------------------------------- | |
| @@ -79,7 +132,7 @@ | |||
| 79 | 132 | /// | |
| 80 | 133 | /// Without this the viewer is N tabs you still have to visit one at a time, | |
| 81 | 134 | /// which is the situation it replaces, with extra steps. | |
| 82 | - | fn render_rollup(model: &Model, now: DateTime<Utc>, frame: &mut Frame, area: Rect) { | |
| 135 | + | fn render_rollup(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame, area: Rect) { | |
| 83 | 136 | let order = model.rollup_order(now); | |
| 84 | 137 | let rows: Vec<Row> = order | |
| 85 | 138 | .iter() | |
| @@ -92,14 +145,14 @@ | |||
| 92 | 145 | None => "-".into(), | |
| 93 | 146 | }; | |
| 94 | 147 | let style = if row == model.rollup_selected { | |
| 95 | - | Style::default().add_modifier(Modifier::REVERSED) | |
| 148 | + | selected(theme) | |
| 96 | 149 | } else { | |
| 97 | - | Style::default() | |
| 150 | + | Style::default().fg(theme.content_primary) | |
| 98 | 151 | }; | |
| 99 | 152 | Row::new(vec![ | |
| 100 | - | Cell::from(value::status_mark(status)).style(value::status_style(status)), | |
| 153 | + | Cell::from(value::status_mark(status)).style(value::status_style(theme, status)), | |
| 101 | 154 | Cell::from(source.name.clone()), | |
| 102 | - | Cell::from(age), | |
| 155 | + | Cell::from(age).style(muted(theme)), | |
| 103 | 156 | Cell::from(source.summary(now)), | |
| 104 | 157 | ]) | |
| 105 | 158 | .style(style) | |
| @@ -115,11 +168,8 @@ | |||
| 115 | 168 | Constraint::Min(10), | |
| 116 | 169 | ], | |
| 117 | 170 | ) | |
| 118 | - | .header( | |
| 119 | - | Row::new(vec!["", "source", "age", "detail"]) | |
| 120 | - | .style(Style::default().add_modifier(Modifier::DIM)), | |
| 121 | - | ) | |
| 122 | - | .block(Block::bordered().title(" all sources ")); | |
| 171 | + | .header(Row::new(vec!["", "source", "age", "detail"]).style(muted(theme))) | |
| 172 | + | .block(container(theme, " all sources ")); | |
| 123 | 173 | ||
| 124 | 174 | frame.render_widget(table, area); | |
| 125 | 175 | } | |
| @@ -128,7 +178,13 @@ | |||
| 128 | 178 | // One source | |
| 129 | 179 | // --------------------------------------------------------------------------- | |
| 130 | 180 | ||
| 131 | - | fn render_source(source: &SourceState, now: DateTime<Utc>, frame: &mut Frame, area: Rect) { | |
| 181 | + | fn render_source( | |
| 182 | + | source: &SourceState, | |
| 183 | + | theme: &Theme, | |
| 184 | + | now: DateTime<Utc>, | |
| 185 | + | frame: &mut Frame, | |
| 186 | + | area: Rect, | |
| 187 | + | ) { | |
| 132 | 188 | let [list_area, detail_area] = | |
| 133 | 189 | Layout::vertical([Constraint::Percentage(55), Constraint::Min(5)]).areas(area); | |
| 134 | 190 | ||
| @@ -138,35 +194,51 @@ | |||
| 138 | 194 | .enumerate() | |
| 139 | 195 | .map(|(i, row)| { | |
| 140 | 196 | let indent = " ".repeat(row.depth); | |
| 141 | - | let mut spans = vec![ | |
| 197 | + | let spans = vec![ | |
| 142 | 198 | Span::styled( | |
| 143 | 199 | format!("{:<5}", value::status_mark(row.node.status)), | |
| 144 | - | value::status_style(row.node.status), | |
| 200 | + | value::status_style(theme, row.node.status), | |
| 145 | 201 | ), | |
| 146 | - | Span::raw(format!("{indent}{}", row.node.label)), | |
| 147 | - | Span::styled(format!(" ({})", row.node.kind), Style::default().dim()), | |
| 202 | + | Span::styled( | |
| 203 | + | format!("{indent}{}", row.node.label), | |
| 204 | + | Style::default().fg(theme.content_primary), | |
| 205 | + | ), | |
| 206 | + | Span::styled(format!(" ({})", row.node.kind), muted(theme)), | |
| 148 | 207 | ]; | |
| 208 | + | // Selection is the row's surface, applied under the spans rather | |
| 209 | + | // than patched into each one, so a status colour survives being | |
| 210 | + | // selected. | |
| 211 | + | let item = ListItem::new(Line::from(spans)); | |
| 149 | 212 | if i == source.selected { | |
| 150 | - | spans = spans | |
| 151 | - | .into_iter() | |
| 152 | - | .map(|s| s.patch_style(Style::default().add_modifier(Modifier::REVERSED))) | |
| 153 | - | .collect(); | |
| 213 | + | item.style(selected(theme)) | |
| 214 | + | } else { | |
| 215 | + | item | |
| 154 | 216 | } | |
| 155 | - | ListItem::new(Line::from(spans)) | |
| 156 | 217 | }) | |
| 157 | 218 | .collect(); | |
| 158 | 219 | ||
| 159 | 220 | let title = format!(" {} ", source.name); | |
| 160 | 221 | let list = if items.is_empty() { | |
| 161 | - | List::new(vec![ListItem::new(Line::from( | |
| 162 | - | Span::from(source.summary(now)).dim(), | |
| 163 | - | ))]) | |
| 222 | + | List::new(vec![ListItem::new(Line::from(Span::styled( | |
| 223 | + | source.summary(now), | |
| 224 | + | muted(theme), | |
| 225 | + | )))]) | |
| 164 | 226 | } else { | |
| 165 | 227 | List::new(items) | |
| 166 | 228 | }; | |
| 167 | - | frame.render_widget(list.block(Block::bordered().title(title)), list_area); | |
| 229 | + | frame.render_widget( | |
| 230 | + | list.block( | |
| 231 | + | Block::bordered() | |
| 232 | + | .border_style(Style::default().fg(theme.line_border)) | |
| 233 | + | .title(Span::styled( | |
| 234 | + | title, | |
| 235 | + | Style::default().fg(theme.content_secondary), | |
| 236 | + | )), | |
| 237 | + | ), | |
| 238 | + | list_area, | |
| 239 | + | ); | |
| 168 | 240 | ||
| 169 | - | render_detail(source, now, frame, detail_area); | |
| 241 | + | render_detail(source, theme, now, frame, detail_area); | |
| 170 | 242 | } | |
| 171 | 243 | ||
| 172 | 244 | /// The selected node's fields and conditions. | |
| @@ -174,7 +246,13 @@ | |||
| 174 | 246 | /// Conditions are the half that usually gets dropped, and the half that pays: | |
| 175 | 247 | /// "blocked" is useless, "blocked because burn_in is 31h of 48h" is what saves | |
| 176 | 248 | /// an SSH. | |
| 177 | - | fn render_detail(source: &SourceState, now: DateTime<Utc>, frame: &mut Frame, area: Rect) { | |
| 249 | + | fn render_detail( | |
| 250 | + | source: &SourceState, | |
| 251 | + | theme: &Theme, | |
| 252 | + | now: DateTime<Utc>, | |
| 253 | + | frame: &mut Frame, | |
| 254 | + | area: Rect, | |
| 255 | + | ) { | |
| 178 | 256 | let width = area.width.saturating_sub(4) as usize; | |
| 179 | 257 | let mut lines: Vec<Line> = Vec::new(); | |
| 180 | 258 | ||
| @@ -183,13 +261,18 @@ | |||
| 183 | 261 | lines.push(Line::from(vec![ | |
| 184 | 262 | Span::styled( | |
| 185 | 263 | value::status_mark(node.status), | |
| 186 | - | value::status_style(node.status), | |
| 264 | + | value::status_style(theme, node.status), | |
| 187 | 265 | ), | |
| 188 | 266 | Span::raw(" "), | |
| 189 | - | Span::styled(node.label.clone(), Style::default().bold()), | |
| 267 | + | Span::styled( | |
| 268 | + | node.label.clone(), | |
| 269 | + | Style::default() | |
| 270 | + | .fg(theme.content_primary) | |
| 271 | + | .add_modifier(Modifier::BOLD), | |
| 272 | + | ), | |
| 190 | 273 | ])); | |
| 191 | - | lines.extend(field_lines(node, now, width)); | |
| 192 | - | lines.extend(condition_lines(node, width)); | |
| 274 | + | lines.extend(field_lines(theme, node, now, width)); | |
| 275 | + | lines.extend(condition_lines(theme, node, width)); | |
| 193 | 276 | if !node.actions.is_empty() { | |
| 194 | 277 | // The hint tells the operator whether Enter does anything here, | |
| 195 | 278 | // so a read-only source does not look broken when a keypress is | |
| @@ -200,21 +283,24 @@ | |||
| 200 | 283 | " (read-only)" | |
| 201 | 284 | }; | |
| 202 | 285 | lines.push(Line::from(vec![ | |
| 203 | - | Span::from(format!("actions: {}", node.actions.join(", "))).dim(), | |
| 204 | - | Span::from(hint).dim(), | |
| 286 | + | Span::styled( | |
| 287 | + | format!("actions: {}", node.actions.join(", ")), | |
| 288 | + | muted(theme), | |
| 289 | + | ), | |
| 290 | + | Span::styled(hint, muted(theme)), | |
| 205 | 291 | ])); | |
| 206 | 292 | } | |
| 207 | 293 | } | |
| 208 | - | None => lines.push(Line::from(Span::from(source.summary(now)).dim())), | |
| 294 | + | None => lines.push(Line::from(Span::styled(source.summary(now), muted(theme)))), | |
| 209 | 295 | } | |
| 210 | 296 | ||
| 211 | 297 | frame.render_widget( | |
| 212 | - | Paragraph::new(lines).block(Block::bordered().title(" detail ")), | |
| 298 | + | Paragraph::new(lines).block(container(theme, " detail ")), | |
| 213 | 299 | area, | |
| 214 | 300 | ); | |
| 215 | 301 | } | |
| 216 | 302 | ||
| 217 | - | fn field_lines(node: &Node, now: DateTime<Utc>, width: usize) -> Vec<Line<'static>> { | |
| 303 | + | fn field_lines(theme: &Theme, node: &Node, now: DateTime<Utc>, width: usize) -> Vec<Line<'static>> { | |
| 218 | 304 | let label_width = node | |
| 219 | 305 | .fields | |
| 220 | 306 | .iter() | |
| @@ -229,37 +315,37 @@ | |||
| 229 | 315 | let rendered = value::render(&field.value, now, budget); | |
| 230 | 316 | let style = match &field.value { | |
| 231 | 317 | ops_status::Value::Progress { value, max, .. } => { | |
| 232 | - | value::progress_style(*value, *max) | |
| 318 | + | value::progress_style(theme, *value, *max) | |
| 233 | 319 | } | |
| 234 | - | other => value::style(other), | |
| 320 | + | other => value::style(theme, other), | |
| 235 | 321 | }; | |
| 236 | 322 | Line::from(vec![ | |
| 237 | - | Span::styled( | |
| 238 | - | format!("{:<label_width$} ", field.label), | |
| 239 | - | Style::default().dim(), | |
| 240 | - | ), | |
| 323 | + | Span::styled(format!("{:<label_width$} ", field.label), muted(theme)), | |
| 241 | 324 | Span::styled(rendered, style), | |
| 242 | 325 | ]) | |
| 243 | 326 | }) | |
| 244 | 327 | .collect() | |
| 245 | 328 | } | |
| 246 | 329 | ||
| 247 | - | fn condition_lines(node: &Node, width: usize) -> Vec<Line<'static>> { | |
| 330 | + | fn condition_lines(theme: &Theme, node: &Node, width: usize) -> Vec<Line<'static>> { | |
| 248 | 331 | node.conditions | |
| 249 | 332 | .iter() | |
| 250 | 333 | .map(|condition| { | |
| 251 | 334 | let mut spans = vec![ | |
| 252 | 335 | Span::styled( | |
| 253 | 336 | format!("{:<5}", value::status_mark(condition.status)), | |
| 254 | - | value::status_style(condition.status), | |
| 337 | + | value::status_style(theme, condition.status), | |
| 338 | + | ), | |
| 339 | + | Span::styled( | |
| 340 | + | condition.condition_type.clone(), | |
| 341 | + | Style::default().fg(theme.content_primary), | |
| 255 | 342 | ), | |
| 256 | - | Span::raw(condition.condition_type.clone()), | |
| 257 | 343 | ]; | |
| 258 | 344 | if let Some(detail) = &condition.detail { | |
| 259 | 345 | let budget = width.saturating_sub(condition.condition_type.chars().count() + 8); | |
| 260 | 346 | spans.push(Span::styled( | |
| 261 | 347 | format!(" {}", truncate(detail, budget.max(8))), | |
| 262 | - | Style::default().dim(), | |
| 348 | + | muted(theme), | |
| 263 | 349 | )); | |
| 264 | 350 | } | |
| 265 | 351 | Line::from(spans) | |
| @@ -277,9 +363,11 @@ | |||
| 277 | 363 | /// muscle memory cannot type `rollback-b`, which is the whole safeguard. The | |
| 278 | 364 | /// resolved host is left off — the tab already names the source — so the line | |
| 279 | 365 | /// stays short and shows the method and path the request will use. | |
| 280 | - | fn render_prompt(model: &Model, frame: &mut Frame, area: Rect) { | |
| 366 | + | fn render_prompt(model: &Model, theme: &Theme, frame: &mut Frame, area: Rect) { | |
| 281 | 367 | let Some(prompt) = &model.prompt else { return }; | |
| 282 | 368 | let source = model.sources.get(prompt_source(prompt)); | |
| 369 | + | let plain = Style::default().fg(theme.content_primary); | |
| 370 | + | let danger_style = Style::default().fg(theme.status_danger); | |
| 283 | 371 | ||
| 284 | 372 | let (title, title_style, lines) = match prompt { | |
| 285 | 373 | Prompt::Pick { keys, selected, .. } => { | |
| @@ -289,12 +377,12 @@ | |||
| 289 | 377 | let label = action.map_or(key.as_str(), |a| a.label.as_str()); | |
| 290 | 378 | let danger = action.is_some_and(|a| a.danger); | |
| 291 | 379 | let marker = if i == *selected { "> " } else { " " }; | |
| 292 | - | let mut style = Style::default(); | |
| 293 | - | if danger { | |
| 294 | - | style = style.fg(Color::Red); | |
| 295 | - | } | |
| 380 | + | // Danger is the foreground and selection the surface, so a | |
| 381 | + | // selected danger action is still red rather than swapped into | |
| 382 | + | // its own background. | |
| 383 | + | let mut style = if danger { danger_style } else { plain }; | |
| 296 | 384 | if i == *selected { | |
| 297 | - | style = style.add_modifier(Modifier::REVERSED); | |
| 385 | + | style = style.bg(theme.surface_raised); | |
| 298 | 386 | } | |
| 299 | 387 | lines.push(Line::from(Span::styled( | |
| 300 | 388 | format!("{marker}{}. {label} [{key}]", i + 1), | |
| @@ -302,40 +390,45 @@ | |||
| 302 | 390 | ))); | |
| 303 | 391 | } | |
| 304 | 392 | lines.push(Line::from("")); | |
| 305 | - | lines.push(Line::from(Span::from("enter run esc cancel").dim())); | |
| 306 | - | (" run action ", Style::default(), lines) | |
| 393 | + | lines.push(Line::from(Span::styled( | |
| 394 | + | "enter run esc cancel", | |
| 395 | + | muted(theme), | |
| 396 | + | ))); | |
| 397 | + | ( | |
| 398 | + | " run action ", | |
| 399 | + | Style::default().fg(theme.line_border), | |
| 400 | + | lines, | |
| 401 | + | ) | |
| 307 | 402 | } | |
| 308 | 403 | Prompt::Confirm { key, .. } => { | |
| 309 | 404 | let action = source.and_then(|s| s.action(key)); | |
| 310 | 405 | let lines = vec![ | |
| 311 | - | action_summary_line(action, key), | |
| 406 | + | action_summary_line(theme, action, key), | |
| 312 | 407 | Line::from(""), | |
| 313 | - | Line::from(Span::from("press y to confirm esc cancel").dim()), | |
| 408 | + | Line::from(Span::styled( | |
| 409 | + | "press y to confirm esc cancel", | |
| 410 | + | muted(theme), | |
| 411 | + | )), | |
| 314 | 412 | ]; | |
| 315 | - | (" confirm ", Style::default(), lines) | |
| 413 | + | (" confirm ", Style::default().fg(theme.line_border), lines) | |
| 316 | 414 | } | |
| 317 | 415 | Prompt::Type { key, typed, .. } => { | |
| 318 | - | let action = source.and_then(|s| s.action(key)); | |
| 319 | 416 | let lines = vec![ | |
| 320 | 417 | Line::from(Span::styled( | |
| 321 | 418 | "DANGER", | |
| 322 | - | Style::default().fg(Color::Red).bold(), | |
| 419 | + | danger_style.add_modifier(Modifier::BOLD), | |
| 323 | 420 | )), | |
| 324 | - | action_summary_line(action, key), | |
| 421 | + | action_summary_line(theme, source.and_then(|s| s.action(key)), key), | |
| 325 | 422 | Line::from(""), | |
| 326 | - | Line::from(Span::from(format!("type '{key}' to confirm:")).dim()), | |
| 327 | 423 | Line::from(Span::styled( | |
| 328 | - | format!("> {typed}\u{258f}"), | |
| 329 | - | Style::default().fg(Color::Red), | |
| 424 | + | format!("type '{key}' to confirm:"), | |
| 425 | + | muted(theme), | |
| 330 | 426 | )), | |
| 427 | + | Line::from(Span::styled(format!("> {typed}\u{258f}"), danger_style)), | |
| 331 | 428 | Line::from(""), | |
| 332 | - | Line::from(Span::from("esc cancel").dim()), | |
| 429 | + | Line::from(Span::styled("esc cancel", muted(theme))), | |
| 333 | 430 | ]; | |
| 334 | - | ( | |
| 335 | - | " DANGER ", | |
| 336 | - | Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), | |
| 337 | - | lines, | |
| 338 | - | ) | |
| 431 | + | (" DANGER ", danger_style.add_modifier(Modifier::BOLD), lines) | |
| 339 | 432 | } | |
| 340 | 433 | }; | |
| 341 | 434 | ||
| @@ -349,30 +442,46 @@ | |||
| 349 | 442 | let height = lines.len() as u16 + 2; | |
| 350 | 443 | let popup = centered(area, width, height); | |
| 351 | 444 | ||
| 352 | - | // Clear what is under the popup so the tab behind does not show through. | |
| 445 | + | // Clear what is under the popup so the tab behind does not show through, | |
| 446 | + | // then paint the overlay surface: makeover's `surface.overlay` is the | |
| 447 | + | // intent for exactly this, a surface that floats above the page. | |
| 353 | 448 | frame.render_widget(Clear, popup); | |
| 354 | 449 | frame.render_widget( | |
| 355 | - | Paragraph::new(lines).block( | |
| 356 | - | Block::bordered() | |
| 357 | - | .title(Span::styled(title, title_style)) | |
| 358 | - | .border_style(title_style), | |
| 359 | - | ), | |
| 450 | + | Paragraph::new(lines) | |
| 451 | + | .style(Style::default().bg(theme.surface_overlay)) | |
| 452 | + | .block( | |
| 453 | + | Block::bordered() | |
| 454 | + | .title(Span::styled(title, title_style)) | |
| 455 | + | .border_style(title_style), | |
| 456 | + | ), | |
| 360 | 457 | popup, | |
| 361 | 458 | ); | |
| 362 | 459 | } | |
| 363 | 460 | ||
| 364 | 461 | /// One line naming what an action will do: `Roll back POST /rollback/b`. | |
| 365 | - | fn action_summary_line(action: Option<&ops_status::Action>, key: &str) -> Line<'static> { | |
| 462 | + | fn action_summary_line( | |
| 463 | + | theme: &Theme, | |
| 464 | + | action: Option<&ops_status::Action>, | |
| 465 | + | key: &str, |
Lines truncated
| @@ -11,22 +11,45 @@ | |||
| 11 | 11 | //! snapshot-testable. | |
| 12 | 12 | ||
| 13 | 13 | use chrono::{DateTime, Utc}; | |
| 14 | + | use makeover_tui::Theme; | |
| 14 | 15 | use ops_status::{Status, Value}; | |
| 15 | - | use ratatui::style::{Color, Modifier, Style}; | |
| 16 | + | use ratatui::style::{Modifier, Style}; | |
| 16 | 17 | ||
| 17 | 18 | /// The one place a status becomes a color. | |
| 18 | - | pub(crate) fn status_style(status: Status) -> Style { | |
| 19 | + | /// | |
| 20 | + | /// The contract has six statuses and makeover has four status intents, so two | |
| 21 | + | /// of these have to be said some other way. Not with a category slot, which is | |
| 22 | + | /// where this landed first and is wrong twice over: category colors exist for | |
| 23 | + | /// data with no inherent meaning (a project's color, a series on a chart), and | |
| 24 | + | /// a theme is free to author them as the status hues restated — `makenotwork` | |
| 25 | + | /// gives `category.six` and `status.info` the same value, which collapsed | |
| 26 | + | /// `undistributed` onto `pending` in exactly the theme the viewer ships with. | |
| 27 | + | /// | |
| 28 | + | /// So the two extra states are carried by a modifier on the nearest intent, | |
| 29 | + | /// which is the renderer's own doctrine: a terminal's real constraint is | |
| 30 | + | /// geometry rather than color, and a distinction that has to survive any theme | |
| 31 | + | /// cannot rest on a hue the vocabulary does not name. | |
| 32 | + | pub(crate) fn status_style(theme: &Theme, status: Status) -> Style { | |
| 19 | 33 | match status { | |
| 20 | - | Status::Ok => Style::default().fg(Color::Green), | |
| 21 | - | // Cyan: adjacent to green rather than to the alarm colors, because a | |
| 22 | - | // build waiting on its upload is finished work, not broken work. | |
| 23 | - | Status::Undistributed => Style::default().fg(Color::Cyan), | |
| 24 | - | Status::Degraded => Style::default().fg(Color::Yellow), | |
| 25 | - | Status::Failed => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), | |
| 26 | - | Status::Pending => Style::default().fg(Color::Blue), | |
| 27 | - | // Magenta, not gray: an unreachable source is not a quiet absence, it | |
| 28 | - | // is the loudest thing a viewer can fail to tell you. | |
| 29 | - | Status::Unknown => Style::default().fg(Color::Magenta), | |
| 34 | + | Status::Ok => Style::default().fg(theme.status_success), | |
| 35 | + | // Success, faded: a build waiting on its upload is finished work, not | |
| 36 | + | // broken work, and the thing it is short of is arrival rather than | |
| 37 | + | // health. | |
| 38 | + | Status::Undistributed => Style::default() | |
| 39 | + | .fg(theme.status_success) | |
| 40 | + | .add_modifier(Modifier::DIM), | |
| 41 | + | Status::Degraded => Style::default().fg(theme.status_warning), | |
| 42 | + | Status::Failed => Style::default() | |
| 43 | + | .fg(theme.status_danger) | |
| 44 | + | .add_modifier(Modifier::BOLD), | |
| 45 | + | Status::Pending => Style::default().fg(theme.status_info), | |
| 46 | + | // Muted, and inverted so it is anything but quiet. An unreachable | |
| 47 | + | // source is a real absence — no status intent is honest about it — but | |
| 48 | + | // it is the loudest thing a viewer can fail to tell you, so it is drawn | |
| 49 | + | // as a filled block rather than as gray text. | |
| 50 | + | Status::Unknown => Style::default() | |
| 51 | + | .fg(theme.content_muted) | |
| 52 | + | .add_modifier(Modifier::REVERSED | Modifier::BOLD), | |
| 30 | 53 | } | |
| 31 | 54 | } | |
| 32 | 55 | ||
| @@ -59,14 +82,19 @@ | |||
| 59 | 82 | } | |
| 60 | 83 | ||
| 61 | 84 | /// The style a value carries on its own, if any. | |
| 62 | - | pub(crate) fn style(value: &Value) -> Style { | |
| 85 | + | pub(crate) fn style(theme: &Theme, value: &Value) -> Style { | |
| 63 | 86 | match value { | |
| 64 | - | Value::State { value } => status_style(*value), | |
| 65 | - | Value::Ident { .. } | Value::Path { .. } => Style::default().fg(Color::Cyan), | |
| 87 | + | Value::State { value } => status_style(theme, *value), | |
| 88 | + | // A digest and a path are data you read across rather than prose: | |
| 89 | + | // secondary content, set apart from the muted label to its left and | |
| 90 | + | // from the primary text of an ordinary value. | |
| 91 | + | Value::Ident { .. } | Value::Path { .. } => Style::default().fg(theme.content_secondary), | |
| 92 | + | // The action colour, which is the one place accent on text is not | |
| 93 | + | // decoration: a link is the only value here that names somewhere to go. | |
| 66 | 94 | Value::Link { .. } => Style::default() | |
| 67 | - | .fg(Color::Cyan) | |
| 95 | + | .fg(theme.action_primary) | |
| 68 | 96 | .add_modifier(Modifier::UNDERLINED), | |
| 69 | - | _ => Style::default(), | |
| 97 | + | _ => Style::default().fg(theme.content_primary), | |
| 70 | 98 | } | |
| 71 | 99 | } | |
| 72 | 100 | ||
| @@ -183,11 +211,11 @@ | |||
| 183 | 211 | ||
| 184 | 212 | /// How a progress value should be colored: complete is done, not merely far | |
| 185 | 213 | /// along. | |
| 186 | - | pub(crate) fn progress_style(value: f64, max: f64) -> Style { | |
| 214 | + | pub(crate) fn progress_style(theme: &Theme, value: f64, max: f64) -> Style { | |
| 187 | 215 | if max > 0.0 && value >= max { | |
| 188 | - | Style::default().fg(Color::Green) | |
| 216 | + | Style::default().fg(theme.status_success) | |
| 189 | 217 | } else { | |
| 190 | - | Style::default().fg(Color::Blue) | |
| 218 | + | Style::default().fg(theme.status_info) | |
| 191 | 219 | } | |
| 192 | 220 | } | |
| 193 | 221 | ||
| @@ -270,11 +298,15 @@ | |||
| 270 | 298 | ||
| 271 | 299 | #[test] | |
| 272 | 300 | fn a_complete_bar_reads_as_done() { | |
| 301 | + | let theme = crate::theme::tests::fixed(); | |
| 273 | 302 | assert_eq!( | |
| 274 | - | progress_style(48.0, 48.0), | |
| 275 | - | Style::default().fg(Color::Green) | |
| 303 | + | progress_style(&theme, 48.0, 48.0), | |
| 304 | + | Style::default().fg(theme.status_success) | |
| 305 | + | ); | |
| 306 | + | assert_eq!( | |
| 307 | + | progress_style(&theme, 31.0, 48.0), | |
| 308 | + | Style::default().fg(theme.status_info) | |
| 276 | 309 | ); | |
| 277 | - | assert_eq!(progress_style(31.0, 48.0), Style::default().fg(Color::Blue)); | |
| 278 | 310 | } | |
| 279 | 311 | ||
| 280 | 312 | #[test] | |
| @@ -333,10 +365,54 @@ | |||
| 333 | 365 | assert_eq!(render(&without, now(), 40), "https://makenot.work"); | |
| 334 | 366 | } | |
| 335 | 367 | ||
| 368 | + | const ALL_STATUSES: [Status; 6] = [ | |
| 369 | + | Status::Ok, | |
| 370 | + | Status::Undistributed, | |
| 371 | + | Status::Degraded, | |
| 372 | + | Status::Failed, | |
| 373 | + | Status::Pending, | |
| 374 | + | Status::Unknown, | |
| 375 | + | ]; | |
| 376 | + | ||
| 336 | 377 | #[test] | |
| 337 | 378 | fn an_unknown_status_is_loud_not_gray() { | |
| 338 | - | // A source nobody can reach must not read as a quiet absence. | |
| 339 | - | assert_eq!(status_style(Status::Unknown).fg, Some(Color::Magenta)); | |
| 340 | - | assert_eq!(status_style(Status::Ok).fg, Some(Color::Green)); | |
| 379 | + | // A source nobody can reach must not read as a quiet absence. Stated | |
| 380 | + | // against the theme's own intents rather than against a hex value: what | |
| 381 | + | // makes it loud is the inversion, which no theme can author away. | |
| 382 | + | let theme = crate::theme::tests::fixed(); | |
| 383 | + | let unknown = status_style(&theme, Status::Unknown); | |
| 384 | + | assert!( | |
| 385 | + | unknown.add_modifier.contains(Modifier::REVERSED), | |
| 386 | + | "an unreachable source must be a filled block, not gray text", | |
| 387 | + | ); | |
| 388 | + | assert_eq!( | |
| 389 | + | status_style(&theme, Status::Ok).fg, | |
| 390 | + | Some(theme.status_success) | |
| 391 | + | ); | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | #[test] | |
| 395 | + | fn no_two_statuses_render_the_same() { | |
| 396 | + | // The failure this guards is silent: a theme free to author its intents | |
| 397 | + | // however it likes can hand two statuses the same hue, and the one | |
| 398 | + | // screen whose whole job is telling them apart stops doing it. Checked | |
| 399 | + | // over every theme makeover ships, not only the viewer's own default, | |
| 400 | + | // since an operator can name any of them in `viewer.toml`. | |
| 401 | + | for (id, source) in makeover::embedded_themes() { | |
| 402 | + | let colors = makeover::parse_theme_str(id, source, false) | |
| 403 | + | .unwrap_or_else(|e| panic!("bundled theme `{id}` does not parse: {e}")); | |
| 404 | + | let Ok(theme) = Theme::from_theme(&colors) else { | |
| 405 | + | continue; // incomplete themes are `theme::load`'s error to report | |
| 406 | + | }; | |
| 407 | + | for (i, a) in ALL_STATUSES.iter().enumerate() { | |
| 408 | + | for b in &ALL_STATUSES[i + 1..] { | |
| 409 | + | let (sa, sb) = (status_style(&theme, *a), status_style(&theme, *b)); | |
| 410 | + | assert!( | |
| 411 | + | (sa.fg, sa.add_modifier) != (sb.fg, sb.add_modifier), | |
| 412 | + | "theme `{id}` renders {a:?} and {b:?} identically", | |
| 413 | + | ); | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | } | |
| 341 | 417 | } | |
| 342 | 418 | } |
| @@ -1,0 +1,247 @@ | |||
| 1 | + | //! Which theme the viewer renders in, and where it loads from. | |
| 2 | + | //! | |
| 3 | + | //! No hex value is written anywhere in this crate. There is deliberately no | |
| 4 | + | //! built-in fallback palette: a missing or malformed theme is an error the | |
| 5 | + | //! operator sees, not something papered over by rendering in colors that exist | |
| 6 | + | //! nowhere in the theme files. | |
| 7 | + | //! | |
| 8 | + | //! The shared app convention is wiki `makeover-app-convention`. The viewer is | |
| 9 | + | //! one of the store-less apps that note describes, with one difference worth | |
| 10 | + | //! knowing: it already owns a config file, so `viewer.toml` *is* the | |
| 11 | + | //! file-backed store and the convention's unprefixed `theme` key lives there. | |
| 12 | + | //! There is no picker and nothing writes the key back, which is why this | |
| 13 | + | //! module has no `remember`: the viewer displays, and choosing a theme is an | |
| 14 | + | //! edit to the same file that says what to display. | |
| 15 | + | ||
| 16 | + | use std::path::PathBuf; | |
| 17 | + | ||
| 18 | + | use anyhow::{Context, Result}; | |
| 19 | + | use makeover::{ThemeDefaults, ThemeDirs, ThemeMeta, ThemeSelection, Variant}; | |
| 20 | + | use makeover_tui::{Fidelity, Theme}; | |
| 21 | + | ||
| 22 | + | /// The viewer's own light theme: the platform's titular skin. | |
| 23 | + | const DEFAULT_LIGHT: &str = "makenotwork"; | |
| 24 | + | ||
| 25 | + | /// The viewer's own dark theme. | |
| 26 | + | /// | |
| 27 | + | /// MNW authors no dark skin of its own, so this is the nearest thing in the | |
| 28 | + | /// bundled set: near-neutral greys on the same axis as `makenotwork`'s | |
| 29 | + | /// parchment, rather than a theme with a hue of its own to bring. It is a | |
| 30 | + | /// fallback and not a pin — `ThemeSelection::resolve` reaches any installed | |
| 31 | + | /// dark theme when this one is missing, and the operator names the theme they | |
| 32 | + | /// want in `viewer.toml`. | |
| 33 | + | const DEFAULT_DARK: &str = "carbonfox"; | |
| 34 | + | ||
| 35 | + | /// Theme search path: the operator's own themes win, then makeover's bundled | |
| 36 | + | /// set. | |
| 37 | + | /// | |
| 38 | + | /// Two tiers and not three. There is no packaged `/usr/share` tier because | |
| 39 | + | /// nothing packages the viewer — it is an operator binary run out of a build, | |
| 40 | + | /// so a system tier would be a directory that never exists. | |
| 41 | + | fn search_path() -> Vec<(PathBuf, bool)> { | |
| 42 | + | ThemeDirs::new() | |
| 43 | + | .bundled(makeover::bundled_themes_dir()) | |
| 44 | + | .custom(config_home().map(|config| config.join("ops-viewer").join("themes"))) | |
| 45 | + | .build() | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// `$XDG_CONFIG_HOME`, else `~/.config`, matching where the viewer already | |
| 49 | + | /// looks for `viewer.toml`. | |
| 50 | + | fn config_home() -> Option<PathBuf> { | |
| 51 | + | if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) { | |
| 52 | + | return Some(PathBuf::from(xdg)); | |
| 53 | + | } | |
| 54 | + | std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// The themes the viewer falls back to when nothing has been chosen. | |
| 58 | + | fn defaults() -> ThemeDefaults { | |
| 59 | + | ThemeDefaults::new(DEFAULT_LIGHT, DEFAULT_DARK) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// Every theme the viewer can render, from the same search path it loads from. | |
| 63 | + | /// | |
| 64 | + | /// makeover's own scan rather than a second one: a list here that disagreed | |
| 65 | + | /// with [`load`] would resolve to an id that then failed to load. | |
| 66 | + | fn available() -> Vec<ThemeMeta> { | |
| 67 | + | makeover::list_themes_from_dirs(&search_path()) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// What the viewer is being drawn on, as makeover's vocabulary. | |
| 71 | + | /// | |
| 72 | + | /// The terminal's answer to a `prefers-color-scheme` media query, and the only | |
| 73 | + | /// one available without writing an OSC query and waiting for a reply before | |
| 74 | + | /// the first frame. `COLORFGBG` carries the background as a color index; 0-6 | |
| 75 | + | /// and 8 are the dark ones. A terminal that says nothing reads as light, which | |
| 76 | + | /// is the documented default. | |
| 77 | + | fn ambient() -> Variant { | |
| 78 | + | terminal_background().unwrap_or(Variant::Light) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | fn terminal_background() -> Option<Variant> { | |
| 82 | + | let value = std::env::var("COLORFGBG").ok()?; | |
| 83 | + | let bg = value.rsplit(';').next()?.trim().parse::<u8>().ok()?; | |
| 84 | + | Some(if bg <= 6 || bg == 8 { | |
| 85 | + | Variant::Dark | |
| 86 | + | } else { | |
| 87 | + | Variant::Light | |
| 88 | + | }) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Load the theme a selection resolves to, as this terminal can draw it. | |
| 92 | + | /// | |
| 93 | + | /// Resolved by makeover against the ambient mode and what is actually | |
| 94 | + | /// installed, so following the terminal reaches any dark theme the operator | |
| 95 | + | /// dropped in rather than only the one this crate names, and a theme that has | |
| 96 | + | /// since been deleted falls back instead of failing to load. | |
| 97 | + | /// | |
| 98 | + | /// Quantized through [`Theme::for_terminal`] rather than handed over as | |
| 99 | + | /// 24-bit. Left alone, a terminal below truecolor approximates the colors | |
| 100 | + | /// itself and its approximation collapses tones the theme keeps apart — which | |
| 101 | + | /// on this surface means a `degraded` and a `failed` source that no longer | |
| 102 | + | /// look different, on the one screen whose whole job is that difference. | |
| 103 | + | pub(crate) fn load(selection: &ThemeSelection) -> Result<Theme> { | |
| 104 | + | let dirs = search_path(); | |
| 105 | + | let id = selection.resolve(ambient(), &defaults(), &available()); | |
| 106 | + | ||
| 107 | + | let colors = makeover::load_theme(&dirs, &id) | |
| 108 | + | .map_err(anyhow::Error::msg) | |
| 109 | + | .with_context(|| { | |
| 110 | + | let searched: Vec<String> = dirs | |
| 111 | + | .iter() | |
| 112 | + | .map(|(path, _)| path.display().to_string()) | |
| 113 | + | .collect(); | |
| 114 | + | format!("loading theme `{id}` (searched: {})", searched.join(", ")) | |
| 115 | + | })?; | |
| 116 | + | ||
| 117 | + | Theme::from_theme(&colors) | |
| 118 | + | .map(|theme| theme.for_terminal(Fidelity::detect())) | |
| 119 | + | .map_err(|e| anyhow::anyhow!("{e}")) | |
| 120 | + | .with_context(|| format!("theme `{id}` is incomplete")) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | #[cfg(test)] | |
| 124 | + | pub(crate) mod tests { | |
| 125 | + | use super::*; | |
| 126 | + | ||
| 127 | + | /// A fixed theme for the render tests. | |
| 128 | + | /// | |
| 129 | + | /// Parsed from makeover's embedded copy rather than loaded off the search | |
| 130 | + | /// path, so the snapshot tests stay pure: they do not depend on which | |
| 131 | + | /// directories exist on the machine running them, on `COLORFGBG`, or on | |
| 132 | + | /// what the operator installed. | |
| 133 | + | pub(crate) fn fixed() -> Theme { | |
| 134 | + | let (_, source) = makeover::embedded_themes() | |
| 135 | + | .find(|(id, _)| *id == DEFAULT_LIGHT) | |
| 136 | + | .expect("makeover embeds the viewer's default theme"); | |
| 137 | + | let colors = makeover::parse_theme_str(DEFAULT_LIGHT, source, false) | |
| 138 | + | .expect("the embedded default theme parses"); | |
| 139 | + | Theme::from_theme(&colors).expect("the embedded default theme is complete") | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // The operator's own themes must outrank the bundled ones. Asserted from | |
| 143 | + | // this side because `ThemeDirs` is what makes the order not this file's to | |
| 144 | + | // get backwards, and a hand-built vector here could still reverse it. | |
| 145 | + | #[test] | |
| 146 | + | fn the_operators_own_themes_outrank_the_bundled_ones() { | |
| 147 | + | let dirs = search_path(); | |
| 148 | + | assert!( | |
| 149 | + | dirs.iter().filter(|(_, is_custom)| *is_custom).count() <= 1, | |
| 150 | + | "exactly one tier is the operator's: {dirs:?}", | |
| 151 | + | ); | |
| 152 | + | if let (Some(custom), Some(bundled)) = ( | |
| 153 | + | dirs.iter().position(|(_, is_custom)| *is_custom), | |
| 154 | + | dirs.iter().position(|(_, is_custom)| !*is_custom), | |
| 155 | + | ) { | |
| 156 | + | assert!( | |
| 157 | + | custom > bundled, | |
| 158 | + | "the operator's themes must come last so they win: {dirs:?}", | |
| 159 | + | ); | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // Nothing in `viewer.toml` is Follow, not a pin on whatever the first run | |
| 164 | + | // guessed. | |
| 165 | + | #[test] | |
| 166 | + | fn an_unset_key_follows_the_terminal() { | |
| 167 | + | assert_eq!(ThemeSelection::parse(None), ThemeSelection::Follow); | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // `COLORFGBG` carries the background as a color index; 0-6 and 8 are dark. | |
| 171 | + | #[test] | |
| 172 | + | fn the_terminal_background_reads_as_a_variant() { | |
| 173 | + | for (raw, expect) in [ | |
| 174 | + | ("15;0", Variant::Dark), | |
| 175 | + | ("0;15", Variant::Light), | |
| 176 | + | ("15;8", Variant::Dark), | |
| 177 | + | ("15;7", Variant::Light), | |
| 178 | + | ] { | |
| 179 | + | let dark = raw | |
| 180 | + | .rsplit(';') | |
| 181 | + | .next() | |
| 182 | + | .and_then(|bg| bg.trim().parse::<u8>().ok()) | |
| 183 | + | .is_some_and(|bg| bg <= 6 || bg == 8); | |
| 184 | + | let got = if dark { Variant::Dark } else { Variant::Light }; | |
| 185 | + | assert_eq!(got, expect, "COLORFGBG={raw}"); | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // Following resolves to the theme matching the terminal rather than to a | |
| 190 | + | // fixed default, and both ids the viewer names are ones makeover ships. | |
| 191 | + | #[test] | |
| 192 | + | fn following_resolves_to_the_theme_matching_the_terminal() { | |
| 193 | + | let available = available(); | |
| 194 | + | if available.is_empty() { | |
| 195 | + | return; // no theme directory on this machine; nothing to resolve against | |
| 196 | + | } | |
| 197 | + | assert_eq!( | |
| 198 | + | ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &available), | |
| 199 | + | DEFAULT_DARK, | |
| 200 | + | ); | |
| 201 | + | assert_eq!( | |
| 202 | + | ThemeSelection::Follow.resolve(Variant::Light, &defaults(), &available), | |
| 203 | + | DEFAULT_LIGHT, | |
| 204 | + | ); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // A pin wins over the ambient mode; that is the difference between a | |
| 208 | + | // selection and a rendered id. | |
| 209 | + | #[test] | |
| 210 | + | fn a_pinned_theme_ignores_the_terminal() { | |
| 211 | + | let available = available(); | |
| 212 | + | if available.is_empty() { | |
| 213 | + | return; | |
| 214 | + | } | |
| 215 | + | let pinned = ThemeSelection::parse(Some("nord")); | |
| 216 | + | assert_eq!( | |
| 217 | + | pinned.resolve(Variant::Light, &defaults(), &available), | |
| 218 | + | "nord" | |
| 219 | + | ); | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // Both ids this crate ships against exist in makeover's set. A rename over | |
| 223 | + | // there should fail here rather than at an operator's first launch. | |
| 224 | + | #[test] | |
| 225 | + | fn the_shipped_defaults_load_and_are_complete() { | |
| 226 | + | if available().is_empty() { | |
| 227 | + | return; | |
| 228 | + | } | |
| 229 | + | for id in [DEFAULT_LIGHT, DEFAULT_DARK] { | |
| 230 | + | let colors = makeover::load_theme(&search_path(), id) | |
| 231 | + | .unwrap_or_else(|e| panic!("default theme `{id}` failed to load: {e}")); | |
| 232 | + | assert!( | |
| 233 | + | Theme::from_theme(&colors).is_ok(), | |
| 234 | + | "default theme `{id}` is incomplete", | |
| 235 | + | ); | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | #[test] | |
| 240 | + | fn a_missing_theme_is_an_error_not_a_fallback() { | |
| 241 | + | // A `Fixed` id that is not installed falls back through `resolve`, so | |
| 242 | + | // the error path is the one where the resolved theme itself is | |
| 243 | + | // unreadable. Asserted through `load_theme` directly, since `resolve` | |
| 244 | + | // by design never hands back an id it did not find. | |
| 245 | + | assert!(makeover::load_theme(&search_path(), "no-such-theme").is_err()); | |
| 246 | + | } | |
| 247 | + | } |