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:
@@ -8,3 +8,6 @@ memory = "3 MiB"
|
|||||||
|
|
||||||
[capabilities]
|
[capabilities]
|
||||||
required = ["display"]
|
required = ["display"]
|
||||||
|
|
||||||
|
[appearance]
|
||||||
|
accent = "#e91e63"
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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, _) => {
|
||||||
Some(buffer) => {
|
// The app runs: its accent is settled.
|
||||||
let (width, height) = self.window_size();
|
self.windows.start(badge);
|
||||||
([w::OK, width.raw() as usize, height.raw() as usize, 0], Some(Give::from(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)))
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
42
crates/theme/src/accent.rs
Normal file
42
crates/theme/src/accent.rs
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user