feat(theme): themes as files (theme crate: toml subset + ints, contrast check, wire); whimsy dark + light; display reads themes/, serves THEME/THEME_NAME, init SET_THEME -> THEME_CHANGED to all windows, bar themed; k-ui follows; settings appearance; init keeps choice in sealed users/<id>/settings/, reapplies at sign-in + display restart

This commit is contained in:
2026-10-28 13:36:57 +01:00
parent 29becf297c
commit 6827e1455d
43 changed files with 1140 additions and 179 deletions

View File

@@ -9,6 +9,7 @@ server-framework = { workspace = true }
libkos = { workspace = true }
port-framebuffer = { workspace = true }
raster = { workspace = true }
theme = { workspace = true }
[dev-dependencies]
driver-framebuffer-mock = { workspace = true }

View File

@@ -3,6 +3,7 @@
pub mod host;
pub mod server;
pub mod themes;
pub mod windows;
#[cfg(test)]

View File

@@ -4,7 +4,8 @@
//! it, copied from that window's pixel buffer as its app presents changes.
//! Apps draw only into their own buffer; the bar is the display's, so no
//! app can pose as another. The kernel side (buffers, capabilities, the
//! input server) is behind `Host`.
//! input server) is behind `Host`. The bar wears the theme in use, which
//! the display also hands every window (see `themes`).
use libkos::syscall::Give;
use port_framebuffer::Framebuffer;
@@ -14,14 +15,11 @@ use types::bounded::CapSlot;
use types::display::window::{self as w, NAME_BYTES};
use types::display::{Color, Dimension, ScreenCoord};
use crate::themes::Themes;
use crate::windows::{MAX_WINDOWS, Windows};
/// The OS bar: one row of text.
pub const BAR_HEIGHT: u16 = font::HEIGHT;
const BAR_BG: Color = Color::rgb(24, 24, 40);
const BAR_FG: Color = Color::rgb(170, 170, 190);
const FRONT_BG: Color = Color::rgb(90, 90, 150);
const FRONT_FG: Color = Color::rgb(255, 255, 255);
/// Pixels copied per row at a time.
const RUN: usize = 256;
@@ -69,11 +67,16 @@ pub struct DisplayServer<F: Framebuffer, H: Host> {
caps: [Option<CapSlot>; MAX_WINDOWS],
user: [u8; NAME_BYTES],
user_len: usize,
themes: Themes,
}
fn colour(c: theme::Rgba) -> Color {
Color::rgb(c.0, c.1, c.2)
}
impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
pub fn new(fb: F, host: H) -> Self {
let mut s = Self { fb, host, windows: Windows::new(), caps: [None; MAX_WINDOWS], user: [0; NAME_BYTES], user_len: 0 };
pub fn new(fb: F, host: H, themes: Themes) -> Self {
let mut s = Self { fb, host, windows: Windows::new(), caps: [None; MAX_WINDOWS], user: [0; NAME_BYTES], user_len: 0, themes };
s.repaint();
s
}
@@ -158,7 +161,8 @@ impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
match self.windows.front() {
Some(index) => self.present(index, ScreenCoord::new(0), ScreenCoord::new(0), width, height),
None => {
self.fb.fill_rect(ScreenCoord::new(0), ScreenCoord::new(BAR_HEIGHT), width, height, Color::rgb(30, 30, 60));
let bg = colour(self.themes.active().background);
self.fb.fill_rect(ScreenCoord::new(0), ScreenCoord::new(BAR_HEIGHT), width, height, bg);
self.fb.flush();
}
}
@@ -167,8 +171,10 @@ impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
/// Each window's name, the front one highlighted; who is signed in at
/// the right.
fn draw_bar(&mut self) {
let t = self.themes.active();
let (bar_bg, bar_fg, front_bg, front_fg) = (colour(t.surface), colour(t.text_secondary), colour(t.accent), colour(t.on_accent));
let width = self.fb.width();
self.fb.fill_rect(ScreenCoord::new(0), ScreenCoord::new(0), width, Dimension::new(BAR_HEIGHT), BAR_BG);
self.fb.fill_rect(ScreenCoord::new(0), ScreenCoord::new(0), width, Dimension::new(BAR_HEIGHT), bar_bg);
let mut x = 0u16;
let mut label = [b' '; NAME_BYTES + 2];
let entries: [Option<([u8; NAME_BYTES], usize, bool)>; MAX_WINDOWS] = {
@@ -184,13 +190,26 @@ impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
label[1..=len].copy_from_slice(&name[..len]);
label[len + 1] = b' ';
let text = &label[..len + 2];
let (fg, bg) = if front { (FRONT_FG, FRONT_BG) } else { (BAR_FG, BAR_BG) };
let (fg, bg) = if front { (front_fg, front_bg) } else { (bar_fg, bar_bg) };
raster::draw_text(&mut self.fb, ScreenCoord::new(x), ScreenCoord::new(0), fg, bg, text);
x = x.saturating_add(text.len() as u16 * font::WIDTH + font::WIDTH);
}
let user = &self.user[..self.user_len];
let at = width.raw().saturating_sub((user.len() as u16 + 1) * font::WIDTH);
raster::draw_text(&mut self.fb, ScreenCoord::new(at), ScreenCoord::new(0), BAR_FG, BAR_BG, user);
raster::draw_text(&mut self.fb, ScreenCoord::new(at), ScreenCoord::new(0), bar_fg, bar_bg, user);
}
/// Every window is told to ask for the theme again; the bar wears it.
fn theme_changed(&mut self) {
let mut told = [None; MAX_WINDOWS];
for (slot, notify) in told.iter_mut().zip(self.windows.push_all([w::THEME_CHANGED, 0, 0, 0])) {
*slot = Some(notify);
}
for notify in told.into_iter().flatten() {
self.host.signal(notify);
}
self.draw_bar();
self.fb.flush();
}
/// A request through window `index`'s capability.
@@ -209,6 +228,17 @@ impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
([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]) {
Some([a, b, c]) => ([w::OK, a, b, c], None),
None => ([MALFORMED, 0, 0, 0], None),
},
(w::THEME_NAME, _) => match self.themes.name(words[1]) {
Some(name) => {
let [a, b, c] = w::pack_name(name);
([w::OK, a, b, c], None)
}
None => ([MALFORMED, 0, 0, 0], None),
},
(w::POLL, _) => (self.windows.poll(badge).unwrap_or([0; 4]), None),
(w::CLOSE, _) => {
self.close(index);
@@ -251,6 +281,18 @@ impl<F: Framebuffer, H: Host> Server for DisplayServer<F, H> {
self.repaint();
([w::OK, 0, 0, 0], None)
}
w::SET_THEME if badge == 0 => {
let (name, len) = w::unpack_name([words[1], words[2], words[3]]);
match self.themes.select(&name[..len]) {
Some(changed) => {
if changed {
self.theme_changed();
}
([w::OK, 0, 0, 0], None)
}
None => ([MALFORMED, 0, 0, 0], None),
}
}
w::FORGET if badge == 0 => {
let mut gone = [None; MAX_WINDOWS];
for (slot, index) in gone.iter_mut().zip(self.windows.opened_by(words[1])) {

View File

@@ -61,7 +61,7 @@ impl Host for FakeHost {
type Srv = DisplayServer<GridFramebuffer<W, H>, FakeHost>;
fn server() -> Srv {
DisplayServer::new(GridFramebuffer::new(), FakeHost::default())
DisplayServer::new(GridFramebuffer::new(), FakeHost::default(), crate::themes::Themes::new())
}
/// Opens a window through launcher `opener`: its badge.
@@ -187,3 +187,48 @@ fn a_present_outside_the_window_is_clipped() {
assert_eq!(s.framebuffer().pixel(31, H - 1), Some(RED));
assert_ne!(s.framebuffer().pixel(29, H - 1), Some(RED), "left of the rectangle is untouched");
}
#[test]
fn init_switches_the_theme_and_every_window_hears_of_it() {
let light = include_str!("../../../../../disk/themes/whimsy-light.toml");
let mut themes = crate::themes::Themes::new();
themes.add(light).unwrap();
let mut s = DisplayServer::new(GridFramebuffer::new(), FakeHost::default(), themes);
let t = open(&mut s, 1, b"Terminal");
let snake = open(&mut s, 2, b"Snake");
let (n1, n2) = (CapSlot::new(9).unwrap(), CapSlot::new(10).unwrap());
s.handle_call(t, [w::SUBSCRIBE, 0, 0, 0], Some(n1));
s.handle_call(snake, [w::SUBSCRIBE, 0, 0, 0], Some(n2));
let names: Vec<_> = (0..)
.map_while(|i| match s.handle_call(t, [w::THEME_NAME, i, 0, 0], None).0 {
[w::OK, a, b, c] => {
let (n, len) = w::unpack_name([a, b, c]);
Some(n[..len].to_vec())
}
_ => None,
})
.collect();
assert_eq!(names, [b"Whimsy Dark".to_vec(), b"Whimsy Light".to_vec()]);
let read = |s: &mut Srv| -> (usize, theme::Theme) {
let mut words = [0; w::THEME_WORDS];
let mut head = 0;
for part in 0..w::THEME_WORDS / 2 {
let r = s.handle_call(snake, [w::THEME, part, 0, 0], None).0;
assert_eq!(r[0], w::OK);
head = r[1];
words[part * 2..part * 2 + 2].copy_from_slice(&r[2..]);
}
(head >> 32, theme::wire::decode(words))
};
assert_eq!(read(&mut s).0, 0);
let n = w::pack_name(b"Whimsy Light");
assert_eq!(s.handle_call(5, [w::SET_THEME, n[0], n[1], n[2]], None).0[0], MALFORMED, "init's alone");
assert_eq!(s.handle_call(0, [w::SET_THEME, n[0], n[1], n[2]], None).0[0], w::OK);
let (index, t2) = read(&mut s);
assert_eq!((index, t2.background), (1, theme::parse(light).unwrap().background));
assert!(s.host().signalled.contains(&n1) && s.host().signalled.contains(&n2), "behind or in front");
assert_eq!(s.handle_call(t, [w::POLL, 0, 0, 0], None).0[0], w::THEME_CHANGED);
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);
}

View File

@@ -0,0 +1,72 @@
//! The themes the display has (read from `themes/` at start) and the one
//! in use, which every window asks for (`THEME`) and is told about when it
//! changes. Pure: the binary reads the files.
use theme::Theme;
pub const MAX_THEMES: usize = 4;
pub struct Themes {
list: [Option<Theme>; MAX_THEMES],
active: usize,
/// Switches so far: a client reading the theme in parts sees it move.
changes: usize,
}
impl Themes {
/// Only the built-in one, until files are added.
pub const fn new() -> Self {
let mut list = [None; MAX_THEMES];
list[0] = Some(Theme::WHIMSY_DARK);
Self { list, active: 0, changes: 0 }
}
/// Adds a theme file's theme, if it reads and passes contrast; one by
/// the name of one already here takes its place (the built-in one is
/// the file's copy).
pub fn add(&mut self, text: &str) -> Result<(), theme::Error> {
let t = theme::parse(text).and_then(theme::check)?;
let same = self.list.iter().position(|s| s.is_some_and(|s| s.name() == t.name()));
if let Some(slot) = same.or_else(|| self.list.iter().position(Option::is_none)) {
self.list[slot] = Some(t);
}
Ok(())
}
pub fn active(&self) -> &Theme {
self.list[self.active].as_ref().unwrap_or(&Theme::WHIMSY_DARK)
}
pub fn name(&self, index: usize) -> Option<&[u8]> {
self.list.get(index)?.as_ref().map(Theme::name)
}
/// Puts the theme `name` in use (none named: the first, the default);
/// whether it changed anything.
pub fn select(&mut self, name: &[u8]) -> Option<bool> {
let index = match name {
[] => 0,
_ => self.list.iter().position(|t| t.is_some_and(|t| t.name() == name))?,
};
let changed = index != self.active;
if changed {
self.active = index;
self.changes += 1;
}
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());
let pair = words.get(part * 2..part * 2 + 2)?;
Some([self.changes & 0xffff_ffff | self.active << 32, pair[0], pair[1]])
}
}
impl Default for Themes {
fn default() -> Self {
Self::new()
}
}

View File

@@ -133,6 +133,14 @@ impl<N: Copy> Windows<N> {
w.notify
}
/// Queues an event for every window; whom to tell.
pub fn push_all(&mut self, event: [usize; 4]) -> impl Iterator<Item = N> + '_ {
self.slots.iter_mut().flatten().filter_map(move |w| {
w.queue.push(event);
w.notify
})
}
/// The window `badge`'s next event.
pub fn poll(&mut self, badge: usize) -> Option<[usize; 4]> {
let i = self.slot(badge)?;