feat(theme): per-app accent: pm gives window manifest accent before app runs (display ACCENT, refused after BUFFER); theme::with_accent keeps hue, shifts lightness until contrast passes, else theme accent; tally pink

This commit is contained in:
2026-10-28 13:40:38 +01:00
parent 6827e1455d
commit 2b82f052ae
14 changed files with 158 additions and 16 deletions

View File

@@ -8,3 +8,6 @@ memory = "3 MiB"
[capabilities] [capabilities]
required = ["display"] required = ["display"]
[appearance]
accent = "#e91e63"

View File

@@ -31,6 +31,12 @@ pub fn open(display: CapSlot, name: &[u8]) -> Option<Opened> {
} }
} }
/// 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. /// Closes `window`: every copy of it, and its buffer, stop working.
pub fn close(window: CapSlot) { pub fn close(window: CapSlot) {
let _ = syscall::request(window, [w::CLOSE, 0, 0, 0]); let _ = syscall::request(window, [w::CLOSE, 0, 0, 0]);

View File

@@ -215,20 +215,24 @@ impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
/// A request through window `index`'s capability. /// A request through window `index`'s capability.
fn window_call(&mut self, index: usize, badge: usize, words: [usize; 4], cap: Option<CapSlot>) -> ([usize; 4], Option<Give>) { fn window_call(&mut self, index: usize, badge: usize, words: [usize; 4], cap: Option<CapSlot>) -> ([usize; 4], Option<Give>) {
match (words[0], cap) { match (words[0], cap) {
(w::BUFFER, _) => match self.host.buffer(index, self.pixels()) { (w::BUFFER, _) => {
// The app runs: its accent is settled.
self.windows.start(badge);
match self.host.buffer(index, self.pixels()) {
Some(buffer) => { Some(buffer) => {
let (width, height) = self.window_size(); let (width, height) = self.window_size();
([w::OK, width.raw() as usize, height.raw() as usize, 0], Some(Give::from(buffer))) ([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, _) => { (w::PRESENT, _) => {
let (x, y, width, height) = w::unpack_rect([words[1], words[2]]); let (x, y, width, height) = w::unpack_rect([words[1], words[2]]);
self.present(index, x, y, width, height); self.present(index, x, y, width, height);
([w::OK, 0, 0, 0], None) ([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], self.windows.accent(badge)) {
(w::THEME, _) => match self.themes.part(words[1]) {
Some([a, b, c]) => ([w::OK, a, b, c], None), Some([a, b, c]) => ([w::OK, a, b, c], None),
None => ([MALFORMED, 0, 0, 0], None), None => ([MALFORMED, 0, 0, 0], None),
}, },
@@ -239,6 +243,7 @@ impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
} }
None => ([MALFORMED, 0, 0, 0], None), 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::POLL, _) => (self.windows.poll(badge).unwrap_or([0; 4]), None),
(w::CLOSE, _) => { (w::CLOSE, _) => {
self.close(index); self.close(index);

View File

@@ -232,3 +232,23 @@ fn init_switches_the_theme_and_every_window_hears_of_it() {
let bogus = w::pack_name(b"Nope"); 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); 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);
}

View File

@@ -56,10 +56,14 @@ impl Themes {
Some(changed) Some(changed)
} }
/// Part `part` of the theme in use, for a window: `changes | index << /// Part `part` of the theme in use, for a window (with its app's
/// 32`, and two of its words. /// accent, if any): `changes | index << 32`, and two of its words.
pub fn part(&self, part: usize) -> Option<[usize; 3]> { pub fn part(&self, part: usize, accent: Option<u32>) -> Option<[usize; 3]> {
let words = theme::wire::encode(self.active()); 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)?; let pair = words.get(part * 2..part * 2 + 2)?;
Some([self.changes & 0xffff_ffff | self.active << 32, pair[0], pair[1]]) Some([self.changes & 0xffff_ffff | self.active << 32, pair[0], pair[1]])
} }

View File

@@ -50,6 +50,10 @@ struct Window<N> {
name_len: usize, name_len: usize,
notify: Option<N>, notify: Option<N>,
queue: Queue, queue: Queue,
/// Its app's accent, `0xrrggbb`.
accent: Option<u32>,
/// Its buffer was asked for: the app runs, so the accent is settled.
started: bool,
} }
pub struct Windows<N: Copy> { pub struct Windows<N: Copy> {
@@ -73,7 +77,7 @@ impl<N: Copy> Windows<N> {
let mut n = [0u8; NAME_BYTES]; let mut n = [0u8; NAME_BYTES];
let name_len = name.len().min(NAME_BYTES); let name_len = name.len().min(NAME_BYTES);
n[..name_len].copy_from_slice(&name[..name_len]); 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); self.front = Some(index);
Some((index, badge)) Some((index, badge))
} }
@@ -119,6 +123,27 @@ impl<N: Copy> Windows<N> {
self.front 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<u32> {
self.slots[self.slot(badge)?].as_ref()?.accent
}
/// Tells `notify` when input for the window `badge` arrives. /// Tells `notify` when input for the window `badge` arrives.
pub fn subscribe(&mut self, badge: usize, notify: N) -> bool { 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 }; let Some(w) = self.slot(badge).and_then(|i| self.slots[i].as_mut()) else { return false };

View File

@@ -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), true => self.display.and_then(|d| libkos::display::open(d, plan.app.name.as_str().as_bytes())).map(|o| o.window),
false => None, 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 // Managing others through a copy of this endpoint that can do no
// more (see `MANAGE_BADGE`). // more (see `MANAGE_BADGE`).
let manage = match plan.granted.contains(Capability::Permissions) { let manage = match plan.granted.contains(Capability::Permissions) {

View File

@@ -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
}

View File

@@ -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) } 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) 0.2126 * channel(c.0) + 0.7152 * channel(c.1) + 0.0722 * channel(c.2)
} }

View File

@@ -8,6 +8,7 @@
#![no_std] #![no_std]
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
mod accent;
mod contrast; mod contrast;
mod parse; mod parse;
pub mod wire; pub mod wire;
@@ -15,6 +16,7 @@ pub mod wire;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
pub use accent::with_accent;
pub use contrast::{check, ratio}; pub use contrast::{check, ratio};
pub use parse::{Error, parse}; pub use parse::{Error, parse};

View File

@@ -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(&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 { .. })))); 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}");
}
}

View File

@@ -108,6 +108,11 @@ pub mod window {
/// the default) is now in use; `MALFORMED` if there is none by that /// the default) is now in use; `MALFORMED` if there is none by that
/// name. /// name.
pub const SET_THEME: usize = 11; 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 event (in place of an input event's tag) every window gets when
/// the theme changes: ask for it again. /// the theme changes: ask for it again.
pub const THEME_CHANGED: usize = 16; pub const THEME_CHANGED: usize = 16;

View File

@@ -72,7 +72,8 @@ required = ["display"] # cannot work without; denied = not installed
optional = [] # works without; the user decides each optional = [] # works without; the user decides each
[appearance] [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 The subset: `[table]` headers, `key = "string"`, `key = ["a", "b"]` on one

View File

@@ -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 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 again (with `SET_USER`) when the display restarts. A user chooses through
Settings: `types::spawn::SET_THEME` on init's session copy, which init Settings: `types::spawn::SET_THEME` on init's session copy, which init
keeps sealed in `users/<id>/settings/theme`. keeps sealed in `users/<id>/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 Input reaches apps through the display server, the input server's only
client. It subscribes to the input server with a notification, drains it client. It subscribes to the input server with a notification, drains it