diff --git a/crates/apps/tally/manifest.toml b/crates/apps/tally/manifest.toml index 6f4be37..f2e5afd 100644 --- a/crates/apps/tally/manifest.toml +++ b/crates/apps/tally/manifest.toml @@ -8,3 +8,6 @@ memory = "3 MiB" [capabilities] required = ["display"] + +[appearance] +accent = "#e91e63" diff --git a/crates/libkos/src/display.rs b/crates/libkos/src/display.rs index b31df92..8509f6d 100644 --- a/crates/libkos/src/display.rs +++ b/crates/libkos/src/display.rs @@ -31,6 +31,12 @@ pub fn open(display: CapSlot, name: &[u8]) -> Option { } } +/// A launcher gives the window it opened its app's accent (`0xrrggbb`), +/// before the app runs; whether the display took it. +pub fn set_accent(window: CapSlot, rgb: u32) -> bool { + syscall::request(window, [w::ACCENT, rgb as usize, 0, 0]).is_ok_and(|r| r[0] == w::OK) +} + /// Closes `window`: every copy of it, and its buffer, stop working. pub fn close(window: CapSlot) { let _ = syscall::request(window, [w::CLOSE, 0, 0, 0]); diff --git a/crates/servers/display-server/src/server.rs b/crates/servers/display-server/src/server.rs index 844275f..adb991c 100644 --- a/crates/servers/display-server/src/server.rs +++ b/crates/servers/display-server/src/server.rs @@ -215,20 +215,24 @@ impl DisplayServer { /// A request through window `index`'s capability. fn window_call(&mut self, index: usize, badge: usize, words: [usize; 4], cap: Option) -> ([usize; 4], Option) { match (words[0], cap) { - (w::BUFFER, _) => match self.host.buffer(index, self.pixels()) { - Some(buffer) => { - let (width, height) = self.window_size(); - ([w::OK, width.raw() as usize, height.raw() as usize, 0], Some(Give::from(buffer))) + (w::BUFFER, _) => { + // The app runs: its accent is settled. + self.windows.start(badge); + match self.host.buffer(index, self.pixels()) { + Some(buffer) => { + let (width, height) = self.window_size(); + ([w::OK, width.raw() as usize, height.raw() as usize, 0], Some(Give::from(buffer))) + } + None => ([MALFORMED, 0, 0, 0], None), } - None => ([MALFORMED, 0, 0, 0], None), - }, + } + (w::ACCENT, _) if words[1] <= 0xff_ffff && self.windows.set_accent(badge, words[1] as u32) => ([w::OK, 0, 0, 0], None), (w::PRESENT, _) => { let (x, y, width, height) = w::unpack_rect([words[1], words[2]]); self.present(index, x, y, width, height); ([w::OK, 0, 0, 0], None) } - (w::SUBSCRIBE, Some(notify)) if self.windows.subscribe(badge, notify) => ([w::OK, 0, 0, 0], None), - (w::THEME, _) => match self.themes.part(words[1]) { + (w::THEME, _) => match self.themes.part(words[1], self.windows.accent(badge)) { Some([a, b, c]) => ([w::OK, a, b, c], None), None => ([MALFORMED, 0, 0, 0], None), }, @@ -239,6 +243,7 @@ impl DisplayServer { } None => ([MALFORMED, 0, 0, 0], None), }, + (w::SUBSCRIBE, Some(notify)) if self.windows.subscribe(badge, notify) => ([w::OK, 0, 0, 0], None), (w::POLL, _) => (self.windows.poll(badge).unwrap_or([0; 4]), None), (w::CLOSE, _) => { self.close(index); diff --git a/crates/servers/display-server/src/tests/server.rs b/crates/servers/display-server/src/tests/server.rs index 99239bf..1a3ce41 100644 --- a/crates/servers/display-server/src/tests/server.rs +++ b/crates/servers/display-server/src/tests/server.rs @@ -232,3 +232,23 @@ fn init_switches_the_theme_and_every_window_hears_of_it() { let bogus = w::pack_name(b"Nope"); assert_eq!(s.handle_call(0, [w::SET_THEME, bogus[0], bogus[1], bogus[2]], None).0[0], MALFORMED); } + +#[test] +fn only_the_launcher_sets_an_accent_and_it_is_in_the_theme() { + let mut s = server(); + let app = open(&mut s, 2, b"Tally"); + let other = open(&mut s, 3, b"Snake"); + let pink = 0xe91e63; + assert_eq!(s.handle_call(app, [w::ACCENT, pink, 0, 0], None).0[0], w::OK); + assert_eq!(s.handle_call(app, [w::ACCENT, 0x00ff00, 0, 0], None).0[0], MALFORMED, "once"); + // The app starts: too late for it to choose one itself. + s.handle_call(other, [w::BUFFER, 0, 0, 0], None); + assert_eq!(s.handle_call(other, [w::ACCENT, pink, 0, 0], None).0[0], MALFORMED); + let accent = |s: &mut Srv, badge| { + let r = s.handle_call(badge, [w::THEME, 0, 0, 0], None).0; + // Colour 3, the top of the theme's second word. + theme::Rgba::rgb((r[3] >> 32) as u32 & 0xff_ffff) + }; + assert_eq!(accent(&mut s, other), theme::Theme::WHIMSY_DARK.accent); + assert_eq!(accent(&mut s, app), theme::with_accent(theme::Theme::WHIMSY_DARK, theme::Rgba::rgb(pink as u32)).accent); +} diff --git a/crates/servers/display-server/src/themes.rs b/crates/servers/display-server/src/themes.rs index 9044ee8..74e47f6 100644 --- a/crates/servers/display-server/src/themes.rs +++ b/crates/servers/display-server/src/themes.rs @@ -56,10 +56,14 @@ impl Themes { Some(changed) } - /// Part `part` of the theme in use, for a window: `changes | index << - /// 32`, and two of its words. - pub fn part(&self, part: usize) -> Option<[usize; 3]> { - let words = theme::wire::encode(self.active()); + /// Part `part` of the theme in use, for a window (with its app's + /// accent, if any): `changes | index << 32`, and two of its words. + pub fn part(&self, part: usize, accent: Option) -> Option<[usize; 3]> { + let t = match accent { + Some(rgb) => theme::with_accent(*self.active(), theme::Rgba::rgb(rgb)), + None => *self.active(), + }; + let words = theme::wire::encode(&t); let pair = words.get(part * 2..part * 2 + 2)?; Some([self.changes & 0xffff_ffff | self.active << 32, pair[0], pair[1]]) } diff --git a/crates/servers/display-server/src/windows.rs b/crates/servers/display-server/src/windows.rs index 7301ad5..4b95c01 100644 --- a/crates/servers/display-server/src/windows.rs +++ b/crates/servers/display-server/src/windows.rs @@ -50,6 +50,10 @@ struct Window { name_len: usize, notify: Option, queue: Queue, + /// Its app's accent, `0xrrggbb`. + accent: Option, + /// Its buffer was asked for: the app runs, so the accent is settled. + started: bool, } pub struct Windows { @@ -73,7 +77,7 @@ impl Windows { let mut n = [0u8; NAME_BYTES]; let name_len = name.len().min(NAME_BYTES); n[..name_len].copy_from_slice(&name[..name_len]); - self.slots[index] = Some(Window { badge, opener, name: n, name_len, notify: None, queue: Queue::new() }); + self.slots[index] = Some(Window { badge, opener, name: n, name_len, notify: None, queue: Queue::new(), accent: None, started: false }); self.front = Some(index); Some((index, badge)) } @@ -119,6 +123,27 @@ impl Windows { self.front } + /// Gives the window `badge` its app's accent: once, before it starts. + pub fn set_accent(&mut self, badge: usize, rgb: u32) -> bool { + let Some(w) = self.slot(badge).and_then(|i| self.slots[i].as_mut()) else { return false }; + let ok = !w.started && w.accent.is_none(); + if ok { + w.accent = Some(rgb); + } + ok + } + + /// The window `badge`'s app is running (its buffer was asked for). + pub fn start(&mut self, badge: usize) { + if let Some(w) = self.slot(badge).and_then(|i| self.slots[i].as_mut()) { + w.started = true; + } + } + + pub fn accent(&self, badge: usize) -> Option { + self.slots[self.slot(badge)?].as_ref()?.accent + } + /// Tells `notify` when input for the window `badge` arrives. pub fn subscribe(&mut self, badge: usize, notify: N) -> bool { let Some(w) = self.slot(badge).and_then(|i| self.slots[i].as_mut()) else { return false }; diff --git a/crates/servers/permission-manager/src/server.rs b/crates/servers/permission-manager/src/server.rs index 3991e45..86789cd 100644 --- a/crates/servers/permission-manager/src/server.rs +++ b/crates/servers/permission-manager/src/server.rs @@ -201,6 +201,10 @@ impl PermissionServer { true => self.display.and_then(|d| libkos::display::open(d, plan.app.name.as_str().as_bytes())).map(|o| o.window), false => None, }; + // Its brand colour too, which the display adapts to the theme. + if let (Some(w), Some(accent)) = (window, plan.app.accent) { + libkos::display::set_accent(w, accent.0); + } // Managing others through a copy of this endpoint that can do no // more (see `MANAGE_BADGE`). let manage = match plan.granted.contains(Capability::Permissions) { diff --git a/crates/theme/src/accent.rs b/crates/theme/src/accent.rs new file mode 100644 index 0000000..b973756 --- /dev/null +++ b/crates/theme/src/accent.rs @@ -0,0 +1,42 @@ +//! An app's own accent (its manifest's `[appearance] accent`), for its +//! primary buttons and active states. Its hue is kept; its lightness may +//! be moved toward the theme's far end until everything drawn with it +//! still passes contrast. One that cannot pass, is black or white, or +//! could be taken for the destructive colour, gives way to the theme's. + +use crate::contrast::{check, luminance, ratio}; +use crate::{Rgba, Theme}; + +/// How far toward white or black the accent may be moved, in tenths. +const MOST: u8 = 6; +/// Channels all this close to the destructive colour's read as it. +const LIKE_DESTRUCTIVE: u8 = 40; + +fn mix(a: Rgba, b: Rgba, tenths: u8) -> Rgba { + let m = |x: u8, y: u8| ((x as u16 * (10 - tenths) as u16 + y as u16 * tenths as u16) / 10) as u8; + Rgba(m(a.0, b.0), m(a.1, b.1), m(a.2, b.2), 255) +} + +fn near(a: Rgba, b: Rgba) -> bool { + a.0.abs_diff(b.0) < LIKE_DESTRUCTIVE && a.1.abs_diff(b.1) < LIKE_DESTRUCTIVE && a.2.abs_diff(b.2) < LIKE_DESTRUCTIVE +} + +/// `t`, with `accent` in place of its own if it can be made to pass. +pub fn with_accent(t: Theme, accent: Rgba) -> Theme { + let (black, white) = (Rgba::rgb(0), Rgba::rgb(0xffffff)); + if accent == black || accent == white || near(accent, t.destructive) { + return t; + } + let toward = if luminance(t.background) < 0.5 { white } else { black }; + for tenths in 0..=MOST { + let a = mix(accent, toward, tenths); + // Whichever of the theme's own colours reads best on it. + let on = [t.on_accent, t.text_primary, t.background] + .into_iter() + .fold(t.on_accent, |best, c| if ratio(c, a) > ratio(best, a) { c } else { best }); + if let Ok(ok) = check(Theme { accent: a, on_accent: on, ..t }) { + return ok; + } + } + t +} diff --git a/crates/theme/src/contrast.rs b/crates/theme/src/contrast.rs index 0d1226c..efa63dd 100644 --- a/crates/theme/src/contrast.rs +++ b/crates/theme/src/contrast.rs @@ -10,7 +10,7 @@ fn channel(c: u8) -> f32 { if c <= 0.040_45 { c / 12.92 } else { libm::powf((c + 0.055) / 1.055, 2.4) } } -fn luminance(c: Rgba) -> f32 { +pub(crate) fn luminance(c: Rgba) -> f32 { 0.2126 * channel(c.0) + 0.7152 * channel(c.1) + 0.0722 * channel(c.2) } diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 558c77a..f7b998d 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -8,6 +8,7 @@ #![no_std] #![forbid(unsafe_code)] +mod accent; mod contrast; mod parse; pub mod wire; @@ -15,6 +16,7 @@ pub mod wire; #[cfg(test)] mod tests; +pub use accent::with_accent; pub use contrast::{check, ratio}; pub use parse::{Error, parse}; diff --git a/crates/theme/src/tests.rs b/crates/theme/src/tests.rs index da3df15..a03decc 100644 --- a/crates/theme/src/tests.rs +++ b/crates/theme/src/tests.rs @@ -51,3 +51,23 @@ fn every_token_is_needed_and_checked() { assert!(matches!(parse(&DARK.replace("highlight = 18", "glow = 18")), Err(Error::Toml(T::Unknown { .. })))); assert!(matches!(parse(&std::format!("{DARK}\n[glass]\n")), Err(Error::Toml(T::Duplicate { .. })))); } + +#[test] +fn an_app_accent_keeps_its_hue_and_passes_in_either_theme() { + let pink = Rgba::rgb(0xe91e63); + for text in [DARK, LIGHT] { + let t = parse(text).unwrap(); + let a = with_accent(t, pink); + assert_ne!(a.accent, t.accent, "{}: the app's, not the theme's", std::str::from_utf8(t.name()).unwrap()); + assert!(a.accent.0 > a.accent.1 && a.accent.0 > a.accent.2 / 2, "still pink: {:?}", a.accent); + check(a).unwrap(); + } +} + +#[test] +fn some_accents_give_way_to_the_theme() { + let t = parse(DARK).unwrap(); + for c in [0x000000, 0xffffff, 0xee5555] { + assert_eq!(with_accent(t, Rgba::rgb(c)), t, "{c:06x}"); + } +} diff --git a/crates/types/src/display.rs b/crates/types/src/display.rs index 060e500..a20d669 100644 --- a/crates/types/src/display.rs +++ b/crates/types/src/display.rs @@ -108,6 +108,11 @@ pub mod window { /// the default) is now in use; `MALFORMED` if there is none by that /// name. pub const SET_THEME: usize = 11; + /// Through a window, before anyone has asked for its buffer (so by + /// its launcher, before the app runs) and once: word 1 the app's + /// accent, `0xrrggbb`. The theme the window is handed carries it where + /// it passes contrast (`theme::with_accent`). + pub const ACCENT: usize = 12; /// The event (in place of an input event's tag) every window gets when /// the theme changes: ask for it again. pub const THEME_CHANGED: usize = 16; diff --git a/docs/apps.md b/docs/apps.md index 3dcca44..6de14ed 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -72,7 +72,8 @@ required = ["display"] # cannot work without; denied = not installed optional = [] # works without; the user decides each [appearance] -accent = "#e91e63" # optional: checked for format here, contrast by K-UI +accent = "#e91e63" # optional: its primary buttons and active states; + # the display adapts its lightness to each theme ``` The subset: `[table]` headers, `key = "string"`, `key = ["a", "b"]` on one diff --git a/docs/ipc.md b/docs/ipc.md index 5c954fb..1cc6b48 100644 --- a/docs/ipc.md +++ b/docs/ipc.md @@ -197,7 +197,12 @@ one in use (`THEME`, two words a call, `theme::wire`). Only init picks it signed-in user's choice at sign-in, the default at sign-out, and both again (with `SET_USER`) when the display restarts. A user chooses through Settings: `types::spawn::SET_THEME` on init's session copy, which init -keeps sealed in `users//settings/theme`. +keeps sealed in `users//settings/theme`. An app's accent (its +manifest's) is given to its window by the permission manager before the +app runs (`ACCENT`, refused once anyone has asked for the buffer, so an +app cannot pick its own); the theme that window reads carries it, its +lightness moved until it passes contrast (`theme::with_accent`), or the +theme's own accent if it cannot, is black or white, or looks destructive. Input reaches apps through the display server, the input server's only client. It subscribes to the input server with a notification, drains it