Skip to main content

max / makenotwork

10.9 KB · 273 lines History Blame Raw
1 //! Which theme the creator's TUI renders in, and where it comes from.
2 //!
3 //! No colour is written anywhere in this crate. There is no built-in fallback
4 //! palette: a theme that will not resolve is an error the session reports, not
5 //! something papered over by rendering in colours that exist in no theme file.
6 //!
7 //! The shared app convention is wiki `makeover-app-convention`. This surface
8 //! differs from every other consumer of that convention in one way that decides
9 //! the whole module: **the process rendering the TUI is not the process the
10 //! creator is sitting in front of.** `mnw-cli` is an SSH daemon on the MNW host,
11 //! and the terminal is at the other end of the connection. So neither of the two
12 //! things a local TUI reads from its own environment is available here:
13 //!
14 //! - **The theme search path is embedded, not on disk.** `bundled_themes_dir()`
15 //! resolves against makeover's own `CARGO_MANIFEST_DIR`, which on a deployed
16 //! host names a cargo-registry directory belonging to the *build* machine. A
17 //! disk tier would be absent in production and stale in staging, and the
18 //! creator cannot drop a file on the host to fill one anyway. The embedded set
19 //! travels with the binary and is the only tier that is always correct.
20 //! - **The ambient mode and the colour fidelity come off the wire**, from the
21 //! client's `TERM`, `COLORTERM` and `COLORFGBG`. Reading `std::env` here would
22 //! describe the daemon's terminal, which is a systemd journal.
23 //!
24 //! What the creator chose is [`UserInfo::theme_id`](crate::api::UserInfo), which
25 //! the server does not send yet; until it does every session follows the
26 //! terminal. See the `ThemeSelection` handling in [`selection`].
27
28 use std::sync::OnceLock;
29
30 use anyhow::{Context, Result};
31 use makeover::{ThemeColors, ThemeDefaults, ThemeMeta, ThemeSelection, Variant};
32 use makeover_tui::{Fidelity, Theme};
33
34 /// The platform's own light theme: the titular skin, and what the website wears.
35 const DEFAULT_LIGHT: &str = "makenotwork";
36
37 /// The nearest dark theme in the embedded set.
38 ///
39 /// MNW authors no dark skin of its own, so this is a fallback rather than a pin:
40 /// near-neutral greys on the same axis as `makenotwork`'s parchment, rather than
41 /// a theme bringing a hue of its own. `ThemeSelection::resolve` reaches any
42 /// other embedded dark theme once a creator can name one.
43 const DEFAULT_DARK: &str = "carbonfox";
44
45 /// The themes this binary ships, parsed once.
46 ///
47 /// Parsed rather than listed off disk: see the module header. Held in a
48 /// `OnceLock` because every SSH session resolves a theme and re-parsing 30-odd
49 /// TOML files per login is a cost with no buyer.
50 fn embedded() -> &'static [(String, ThemeColors)] {
51 static THEMES: OnceLock<Vec<(String, ThemeColors)>> = OnceLock::new();
52 THEMES.get_or_init(|| {
53 makeover::embedded_themes()
54 .filter_map(|(id, source)| {
55 match makeover::parse_theme_str(id, source, false) {
56 Ok(colors) => Some((id.to_string(), colors)),
57 // An embedded theme that will not parse is a packaging bug
58 // in makeover, not something this creator's session can act
59 // on. Drop it and keep the rest rather than failing every
60 // login over a theme nobody asked for.
61 Err(e) => {
62 tracing::error!(theme = id, error = %e, "embedded theme failed to parse");
63 None
64 }
65 }
66 })
67 .collect()
68 })
69 }
70
71 /// The themes a selection can resolve to.
72 fn available() -> Vec<ThemeMeta> {
73 embedded()
74 .iter()
75 .map(|(_, colors)| colors.meta.clone())
76 .collect()
77 }
78
79 /// The platform's own light/dark pair, for a selection that follows.
80 fn defaults() -> ThemeDefaults {
81 ThemeDefaults::new(DEFAULT_LIGHT, DEFAULT_DARK)
82 }
83
84 /// What the creator's terminal told us about itself, captured from the SSH
85 /// session rather than from this process.
86 ///
87 /// `TERM` arrives on the PTY request and is always present for a TUI session.
88 /// `COLORTERM` and `COLORFGBG` arrive only if the client was configured to send
89 /// them (`SendEnv`), which most are not — both fields are routinely empty, and
90 /// the defaults each one falls back to are the documented behaviour rather than
91 /// a degraded mode.
92 #[derive(Debug, Clone, Default)]
93 pub(crate) struct ClientTerminal {
94 /// The `TERM` from the PTY request.
95 pub(crate) term: String,
96 /// `COLORTERM`, if the client sent it.
97 pub(crate) colorterm: String,
98 /// `COLORFGBG`, if the client sent it.
99 pub(crate) colorfgbg: Option<String>,
100 }
101
102 impl ClientTerminal {
103 /// The terminal's answer to a `prefers-color-scheme` media query.
104 ///
105 /// `COLORFGBG` carries the background as a colour index; 0-6 and 8 are the
106 /// dark ones. A terminal that says nothing reads as light, which is the
107 /// documented default across the family. The alternative is an OSC 11 query
108 /// and a wait for the reply before the first frame, which is a round trip
109 /// over the creator's SSH connection for a preference the server will carry
110 /// explicitly soon enough.
111 fn ambient(&self) -> Variant {
112 self.colorfgbg
113 .as_deref()
114 .and_then(|value| value.rsplit(';').next())
115 .and_then(|bg| bg.trim().parse::<u8>().ok())
116 .map_or(Variant::Light, |bg| {
117 if bg <= 6 || bg == 8 {
118 Variant::Dark
119 } else {
120 Variant::Light
121 }
122 })
123 }
124
125 /// How much colour the far end can draw.
126 fn fidelity(&self) -> Fidelity {
127 Fidelity::from_env(&self.colorterm, &self.term)
128 }
129 }
130
131 /// What this creator chose, as the convention encodes it.
132 ///
133 /// `None` is not "no preference expressed" but "the server did not tell us",
134 /// and both land on `Follow` today. They separate once `UserInfo` carries the
135 /// field: a creator who picked a theme on the website gets `Fixed`, and one who
136 /// never opened the setting keeps following their terminal.
137 pub(crate) fn selection(theme_id: Option<&str>) -> ThemeSelection {
138 ThemeSelection::parse(theme_id)
139 }
140
141 /// Load the theme a selection resolves to, as this client can draw it.
142 ///
143 /// Quantised through [`Theme::for_terminal`] rather than handed over as 24-bit.
144 /// Left alone, a terminal below truecolor approximates the colours itself and
145 /// its approximation collapses tones the theme keeps apart — here that means a
146 /// published item and a draft one stop looking different.
147 pub(crate) fn load(selection: &ThemeSelection, client: &ClientTerminal) -> Result<Theme> {
148 let id = selection.resolve(client.ambient(), &defaults(), &available());
149
150 let colors = embedded()
151 .iter()
152 .find(|(embedded_id, _)| *embedded_id == id)
153 .map(|(_, colors)| colors)
154 .with_context(|| format!("theme `{id}` is not embedded in this binary"))?;
155
156 Theme::from_theme(colors)
157 .map(|theme| theme.for_terminal(client.fidelity()))
158 .map_err(|e| anyhow::anyhow!("{e}"))
159 .with_context(|| format!("theme `{id}` is incomplete"))
160 }
161
162 #[cfg(test)]
163 pub(crate) mod tests {
164 use super::*;
165
166 /// A fixed theme for tests that need to render something.
167 pub(crate) fn fixed() -> Theme {
168 load(
169 &ThemeSelection::Fixed(DEFAULT_LIGHT.into()),
170 &ClientTerminal::default(),
171 )
172 .expect("the platform's own theme loads")
173 }
174
175 // The embedded set is the whole search path, so an empty one means every
176 // session renders nothing. Asserted here because nothing else would notice
177 // until a creator logged in.
178 #[test]
179 fn the_binary_ships_its_own_themes() {
180 assert!(
181 !embedded().is_empty(),
182 "no themes embedded; every session would fail to resolve one",
183 );
184 }
185
186 // Both ids this crate names must exist in makeover's embedded set. A rename
187 // over there should fail here rather than at a creator's next login.
188 //
189 // Asserted against `embedded()` and not through `load`, because `resolve`
190 // answers a `Fixed` id it cannot find by falling back to something it can —
191 // so a `load` that succeeded would prove nothing about the id being named.
192 #[test]
193 fn the_named_defaults_are_embedded_and_complete() {
194 for id in [DEFAULT_LIGHT, DEFAULT_DARK] {
195 let (_, colors) = embedded()
196 .iter()
197 .find(|(embedded_id, _)| embedded_id == id)
198 .unwrap_or_else(|| panic!("default theme `{id}` is not embedded"));
199 assert!(
200 Theme::from_theme(colors).is_ok(),
201 "default theme `{id}` is incomplete",
202 );
203 }
204 }
205
206 // Following reaches the theme matching the terminal, not a fixed default.
207 #[test]
208 fn following_resolves_to_the_theme_matching_the_terminal() {
209 let available = available();
210 assert_eq!(
211 ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &available),
212 DEFAULT_DARK,
213 );
214 assert_eq!(
215 ThemeSelection::Follow.resolve(Variant::Light, &defaults(), &available),
216 DEFAULT_LIGHT,
217 );
218 }
219
220 // A server that does not send the field and a creator who never chose are
221 // the same thing today, and both follow.
222 #[test]
223 fn an_absent_theme_id_follows_the_terminal() {
224 assert_eq!(selection(None), ThemeSelection::Follow);
225 assert_eq!(
226 selection(Some("nord")),
227 ThemeSelection::Fixed("nord".into())
228 );
229 }
230
231 // `COLORFGBG` is the client's, and it decides light from dark.
232 #[test]
233 fn the_clients_background_reads_as_a_variant() {
234 for (raw, expect) in [
235 ("15;0", Variant::Dark),
236 ("0;15", Variant::Light),
237 ("15;8", Variant::Dark),
238 ("15;7", Variant::Light),
239 ] {
240 let client = ClientTerminal {
241 colorfgbg: Some(raw.to_string()),
242 ..ClientTerminal::default()
243 };
244 assert_eq!(client.ambient(), expect, "COLORFGBG={raw}");
245 }
246 }
247
248 // A client that sent nothing is light, not an error and not a guess.
249 #[test]
250 fn a_silent_client_reads_as_light() {
251 assert_eq!(ClientTerminal::default().ambient(), Variant::Light);
252 }
253
254 // The fidelity is the client's claim, never this daemon's environment. The
255 // Linux virtual console is the case that matters: it really does have
256 // sixteen colours, and a truecolor guess there throws the theme away.
257 #[test]
258 fn the_fidelity_comes_from_the_client_not_the_daemon() {
259 let console = ClientTerminal {
260 term: "linux".to_string(),
261 ..ClientTerminal::default()
262 };
263 assert_eq!(console.fidelity(), Fidelity::Ansi16);
264
265 let modern = ClientTerminal {
266 term: "xterm-256color".to_string(),
267 colorterm: "truecolor".to_string(),
268 ..ClientTerminal::default()
269 };
270 assert_eq!(modern.fidelity(), Fidelity::TrueColor);
271 }
272 }
273