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]
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.
pub fn close(window: CapSlot) {
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.
fn window_call(&mut self, index: usize, badge: usize, words: [usize; 4], cap: Option<CapSlot>) -> ([usize; 4], Option<Give>) {
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<F: Framebuffer, H: Host> DisplayServer<F, H> {
}
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);

View File

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

View File

@@ -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<u32>) -> 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]])
}

View File

@@ -50,6 +50,10 @@ struct Window<N> {
name_len: usize,
notify: Option<N>,
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> {
@@ -73,7 +77,7 @@ impl<N: Copy> Windows<N> {
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<N: Copy> Windows<N> {
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.
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 };

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),
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) {

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

View File

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

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(&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
/// 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;