1use draw::{hsl, hue, srgb};
2
3/// Colours are linear RGBA; sizes are logical pixels.
4#[derive(Clone, Debug)]
5pub struct Theme {
6 pub font_size: f32,
7 /// Content surfaces, such as fields and lists.
8 pub base: [f32; 4],
9 /// Side panels.
10 pub panel: [f32; 4],
11 /// The window's title bar, toolbar and tab row; transparent where the system's backdrop
12 /// shows through them.
13 pub strip: [f32; 4],
14 /// The notebook sidebar: the strip's colour, unless kept opaque over a backdrop.
15 pub sidebar: [f32; 4],
16 /// Toolbar buttons and their groups at rest: clear, or a face where the backdrop would
17 /// hide their icons.
18 pub tool: [f32; 4],
19 /// Gathers the toolbar's groups on one `tool` face the length of the row, ruled between
20 /// groups, rather than a face per group.
21 pub tool_panel: bool,
22 pub accent: [f32; 4],
23 /// The text caret, and selected text's fill with and without keyboard focus; the
24 /// platform's own where it has them.
25 pub caret: [f32; 4],
26 pub selection: [f32; 4],
27 pub inactive_selection: [f32; 4],
28 pub text: [f32; 4],
29 pub text_dim: [f32; 4],
30 /// Text on section colours.
31 pub ink: [f32; 4],
32 /// The page's colour, which the open page's tab shares.
33 pub paper: [f32; 4],
34 /// Text on the paper, and on the page wherever its colour is left automatic.
35 pub paper_ink: [f32; 4],
36 /// Key caps, small controls and field borders.
37 pub chip: [f32; 4],
38 /// Menus and other popups, and the shadow they cast.
39 pub popup: [f32; 4],
40 pub shadow: [f32; 4],
41 /// Saturation and lightness each of a section's colours takes from its hue.
42 pub shades: Shades,
43 /// Menus as the desktop draws its own, where Snowbound follows it; see `Theme::menu`.
44 pub desktop_menu: Option<Menu>,
45}
46
47/// How menus and the other popups look and move. Colours are linear RGBA; sizes are
48/// logical pixels.
49#[derive(Clone, Debug, PartialEq)]
50pub struct Menu {
51 pub fill: [f32; 4],
52 pub border: [f32; 4],
53 /// The layers of the shadow a popup casts, painted in order.
54 pub shadows: Vec<Shadow>,
55 pub text: [f32; 4],
56 /// Shortcuts and headings.
57 pub dim: [f32; 4],
58 pub disabled: [f32; 4],
59 /// The row the pointer or keyboard is on, and its outline.
60 pub highlight: [f32; 4],
61 pub highlight_border: Option<[f32; 4]>,
62 /// The line between groups of rows.
63 pub rule: [f32; 4],
64 pub font_size: f32,
65 pub radius: f32,
66 /// Inset of the popup's contents.
67 pub pad: f32,
68 pub row: f32,
69 pub row_radius: f32,
70 /// Inset of a row's icon and text inside its highlight.
71 pub row_pad: f32,
72 /// Height of the band between groups, and how far the rule across it stays from the
73 /// rows' ends.
74 pub rule_band: f32,
75 pub rule_inset: f32,
76 pub motion: PopupMotion,
77}
78
79/// A soft shadow blurred `blur` wide, `drop` below its box and grown `spread` past it, as
80/// CSS's `box-shadow` takes them.
81#[derive(Clone, Copy, Debug, PartialEq)]
82pub struct Shadow {
83 pub color: [f32; 4],
84 pub blur: f32,
85 pub drop: f32,
86 pub spread: f32,
87}
88
89/// How popups other than dialogs open and close.
90#[derive(Clone, Copy, Debug, PartialEq)]
91pub enum PopupMotion {
92 /// Growing out of what they open from as they fade, Snowbound's own.
93 Grow,
94 /// At once, as GTK 4 shows its popovers.
95 Cut,
96 /// Opacity alone: in linearly over the first seconds, out over the second easing as
97 /// OutQuart, as KWin's Fading Popups.
98 Fade([f32; 2]),
99}
100
101/// The platform's caret and selection colours in a light or `dark` appearance.
102fn text_colors(dark: bool) -> [[f32; 4]; 3] {
103 draw::edit::Platform::CURRENT
104 .text_colors(dark)
105 .map(|([red, green, blue], alpha)| {
106 let [red, green, blue, _] = srgb(red, green, blue);
107 [red, green, blue, alpha]
108 })
109}
110
111/// Saturation and lightness pairs, each from 0 to 1.
112#[derive(Clone, Copy, Debug)]
113pub struct Shades {
114 /// The frame's top and bottom; the bottom turns 10° further round the hue.
115 pub frame: [[f32; 2]; 2],
116 pub tab: [f32; 2],
117 pub edge: [f32; 2],
118 pub accent: [f32; 2],
119}
120
121/// A section's colours in a theme.
122#[derive(Clone, Copy, Debug)]
123pub struct Section {
124 /// Top and bottom of the frame around the page, which the open tab shares.
125 pub frame: [[f32; 4]; 2],
126 /// Tabs not open.
127 pub tab: [f32; 4],
128 /// Borders around the open tab and the page.
129 pub edge: [f32; 4],
130 pub accent: [f32; 4],
131}
132
133impl Section {
134 /// A tab under the pointer: most of the way to the accent, which lies past the frame's
135 /// lightness from the tabs in both themes, so it stands out from both.
136 pub fn hover(&self) -> [f32; 4] {
137 crate::mix(self.tab, self.accent, 0.7)
138 }
139}
140
141impl Theme {
142 /// After File Pilot's default dark scheme.
143 pub fn dark() -> Self {
144 let [caret, selection, inactive_selection] = text_colors(true);
145 Self {
146 font_size: 13.0,
147 base: srgb(0x19, 0x1b, 0x1c),
148 panel: srgb(0x1f, 0x22, 0x23),
149 strip: srgb(0x27, 0x2a, 0x2b),
150 sidebar: srgb(0x27, 0x2a, 0x2b),
151 tool: [0.0; 4],
152 tool_panel: false,
153 accent: srgb(0x00, 0x79, 0xa6),
154 caret,
155 selection,
156 inactive_selection,
157 text: srgb(0xdd, 0xde, 0xe0),
158 text_dim: srgb(0x6b, 0x70, 0x78),
159 ink: srgb(0xe8, 0xe9, 0xeb),
160 paper: srgb(0x1f, 0x20, 0x22),
161 paper_ink: srgb(0xe6, 0xe6, 0xe6),
162 chip: srgb(0x38, 0x3c, 0x3d),
163 popup: srgb(0x2b, 0x2e, 0x30),
164 shadow: [0.0, 0.0, 0.0, 0.6],
165 shades: Shades {
166 frame: [[0.30, 0.36], [0.30, 0.30]],
167 tab: [0.22, 0.25],
168 edge: [0.30, 0.16],
169 accent: [0.55, 0.55],
170 },
171 desktop_menu: None,
172 }
173 }
174
175 pub fn light() -> Self {
176 let [caret, selection, inactive_selection] = text_colors(false);
177 Self {
178 font_size: 13.0,
179 base: srgb(0xfc, 0xfc, 0xfd),
180 panel: srgb(0xf4, 0xf5, 0xf7),
181 strip: srgb(0xeb, 0xed, 0xf0),
182 sidebar: srgb(0xeb, 0xed, 0xf0),
183 tool: [0.0; 4],
184 tool_panel: false,
185 accent: srgb(0x00, 0x79, 0xa6),
186 caret,
187 selection,
188 inactive_selection,
189 text: srgb(0x1f, 0x23, 0x28),
190 text_dim: srgb(0x7b, 0x82, 0x8c),
191 ink: srgb(0x1d, 0x1e, 0x20),
192 paper: [1.0; 4],
193 paper_ink: [0.0, 0.0, 0.0, 1.0],
194 chip: srgb(0xcf, 0xd3, 0xd9),
195 popup: [1.0; 4],
196 shadow: [0.0, 0.0, 0.0, 0.3],
197 shades: Shades {
198 frame: [[0.55, 0.82], [0.55, 0.76]],
199 tab: [0.45, 0.70],
200 edge: [0.30, 0.52],
201 accent: draw::LIGHT_ACCENT,
202 },
203 desktop_menu: None,
204 }
205 }
206
207 /// The theme over a system material that shows through the strip: dim text, controls
208 /// and fields take the text colour at part opacity, as vibrancy draws them.
209 pub fn over_backdrop(self) -> Self {
210 let text = |alpha| [self.text[0], self.text[1], self.text[2], alpha];
211 Self {
212 strip: [0.0; 4],
213 sidebar: [0.0; 4],
214 text_dim: text(0.5),
215 chip: text(0.1),
216 base: text(0.05),
217 ..self
218 }
219 }
220
221 /// Menus as the desktop draws them, or Snowbound's own.
222 pub fn menu(&self) -> Menu {
223 use crate::popup::{MENU_ROW, PAD};
224 self.desktop_menu.clone().unwrap_or_else(|| Menu {
225 fill: self.popup,
226 border: self.chip,
227 shadows: vec![Shadow {
228 color: self.shadow,
229 blur: crate::POPUP_SHADOW[0],
230 drop: crate::POPUP_SHADOW[1],
231 spread: 0.0,
232 }],
233 text: self.text,
234 dim: self.text_dim,
235 disabled: self.text_dim,
236 highlight: self.hover(),
237 highlight_border: None,
238 rule: self.chip,
239 font_size: self.font_size,
240 radius: 6.0,
241 pad: PAD,
242 row: MENU_ROW,
243 row_radius: 4.0,
244 row_pad: 8.0,
245 rule_band: 9.0,
246 rule_inset: 0.0,
247 motion: PopupMotion::Grow,
248 })
249 }
250
251 /// Hovered controls: the accent over the base.
252 pub fn hover(&self) -> [f32; 4] {
253 crate::mix(self.base, self.accent, 0.35)
254 }
255
256 /// Colours for icons' slots under a section of `accent`, with `highlight` the colour
257 /// the highlighter applies. The accent keeps its hue but stands as clear of the toolbar's
258 /// lightness as text must; the badge takes the first of green, blue and orange whose hue
259 /// stands clear of the accent's.
260 pub fn icon_palette(&self, accent: [f32; 4], highlight: [f32; 4]) -> draw::Palette {
261 // Over a system material the strip is clear, and the panel has its lightness.
262 let behind = if self.strip[3] == 1.0 {
263 self.strip
264 } else {
265 self.panel
266 };
267 let [under, ..] = draw::oklab(behind);
268 let [lightness, a, b] = draw::oklab(accent);
269 let lightness = if under < 0.5 {
270 lightness.max(under + ICON_CONTRAST)
271 } else {
272 lightness.min(under - ICON_CONTRAST)
273 };
274 let [r, g, b] = draw::from_oklab([lightness, a, b]);
275 let accent = [r, g, b, 1.0];
276 let apart = |a: f32, b: f32| {
277 let turn = (a - b).rem_euclid(360.0);
278 turn.min(360.0 - turn)
279 };
280 let accent_hue = hue(accent);
281 let badge = [135.0, 215.0, 30.0]
282 .into_iter()
283 .find(|badge| apart(*badge, accent_hue) >= 60.0)
284 .expect("Three hues this far apart leave one clear of any other");
285 let rgb = |[r, g, b, _]: [f32; 4]| [r, g, b];
286 draw::Palette {
287 accent: Some(rgb(accent)),
288 highlight: Some(rgb(highlight)),
289 badge: Some(rgb(hsl(badge, 0.65, 0.45))),
290 }
291 }
292
293 /// The colours a section takes from the hue of `color`, linear RGBA, as grey as it is.
294 pub fn section(&self, color: [f32; 4]) -> Section {
295 let (hue, vividness) = (hue(color), draw::vividness(color));
296 let shade = |hue: f32, [saturation, lightness]: [f32; 2]| {
297 hsl(hue, saturation * vividness, lightness)
298 };
299 let Shades {
300 frame,
301 tab,
302 edge,
303 accent,
304 } = self.shades;
305 Section {
306 frame: [shade(hue, frame[0]), shade(hue + 10.0, frame[1])],
307 tab: shade(hue, tab),
308 edge: shade(hue, edge),
309 accent: shade(hue, accent),
310 }
311 }
312}
313
314/// Least OKLab lightness difference between icons' accent marks and the toolbar.
315const ICON_CONTRAST: f32 = 0.4;
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn sections_keep_their_hue_at_the_theme_s_shades() {
323 let orange = srgb(0xf6, 0xb0, 0x78);
324 let section = Theme::dark().section(orange);
325 assert!((hue(section.frame[0]) - hue(orange)).abs() < 3.0);
326 assert!((hue(section.frame[1]) - hue(orange) - 10.0).abs() < 3.0);
327 assert_eq!(hsl(0.0, 1.0, 0.5), srgb(0xff, 0, 0));
328 assert_eq!(hsl(240.0, 0.0, 1.0), srgb(0xff, 0xff, 0xff));
329 }
330
331 /// OneNote 2010's Silver (`corpus/section-color/native/colors.txt`) shades grey, as
332 /// faint in hue as it is, while its Blue takes the theme's full saturation.
333 #[test]
334 fn grey_sections_stay_grey() {
335 // HSL saturation, as sRGB shows it.
336 let saturation = |color: [f32; 4]| {
337 let [r, g, b] = draw::srgb_bytes(color).map(|byte| f32::from(byte) / 255.0);
338 let [max, min] = [r.max(g).max(b), r.min(g).min(b)];
339 (max - min) / (1.0 - (max + min - 1.0).abs())
340 };
341 for theme in [Theme::light(), Theme::dark()] {
342 let silver = theme.section(srgb(0x95, 0x95, 0xaa));
343 let blue = theme.section(srgb(0x8a, 0xa8, 0xe4));
344 for (silver, blue) in [
345 (silver.tab, blue.tab),
346 (silver.accent, blue.accent),
347 (silver.frame[0], blue.frame[0]),
348 ] {
349 assert!(
350 saturation(silver) < saturation(blue) * 0.4,
351 "{silver:?} {blue:?}"
352 );
353 }
354 }
355 }
356
357 #[test]
358 fn icon_badges_stand_clear_of_the_accent() {
359 let badge = |accent_hue: f32| {
360 let palette = Theme::light().icon_palette(hsl(accent_hue, 0.6, 0.45), [1.0; 4]);
361 let [r, g, b] = palette.badge.unwrap();
362 hue([r, g, b, 1.0]).round()
363 };
364 assert_eq!(badge(210.0), 135.0);
365 assert_eq!(badge(120.0), 215.0);
366 assert_eq!(badge(170.0), 30.0);
367 }
368
369 #[test]
370 fn icon_accents_stand_clear_of_the_toolbar() {
371 for theme in [Theme::light(), Theme::dark()] {
372 let [under, ..] = draw::oklab(theme.strip);
373 for hue in (0..360).step_by(15) {
374 let accent = theme.section(hsl(hue as f32, 0.6, 0.6)).accent;
375 let [r, g, b] = theme.icon_palette(accent, [1.0; 4]).accent.unwrap();
376 let [lightness, ..] = draw::oklab([r, g, b, 1.0]);
377 assert!(
378 (lightness - under).abs() >= ICON_CONTRAST - 0.02,
379 "{hue}: {lightness} on {under}"
380 );
381 }
382 }
383 }
384
385 #[test]
386 fn a_hovered_tab_stands_out_from_the_frame_and_its_neighbours() {
387 let lightness = |[r, g, b, _]: [f32; 4]| {
388 let y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
389 116.0 * y.cbrt() - 16.0
390 };
391 for theme in [Theme::light(), Theme::dark()] {
392 for hue in (0..360).step_by(5) {
393 let section = theme.section(hsl(hue as f32, 0.6, 0.6));
394 let hover = lightness(section.hover());
395 for other in [section.frame[0], section.frame[1], section.tab] {
396 assert!((hover - lightness(other)).abs() > 5.0, "{hue}");
397 }
398 }
399 }
400 }
401
402 #[test]
403 fn colours_mix_as_premultiplied_so_transparency_fades_a_colour_in() {
404 let [white, black] = [[1.0; 4], [0.0, 0.0, 0.0, 1.0]];
405 assert_eq!(crate::mix(white, black, 0.25), [0.75, 0.75, 0.75, 1.0]);
406 let accent = Theme::light().accent;
407 let faded = crate::mix([0.0; 4], accent, 0.6);
408 assert!((0..3).all(|channel| (faded[channel] - accent[channel]).abs() < 1e-6));
409 assert!((faded[3] - 0.6).abs() < 1e-6);
410 assert_eq!(crate::mix([0.0; 4], [0.0; 4], 0.5), [0.0; 4]);
411 }
412}