From 6827e1455d73875aaeedb8106582c4dd820c309b Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 28 Oct 2026 13:36:57 +0100 Subject: [PATCH] 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//settings/, reapplies at sign-in + display restart --- Cargo.lock | 11 + Cargo.toml | 2 + crates/apps/settings/src/main.rs | 44 +++- crates/bins/display-qemu/src/main.rs | 48 ++++- crates/init/src/appearance.rs | 90 ++++++++ crates/init/src/factory.rs | 8 +- crates/init/src/lib.rs | 1 + crates/init/src/manifest.rs | 4 +- crates/init/src/session.rs | 16 +- crates/init/src/spawn.rs | 1 + crates/init/src/supervise.rs | 4 + crates/k-ui/Cargo.toml | 1 + crates/k-ui/src/run.rs | 24 ++- crates/k-ui/src/tests.rs | 8 +- crates/k-ui/src/theme.rs | 132 +----------- crates/k-ui/src/ui.rs | 17 +- crates/libkos/src/display.rs | 61 +++++- crates/libkos/src/session.rs | 7 + crates/manifest/src/lib.rs | 2 +- crates/manifest/src/manifest.rs | 2 +- crates/manifest/src/tests.rs | 2 + crates/manifest/src/toml.rs | 9 +- crates/servers/display-server/Cargo.toml | 1 + crates/servers/display-server/src/lib.rs | 1 + crates/servers/display-server/src/server.rs | 64 +++++- .../display-server/src/tests/server.rs | 47 +++- crates/servers/display-server/src/themes.rs | 72 +++++++ crates/servers/display-server/src/windows.rs | 8 + .../servers/storage-server/src/encrypted.rs | 5 +- .../storage-server/src/tests/encrypted.rs | 1 + crates/theme/Cargo.toml | 9 + crates/theme/src/contrast.rs | 50 +++++ crates/theme/src/lib.rs | 202 ++++++++++++++++++ crates/theme/src/parse.rs | 126 +++++++++++ crates/theme/src/tests.rs | 53 +++++ crates/theme/src/wire.rs | 73 +++++++ crates/types/src/display.rs | 18 ++ crates/types/src/spawn.rs | 4 + disk/themes/whimsy-dark.toml | 37 ++++ disk/themes/whimsy-light.toml | 34 +++ docs/apps.md | 4 +- docs/ipc.md | 13 ++ scripts/make-disk-image.sh | 3 +- 43 files changed, 1140 insertions(+), 179 deletions(-) create mode 100644 crates/init/src/appearance.rs create mode 100644 crates/servers/display-server/src/themes.rs create mode 100644 crates/theme/Cargo.toml create mode 100644 crates/theme/src/contrast.rs create mode 100644 crates/theme/src/lib.rs create mode 100644 crates/theme/src/parse.rs create mode 100644 crates/theme/src/tests.rs create mode 100644 crates/theme/src/wire.rs create mode 100644 disk/themes/whimsy-dark.toml create mode 100644 disk/themes/whimsy-light.toml diff --git a/Cargo.lock b/Cargo.lock index 5498647..1122195 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -242,6 +242,7 @@ dependencies = [ "port-framebuffer", "raster", "server-framework", + "theme", "types", ] @@ -467,6 +468,7 @@ dependencies = [ "libkos", "libm", "port-framebuffer", + "theme", "tiny-skia", "types", ] @@ -757,6 +759,15 @@ dependencies = [ "types", ] +[[package]] +name = "theme" +version = "0.1.0" +dependencies = [ + "libm", + "manifest", + "types", +] + [[package]] name = "tiny-skia" version = "0.11.4" diff --git a/Cargo.toml b/Cargo.toml index 62982fd..613f200 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/apps/flaky", "crates/apps/tally", "crates/apps/settings", + "crates/theme", "crates/apps/hello", "crates/apps/shell", "crates/apps/cat", @@ -81,6 +82,7 @@ types = { path = "crates/types" } domain = { path = "crates/domain" } elf = { path = "crates/elf" } manifest = { path = "crates/manifest" } +theme = { path = "crates/theme" } keystore = { path = "crates/keystore" } filecrypt = { path = "crates/filecrypt" } # Audited RustCrypto implementations; never our own. diff --git a/crates/apps/settings/src/main.rs b/crates/apps/settings/src/main.rs index b77e3f3..86169c2 100644 --- a/crates/apps/settings/src/main.rs +++ b/crates/apps/settings/src/main.rs @@ -1,5 +1,6 @@ //! Settings: what each app may use (allow or revoke, through the -//! permission manager), who is signed in, and signing out. A system app: +//! permission manager), the theme (the display's list, kept per user by +//! init), who is signed in, and signing out. A system app: //! `permissions` and `session` are only ever granted to apps on the system //! image. @@ -29,6 +30,7 @@ enum Screen { Apps, /// One app, by its place in the list. App(usize), + Appearance, About, } @@ -37,6 +39,8 @@ enum Event { Show(Screen), /// Allow or revoke this capability of the app on screen. Flip(Capability), + /// Use the display's `n`th theme. + Theme(usize), SignOut, } @@ -54,6 +58,9 @@ struct Settings { rows: Vec, /// Who is signed in, and how. who: Option<(String, Role)>, + /// The display's themes, and which is in use. + themes: Vec, + theme: usize, /// What went wrong last, shown until the next change. trouble: Option, } @@ -99,6 +106,23 @@ impl Settings { self.reload(); } + fn appearance(&self) -> Element { + let rows = self.themes.iter().enumerate().map(|(i, name)| { + let item = list_item(name.clone(), Event::Theme(i)); + if i == self.theme { item.subtitle("In use") } else { item } + }); + let view = Self::hint(Self::heading("Appearance"), "Esc goes back").children(rows); + self.with_trouble(view).into() + } + + fn choose_theme(&mut self, index: usize) { + let (Some(name), Some(session)) = (self.themes.get(index), libkos::start::session()) else { return }; + match libkos::session::set_theme(session, name.as_bytes()) { + true => self.theme = index, + false => self.trouble = Some("That theme could not be kept.".to_string()), + } + } + fn heading(title: &str) -> k_ui::element::FlexBuilder { column().padding(Spacing::Xl).gap(Spacing::Md).child(text(title).style(TextStyle::Heading)) } @@ -110,6 +134,7 @@ impl Settings { fn home(&self) -> Element { Self::heading("Settings") .child(list_item("Apps", Event::Show(Screen::Apps)).subtitle("What each app may use")) + .child(list_item("Appearance", Event::Show(Screen::Appearance)).subtitle("The theme everything is drawn in")) .child(list_item("About", Event::Show(Screen::About)).subtitle("Who is signed in, and this device")) .child(button("Sign out", Event::SignOut).variant(ButtonVariant::Destructive)) .into() @@ -176,7 +201,15 @@ impl App for Settings { let who = libkos::start::session() .and_then(|s| libkos::session::who(s, &mut name)) .map(|(len, role)| (String::from_utf8_lossy(&name[..len]).into_owned(), role)); - let mut s = Settings { screen: Screen::Home, apps, rows: Vec::new(), who, trouble: None }; + let window = libkos::start::display(); + let themes = window.map_or(Vec::new(), |w| { + (0..) + .map_while(|i| libkos::display::theme_name(w, i)) + .map(|(n, len)| String::from_utf8_lossy(&n[..len]).into_owned()) + .collect() + }); + let theme = window.and_then(libkos::display::theme).map_or(0, |(i, _)| i); + let mut s = Settings { screen: Screen::Home, apps, rows: Vec::new(), who, themes, theme, trouble: None }; s.reload(); s } @@ -195,6 +228,7 @@ impl App for Settings { self.flip(i, cap); } } + Event::Theme(i) => self.choose_theme(i), Event::SignOut => { if let Some(s) = libkos::start::session() { libkos::session::sign_out(s); @@ -208,6 +242,7 @@ impl App for Settings { Screen::Home => self.home(), Screen::Apps => self.app_list(), Screen::App(i) => self.app(i), + Screen::Appearance => self.appearance(), Screen::About => self.about(), } } @@ -217,7 +252,7 @@ impl App for Settings { self.screen = match self.screen { Screen::Home => return false, Screen::App(_) => Screen::Apps, - Screen::Apps | Screen::About => Screen::Home, + Screen::Apps | Screen::Appearance | Screen::About => Screen::Home, }; true } @@ -227,7 +262,8 @@ impl App for Settings { Screen::Home => 0, Screen::Apps => 1, Screen::About => 2, - Screen::App(i) => 3 + i, + Screen::Appearance => 3, + Screen::App(i) => 4 + i, } } } diff --git a/crates/bins/display-qemu/src/main.rs b/crates/bins/display-qemu/src/main.rs index 4028281..1683dba 100644 --- a/crates/bins/display-qemu/src/main.rs +++ b/crates/bins/display-qemu/src/main.rs @@ -9,6 +9,12 @@ use types::bounded::CapSlot; /// Where init put them (see init's manifest: own endpoint, then `uses`). const EP: CapSlot = CapSlot::const_new(0); const INPUT: CapSlot = CapSlot::const_new(1); +/// `themes/`, read-only. +const THEMES: CapSlot = CapSlot::const_new(2); +/// Where storage's buffer is mapped while the themes are read. +const STORAGE_BUF_VA: types::address::VirtualAddress = types::address::VirtualAddress::from_raw(0x0180_0000); +/// The longest theme file read. +const THEME_BYTES: usize = 4096; libkos::entry!(main); @@ -38,7 +44,47 @@ fn subscribe_to_input() -> Option { } fn server(fb: F, input: Option) -> display_server::server::DisplayServer { - display_server::server::DisplayServer::new(fb, display_server::host::KernelHost::new(EP, input)) + display_server::server::DisplayServer::new(fb, display_server::host::KernelHost::new(EP, input), themes()) +} + +/// Every theme in `themes/` that reads and passes contrast; the built-in +/// one if none does. +fn themes() -> display_server::themes::Themes { + let mut themes = display_server::themes::Themes::new(); + let Ok(mut fs) = libkos::fs::Storage::attach(THEMES, STORAGE_BUF_VA) else { + libkos::println!("display: no themes folder; the built-in theme only"); + return themes; + }; + let mut names = [([0u8; types::storage::DIR_NAME_SIZE], 0usize); display_server::themes::MAX_THEMES]; + let mut found = 0; + let _ = fs.list_dir(b"", |e| { + if !e.is_dir && e.name().ends_with(b".toml") && found < names.len() { + names[found] = (e.name, e.name_len); + found += 1; + } + }); + let mut text = [0u8; THEME_BYTES]; + for (name, len) in &names[..found] { + let name = &name[..*len]; + let read = fs.open(name).and_then(|h| { + let n = fs.read(h, &mut text); + let _ = fs.close(h); + n + }); + let shown = core::str::from_utf8(name).unwrap_or("?"); + match read.ok().and_then(|n| core::str::from_utf8(&text[..n]).ok()) { + Some(t) => { + if let Err(e) = themes.add(t) { + libkos::println!("display: theme {} refused: {:?}", shown, e); + } + } + None => libkos::println!("display: cannot read theme {}", shown), + } + } + fs.detach(); + // Storage has few connections to give; this one is done with. + let _ = libkos::fs::release(THEMES); + themes } #[inline(never)] diff --git a/crates/init/src/appearance.rs b/crates/init/src/appearance.rs new file mode 100644 index 0000000..18a50a5 --- /dev/null +++ b/crates/init/src/appearance.rs @@ -0,0 +1,90 @@ +//! Which theme each user chose. Settings asks through init's session copy +//! (`types::spawn::SET_THEME`); init keeps the name in the user's folder +//! (`users//settings/theme`, sealed) and tells the display, at +//! sign-in, at sign-out (the default), and when the display restarts. + +use libkos::fs::Storage; +use types::display::window::NAME_BYTES; +use types::user::UserFolder; + +use crate::launch::Launcher; + +/// A theme's name; empty is the display's default. +#[derive(Clone, Copy)] +pub(crate) struct Choice { + name: [u8; NAME_BYTES], + len: usize, +} + +impl Choice { + pub(crate) const DEFAULT: Self = Self { name: [0; NAME_BYTES], len: 0 }; + + fn of(name: &[u8]) -> Option { + // What a theme file's name may be (`theme::parse`). + let fits = name.len() <= NAME_BYTES && name.iter().all(|b| (b' '..=b'~').contains(b) && *b != b'"' && *b != b'\\'); + fits.then(|| { + let mut c = Self::DEFAULT; + c.name[..name.len()].copy_from_slice(name); + c.len = name.len(); + c + }) + } +} + +fn path(folder: UserFolder, out: &mut [u8; 64]) -> Option<&[u8]> { + let parts: [&[u8]; 3] = [b"users/", folder.as_bytes(), b"/settings/theme"]; + let mut n = 0; + for p in parts { + out.get_mut(n..n + p.len())?.copy_from_slice(p); + n += p.len(); + } + Some(&out[..n]) +} + +/// What `folder`'s user chose, or the default. +pub(crate) fn load(fs: &mut Storage, folder: UserFolder) -> Choice { + let mut buf = [0u8; 64]; + let Some(path) = path(folder, &mut buf) else { return Choice::DEFAULT }; + let Ok(h) = fs.open(path) else { return Choice::DEFAULT }; + let mut name = [0u8; NAME_BYTES + 1]; + let read = fs.read(h, &mut name); + let _ = fs.close(h); + read.ok().and_then(|n| Choice::of(&name[..n])).unwrap_or(Choice::DEFAULT) +} + +fn save(fs: &mut Storage, folder: UserFolder, c: &Choice) -> bool { + let mut buf = [0u8; 64]; + let Some(path) = path(folder, &mut buf) else { return false }; + // Gone or not, it is written anew. + let _ = fs.delete(path); + let Ok(h) = fs.create(path) else { return false }; + let written = fs.write(h, &c.name[..c.len]) == Ok(c.len); + fs.close(h).is_ok() && written +} + +/// Settings chose the theme named in `words`: kept, and shown. +pub(crate) fn choose(l: &mut Launcher, words: [usize; 3]) -> bool { + let (name, len) = types::name::unpack(words); + let (Some(choice), Some(user)) = (Choice::of(&name[..len]), l.user) else { return false }; + let (fs, _) = l.storage(); + if !save(fs, user.folder, &choice) { + return false; + } + if let Some(u) = l.user.as_mut() { + u.theme = choice; + } + tell_display(l); + true +} + +/// The display hears who is in and their theme (nobody, the default). +pub(crate) fn tell_display(l: &Launcher) { + let Some(display) = l.endpoint("display") else { return }; + let (name, theme) = match &l.user { + Some(u) => (u.name(), u.theme), + None => (&[][..], Choice::DEFAULT), + }; + crate::output::post(display, libkos::display::set_user_words(name)); + let [a, b, c] = types::name::pack(&theme.name[..theme.len]); + crate::output::post(display, [types::display::window::SET_THEME, a, b, c]); +} diff --git a/crates/init/src/factory.rs b/crates/init/src/factory.rs index 1036b84..06ee0c8 100644 --- a/crates/init/src/factory.rs +++ b/crates/init/src/factory.rs @@ -8,7 +8,13 @@ use libkos::fs::Storage; use types::storage::MAX_PATH; /// Each area of a user's folder, and what it starts with. -const AREAS: [(&str, &str); 4] = [("home", "factory/home"), ("permissions", "factory/permissions"), ("data", "factory/data"), ("apps", "factory/apps")]; +const AREAS: [(&str, &str); 5] = [ + ("home", "factory/home"), + ("permissions", "factory/permissions"), + ("settings", "factory/settings"), + ("data", "factory/data"), + ("apps", "factory/apps"), +]; /// Entries handled per listing; a folder with more is listed again. const BATCH: usize = 32; const NAME: usize = types::storage::DIR_NAME_SIZE; diff --git a/crates/init/src/lib.rs b/crates/init/src/lib.rs index 6335d4b..e470cac 100644 --- a/crates/init/src/lib.rs +++ b/crates/init/src/lib.rs @@ -3,6 +3,7 @@ extern crate alloc; +mod appearance; mod devices; mod factory; mod launch; diff --git a/crates/init/src/manifest.rs b/crates/init/src/manifest.rs index 3172a7b..ab20465 100644 --- a/crates/init/src/manifest.rs +++ b/crates/init/src/manifest.rs @@ -111,13 +111,15 @@ pub const SERVERS: &[Program] = &[ Program { devices: &[Device::Gpu], uses: &["input"], + // The themes, read once at start. + storage: &[disk(b"themes", false)], // Its clients subscribe and take focus. restarts_clients: true, // Its windows' state (input queues among it) is KiBs. stack_pages: 8, // The framebuffer is ~1000 pages at 1280x800, and so is each of // the (at most 4) windows' pixel buffers, which it owns. - ..server("display", b"bin/display", Limits { pages: 5200, regions: 8, ..SMALL }) + ..server("display", b"bin/display", Limits { pages: 5200, regions: 9, ..SMALL }) }, // Text on the display, lines from the keyboard (it takes input focus). // Its clients wait on lines and Ctrl+C. It draws its own text now, diff --git a/crates/init/src/session.rs b/crates/init/src/session.rs index bb8b4ee..880c0ae 100644 --- a/crates/init/src/session.rs +++ b/crates/init/src/session.rs @@ -26,9 +26,14 @@ pub(crate) struct User { name_len: usize, /// As `types::spawn::role` says. role: usize, + pub(crate) theme: crate::appearance::Choice, } impl User { + pub(crate) fn name(&self) -> &[u8] { + &self.name[..self.name_len] + } + /// The reply to `types::spawn::WHO`. pub(crate) fn who(&self) -> [usize; 4] { let [a, b, c] = types::name::pack(&self.name[..self.name_len]); @@ -121,15 +126,14 @@ pub(crate) fn sign_in(l: &mut Launcher) -> Result<(), &'static str> { crate::factory::prepare(fs, folder, scratch)?; let name = core::str::from_utf8(&h.name[..h.name_len]).unwrap_or("someone"); crate::outln!("init: {} is in", name); - if let Some(display) = l.endpoint("display") { - crate::output::post(display, libkos::display::set_user_words(name.as_bytes())); - } + let theme = crate::appearance::load(fs, folder); let role = match (guest, h.role) { (true, _) => types::spawn::role::GUEST, (_, unlocked::OWNER) => types::spawn::role::OWNER, _ => types::spawn::role::STANDARD, }; - l.user = Some(User { folder, guest, name: h.name, name_len: h.name_len, role }); + l.user = Some(User { folder, guest, name: h.name, name_len: h.name_len, role, theme }); + crate::appearance::tell_display(l); start_session(l); Ok(()) } @@ -150,10 +154,8 @@ fn start_session(l: &mut Launcher) { pub(crate) fn sign_out(l: &mut Launcher) { l.spawner.clear(); l.stop_session(); - if let Some(display) = l.endpoint("display") { - crate::output::post(display, libkos::display::set_user_words(b"")); - } let user = l.user.take(); + crate::appearance::tell_display(l); let (fs, _) = l.storage(); let locked = fs.lock().is_ok(); // A guest leaves nothing behind. diff --git a/crates/init/src/spawn.rs b/crates/init/src/spawn.rs index 88d94a1..436638e 100644 --- a/crates/init/src/spawn.rs +++ b/crates/init/src/spawn.rs @@ -188,6 +188,7 @@ impl Server for Launcher<'_> { ([MALFORMED, 0, 0, 0], None) } types::spawn::WHO => (self.user.map_or([MALFORMED, 0, 0, 0], |u| u.who()), None), + types::spawn::SET_THEME if crate::appearance::choose(self, [words[1], words[2], words[3]]) => ([reply::OK, 0, 0, 0], None), _ => ([MALFORMED, 0, 0, 0], None), }; } diff --git a/crates/init/src/supervise.rs b/crates/init/src/supervise.rs index 5cd371f..ad8a607 100644 --- a/crates/init/src/supervise.rs +++ b/crates/init/src/supervise.rs @@ -324,6 +324,10 @@ impl Launcher<'_> { ..s }); self.ping(index); + if s.program.name == "display" { + // What it showed went with it. + crate::appearance::tell_display(self); + } if s.program.restarts_clients { self.restart_users_of(s.program.name); } diff --git a/crates/k-ui/Cargo.toml b/crates/k-ui/Cargo.toml index a56e27f..f0fd1f4 100644 --- a/crates/k-ui/Cargo.toml +++ b/crates/k-ui/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] types = { workspace = true } +theme = { workspace = true } libkos = { workspace = true } port-framebuffer = { workspace = true } tiny-skia = { version = "0.11", default-features = false, features = ["no-std-float"] } diff --git a/crates/k-ui/src/run.rs b/crates/k-ui/src/run.rs index 3ce4124..82a26c7 100644 --- a/crates/k-ui/src/run.rs +++ b/crates/k-ui/src/run.rs @@ -1,9 +1,10 @@ //! An app's event loop on K-OS: its window (which the permission manager //! opened, named from its manifest), keys from the display while it is in //! front, a redraw after every change. Escape goes back, and leaves from -//! the first screen. +//! the first screen. It draws in the theme the display has in use, and +//! again in the new one when that changes. -use libkos::display::{self, Canvas}; +use libkos::display::{self, Canvas, Event}; use libkos::input::{InputEvent, Key as Pressed}; use libkos::syscall; use port_framebuffer::Framebuffer; @@ -39,7 +40,7 @@ pub fn run() -> ! { libkos::println!("k-ui: no input"); syscall::exit(); } - let mut ui = Ui::::new(&Theme::WHIMSY_DARK); + let mut ui = Ui::::new(theme(canvas.window())); let mut dirty = true; loop { if dirty { @@ -47,8 +48,16 @@ pub fn run() -> ! { dirty = false; } let _ = syscall::wait(notify); - while let Some(event) = display::poll_input(canvas.window()) { - let InputEvent::KeyDown(k) = event else { continue }; + while let Some(event) = display::poll(canvas.window()) { + let k = match event { + Event::ThemeChanged => { + ui.set_theme(theme(canvas.window())); + dirty = true; + continue; + } + Event::Input(InputEvent::KeyDown(k)) => k, + Event::Input(_) => continue, + }; match key(k).map(|k| ui.key(k)) { Some(Outcome::Leave) => syscall::exit(), Some(Outcome::Changed) => dirty = true, @@ -58,6 +67,11 @@ pub fn run() -> ! { } } +/// The display's theme, or the built-in one if it will not say. +fn theme(window: types::bounded::CapSlot) -> Theme { + display::theme(window).map_or(Theme::WHIMSY_DARK, |(_, words)| ::theme::wire::decode(words)) +} + fn draw(ui: &mut Ui, canvas: &mut Canvas, fonts: &Fonts) { let (w, h) = (canvas.width(), canvas.height()); if let Some(mut pm) = PixmapMut::from_bytes(canvas.pixels_mut(), w.raw() as u32, h.raw() as u32) { diff --git a/crates/k-ui/src/tests.rs b/crates/k-ui/src/tests.rs index 162243a..3393117 100644 --- a/crates/k-ui/src/tests.rs +++ b/crates/k-ui/src/tests.rs @@ -80,7 +80,7 @@ impl App for Counter { #[test] fn keys_move_focus_and_press_the_focused_button() { - let mut ui = Ui::::new(T); + let mut ui = Ui::::new(Theme::WHIMSY_DARK); assert_eq!(ui.key(Key::Press), Outcome::Changed, "Add has focus first"); assert_eq!(ui.key(Key::Press), Outcome::Changed); assert_eq!(ui.app.0, 2); @@ -104,7 +104,7 @@ fn at(pm: &tiny_skia::Pixmap, x: u32, y: u32) -> (u8, u8, u8) { #[test] fn the_view_is_drawn_in_the_theme_with_a_focus_ring() { let fonts = Fonts::load().expect("the bundled fonts parse"); - let mut ui = Ui::::new(T); + let mut ui = Ui::::new(Theme::WHIMSY_DARK); let mut pm = tiny_skia::Pixmap::new(320, 200).unwrap(); ui.draw(&mut pm.as_mut(), &fonts); let bg = T.background; @@ -163,7 +163,7 @@ impl App for Pages { #[test] fn rows_open_screens_toggles_flip_and_escape_goes_back_then_leaves() { - let mut ui = Ui::::new(T); + let mut ui = Ui::::new(Theme::WHIMSY_DARK); assert_eq!(ui.key(Key::Next), Outcome::Changed); assert_eq!(ui.key(Key::Press), Outcome::Changed); assert_eq!(ui.app.open, Some(1), "the second row"); @@ -178,7 +178,7 @@ fn rows_open_screens_toggles_flip_and_escape_goes_back_then_leaves() { #[test] fn rows_stretch_across_and_the_view_scrolls_to_the_focused_one() { let fonts = Fonts::load().unwrap(); - let mut ui = Ui::::new(T); + let mut ui = Ui::::new(Theme::WHIMSY_DARK); let view = ui.app.view(); let rects = layout(&view, Rect { x: 0.0, y: 0.0, w: 320.0, h: 5000.0 }, &crate::text::Measurer { fonts: &fonts, theme: T }, T); assert_eq!(rects[1].w, 320.0, "a row fills its column"); diff --git a/crates/k-ui/src/theme.rs b/crates/k-ui/src/theme.rs index 364c83f..3db0858 100644 --- a/crates/k-ui/src/theme.rs +++ b/crates/k-ui/src/theme.rs @@ -1,130 +1,4 @@ -//! The theme's tokens (see the vision's K-UI note): apps say what a thing -//! is (a primary button, a heading), never its colour or size. One theme -//! for now, built in; loading themes from TOML comes later. +//! The theme's tokens and vocabulary live in the `theme` crate, shared +//! with the display that hands the theme in use to every window. -/// A colour as the theme names it: straight (not premultiplied) RGBA. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Rgba(pub u8, pub u8, pub u8, pub u8); - -impl Rgba { - pub const fn opaque(r: u8, g: u8, b: u8) -> Self { - Self(r, g, b, 255) - } -} - -/// Steps of the spacing scale. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Spacing { - None, - Xs, - Sm, - Md, - Lg, - Xl, -} - -/// Steps of the type scale. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TextStyle { - Heading, - Subheading, - Body, - Caption, - Label, -} - -/// What a text is, for its colour. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TextColour { - Primary, - Secondary, - Accent, - OnAccent, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ButtonVariant { - Primary, - Secondary, - Ghost, - Destructive, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Weight { - Regular, - SemiBold, -} - -pub struct Theme { - pub background: Rgba, - pub surface: Rgba, - pub surface_raised: Rgba, - pub accent: Rgba, - pub on_accent: Rgba, - pub text_primary: Rgba, - pub text_secondary: Rgba, - pub destructive: Rgba, - pub focus: Rgba, - pub corner_radius: f32, - /// The spacing scale's unit, in pixels. - pub spacing_base: f32, -} - -impl Theme { - pub const WHIMSY_DARK: Theme = Theme { - background: Rgba::opaque(0x0f, 0x0e, 0x17), - surface: Rgba::opaque(0x1a, 0x1a, 0x2e), - surface_raised: Rgba::opaque(0x2a, 0x2a, 0x44), - accent: Rgba::opaque(0x4f, 0xc3, 0xf7), - on_accent: Rgba::opaque(0x0f, 0x0e, 0x17), - text_primary: Rgba::opaque(0xe0, 0xe0, 0xe0), - text_secondary: Rgba::opaque(0x9e, 0x9e, 0xb8), - destructive: Rgba::opaque(0xef, 0x53, 0x50), - focus: Rgba::opaque(0xff, 0xff, 0xff), - corner_radius: 16.0, - spacing_base: 4.0, - }; - - pub fn space(&self, s: Spacing) -> f32 { - self.spacing_base - * match s { - Spacing::None => 0.0, - Spacing::Xs => 1.0, - Spacing::Sm => 2.0, - Spacing::Md => 4.0, - Spacing::Lg => 6.0, - Spacing::Xl => 8.0, - } - } - - /// A text style's size in pixels and its weight. - pub fn text(&self, s: TextStyle) -> (f32, Weight) { - match s { - TextStyle::Heading => (32.0, Weight::SemiBold), - TextStyle::Subheading => (22.0, Weight::SemiBold), - TextStyle::Body => (17.0, Weight::Regular), - TextStyle::Caption => (13.0, Weight::Regular), - TextStyle::Label => (17.0, Weight::SemiBold), - } - } - - pub fn text_colour(&self, c: TextColour) -> Rgba { - match c { - TextColour::Primary => self.text_primary, - TextColour::Secondary => self.text_secondary, - TextColour::Accent => self.accent, - TextColour::OnAccent => self.on_accent, - } - } - - /// A button's fill (none for a ghost) and its label's colour. - pub fn button(&self, v: ButtonVariant) -> (Option, Rgba) { - match v { - ButtonVariant::Primary => (Some(self.accent), self.on_accent), - ButtonVariant::Secondary => (Some(self.surface_raised), self.text_primary), - ButtonVariant::Ghost => (None, self.accent), - ButtonVariant::Destructive => (Some(self.destructive), self.text_primary), - } - } -} +pub use ::theme::{ButtonVariant, Rgba, Spacing, TextColour, TextStyle, Theme, Weight}; diff --git a/crates/k-ui/src/ui.rs b/crates/k-ui/src/ui.rs index d69a4f2..fc38c49 100644 --- a/crates/k-ui/src/ui.rs +++ b/crates/k-ui/src/ui.rs @@ -59,7 +59,7 @@ pub struct Ui { focus: usize, /// How far down the view is scrolled, in pixels. scroll: f32, - theme: &'static Theme, + theme: Theme, } /// The pre-order indices of every pressable element in `e`. @@ -87,10 +87,15 @@ fn event_at(e: &Element, index: &mut usize, target: usize) -> Optio } impl Ui { - pub fn new(theme: &'static Theme) -> Self { + pub fn new(theme: Theme) -> Self { Self { app: A::init(), focus: 0, scroll: 0.0, theme } } + /// A new theme: everything is drawn in it from the next frame. + pub fn set_theme(&mut self, theme: Theme) { + self.theme = theme; + } + fn pressables(&self, view: &Element) -> Vec { let mut out = Vec::new(); pressables(view, &mut 0, &mut out); @@ -143,10 +148,10 @@ impl Ui { pub fn draw(&mut self, pm: &mut PixmapMut, fonts: &Fonts) { let view = self.app.view(); let (w, h) = (pm.width() as f32, pm.height() as f32); - let measurer = Measurer { fonts, theme: self.theme }; + let measurer = Measurer { fonts, theme: &self.theme }; // As tall as it needs: what does not fit is scrolled to. - let tall = layout::natural(&view, &measurer, self.theme).1.max(h); - let mut rects = layout::layout(&view, Rect { x: 0.0, y: 0.0, w, h: tall }, &measurer, self.theme); + let tall = layout::natural(&view, &measurer, &self.theme).1.max(h); + let mut rects = layout::layout(&view, Rect { x: 0.0, y: 0.0, w, h: tall }, &measurer, &self.theme); let focused = self.pressables(&view).get(self.focus).copied(); if let Some(r) = focused.and_then(|i| rects.get(i)) { self.scroll = keep_in_sight(self.scroll, r.y, r.h, h, tall); @@ -154,7 +159,7 @@ impl Ui { for r in &mut rects { r.y -= self.scroll; } - render::render(pm, &view, &rects, fonts, self.theme, focused); + render::render(pm, &view, &rects, fonts, &self.theme, focused); } } diff --git a/crates/libkos/src/display.rs b/crates/libkos/src/display.rs index fd92e2e..b31df92 100644 --- a/crates/libkos/src/display.rs +++ b/crates/libkos/src/display.rs @@ -50,10 +50,67 @@ pub fn subscribe(window: CapSlot, notify: CapSlot, badge: types::badge::Badge) - sent.is_ok_and(|r| r.words()[0] == w::OK) } +/// What a window hears. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Event { + Input(crate::input::InputEvent), + /// Ask for the theme again (`theme`). + ThemeChanged, +} + +/// `window`'s next event, if one is waiting; ones not known here are +/// skipped. +pub fn poll(window: CapSlot) -> Option { + loop { + let words = syscall::request(window, [w::POLL, 0, 0, 0]).ok()?; + match words[0] { + 0 => return None, + w::THEME_CHANGED => return Some(Event::ThemeChanged), + _ => { + if let Some(e) = crate::input::decode(words) { + return Some(Event::Input(e)); + } + } + } + } +} + /// `window`'s next input event, if one is waiting. pub fn poll_input(window: CapSlot) -> Option { - let words = syscall::request(window, [w::POLL, 0, 0, 0]).ok()?; - crate::input::decode(words) + loop { + if let Event::Input(e) = poll(window)? { + return Some(e); + } + } +} + +/// The theme in use, as `theme::wire` words it, and its place in the +/// display's list (`theme_name`). +pub fn theme(window: CapSlot) -> Option<(usize, [usize; w::THEME_WORDS])> { + // A switch mid-read is seen and read again; a few in a row, given up. + for _ in 0..3 { + let mut words = [0; w::THEME_WORDS]; + let mut first = None; + let mut torn = false; + for part in 0..w::THEME_WORDS / 2 { + let r = syscall::request(window, [w::THEME, part, 0, 0]).ok()?; + if r[0] != w::OK { + return None; + } + torn |= *first.get_or_insert(r[1]) != r[1]; + words[part * 2..part * 2 + 2].copy_from_slice(&r[2..]); + } + if !torn { + return first.map(|head| (head >> 32, words)); + } + } + None +} + +/// The name of the display's `index`th theme, if it has that many. +pub fn theme_name(window: CapSlot, index: usize) -> Option<([u8; w::NAME_BYTES], usize)> { + let r = syscall::request(window, [w::THEME_NAME, index, 0, 0]).ok()?; + (r[0] == w::OK).then(|| w::unpack_name([r[1], r[2], r[3]])) } /// A changed area, as corners. diff --git a/crates/libkos/src/session.rs b/crates/libkos/src/session.rs index 7360a2d..c4c40e3 100644 --- a/crates/libkos/src/session.rs +++ b/crates/libkos/src/session.rs @@ -31,3 +31,10 @@ pub fn who(session: CapSlot, name: &mut [u8; types::name::BYTES]) -> Option<(usi pub fn sign_out(session: CapSlot) -> bool { syscall::send_msg(session, [SIGN_OUT, 0, 0, 0], None).is_ok() } + +/// Makes the theme named `name` the signed-in user's (kept, and shown at +/// once); whether init took it. +pub fn set_theme(session: CapSlot, name: &[u8]) -> bool { + let [a, b, c] = types::name::pack(name); + syscall::request(session, [types::spawn::SET_THEME, a, b, c]).is_ok_and(|r| r[0] == types::spawn::reply::OK) +} diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 373db0d..d4f6e55 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -9,7 +9,7 @@ mod capability; mod decision; mod fields; mod manifest; -mod toml; +pub mod toml; #[cfg(test)] mod tests; diff --git a/crates/manifest/src/manifest.rs b/crates/manifest/src/manifest.rs index dc228ab..36785db 100644 --- a/crates/manifest/src/manifest.rs +++ b/crates/manifest/src/manifest.rs @@ -52,7 +52,7 @@ fn set(slot: &mut Option, value: Option, line: usize) -> Result<(), Err fn text<'a>(value: Value<'a>) -> Option<&'a str> { match value { Value::Str(s) => Some(s), - Value::List(_) => None, + Value::List(_) | Value::Int(_) => None, } } diff --git a/crates/manifest/src/tests.rs b/crates/manifest/src/tests.rs index 81e716e..d897b06 100644 --- a/crates/manifest/src/tests.rs +++ b/crates/manifest/src/tests.rs @@ -167,6 +167,8 @@ fn only_the_subset_parses() { "id = kos.snake", // bare value "id \"kos.snake\"", // no = "[app", // unclosed table + "id = 12x", // not a whole number + "id = 1.5", // no fractions ] { assert!( matches!( diff --git a/crates/manifest/src/toml.rs b/crates/manifest/src/toml.rs index ad4b1fe..de471e9 100644 --- a/crates/manifest/src/toml.rs +++ b/crates/manifest/src/toml.rs @@ -1,6 +1,7 @@ //! The strict TOML subset manifests and decisions are written in: //! `[table]` headers, `key = "string"`, `key = ["a", "b"]` on one line, -//! `#` comments. Strings are printable ASCII without `"` or `\`. +//! `key = 42` (a whole number), `#` comments. Strings are printable ASCII +//! without `"` or `\`. use crate::Error; @@ -18,6 +19,8 @@ pub enum Value<'a> { Str(&'a str), /// A one-line array of strings, already checked. List(List<'a>), + /// Digits only: no sign, no fraction. + Int(u32), } /// The inside of `[...]`: iterate it for its strings. @@ -119,6 +122,10 @@ fn string(s: &str, line: usize) -> Result<&str, Error> { } fn value_of(s: &str, line: usize) -> Result, Error> { + if s.bytes().next().is_some_and(|b| b.is_ascii_digit()) { + let digits = s.bytes().all(|b| b.is_ascii_digit()); + return digits.then(|| s.parse().ok()).flatten().map(Value::Int).ok_or(Error::Syntax { line }); + } let Some(inner) = s.strip_prefix('[') else { return string(s, line).map(Value::Str); }; diff --git a/crates/servers/display-server/Cargo.toml b/crates/servers/display-server/Cargo.toml index c3b6b78..e01ee04 100644 --- a/crates/servers/display-server/Cargo.toml +++ b/crates/servers/display-server/Cargo.toml @@ -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 } diff --git a/crates/servers/display-server/src/lib.rs b/crates/servers/display-server/src/lib.rs index 12e4490..d71b4be 100644 --- a/crates/servers/display-server/src/lib.rs +++ b/crates/servers/display-server/src/lib.rs @@ -3,6 +3,7 @@ pub mod host; pub mod server; +pub mod themes; pub mod windows; #[cfg(test)] diff --git a/crates/servers/display-server/src/server.rs b/crates/servers/display-server/src/server.rs index bd73185..844275f 100644 --- a/crates/servers/display-server/src/server.rs +++ b/crates/servers/display-server/src/server.rs @@ -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 { caps: [Option; 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 DisplayServer { - 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 DisplayServer { 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 DisplayServer { /// 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 DisplayServer { 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 DisplayServer { ([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 Server for DisplayServer { 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])) { diff --git a/crates/servers/display-server/src/tests/server.rs b/crates/servers/display-server/src/tests/server.rs index b08fe84..99239bf 100644 --- a/crates/servers/display-server/src/tests/server.rs +++ b/crates/servers/display-server/src/tests/server.rs @@ -61,7 +61,7 @@ impl Host for FakeHost { type Srv = DisplayServer, 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); +} diff --git a/crates/servers/display-server/src/themes.rs b/crates/servers/display-server/src/themes.rs new file mode 100644 index 0000000..9044ee8 --- /dev/null +++ b/crates/servers/display-server/src/themes.rs @@ -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; 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 { + 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() + } +} diff --git a/crates/servers/display-server/src/windows.rs b/crates/servers/display-server/src/windows.rs index a04f7ab..7301ad5 100644 --- a/crates/servers/display-server/src/windows.rs +++ b/crates/servers/display-server/src/windows.rs @@ -133,6 +133,14 @@ impl Windows { w.notify } + /// Queues an event for every window; whom to tell. + pub fn push_all(&mut self, event: [usize; 4]) -> impl Iterator + '_ { + 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)?; diff --git a/crates/servers/storage-server/src/encrypted.rs b/crates/servers/storage-server/src/encrypted.rs index 44b56c1..12904ad 100644 --- a/crates/servers/storage-server/src/encrypted.rs +++ b/crates/servers/storage-server/src/encrypted.rs @@ -1,6 +1,7 @@ //! The encrypted areas (see the vision's Encryption and Users notes), over //! any file system. Each user's folder `users//` holds their areas: -//! `home/`, `permissions/`, and per app `apps//` and `data//`, +//! `home/`, `permissions/`, `settings/` (init's record of their choices, +//! such as the theme), and per app `apps//` and `data//`, //! each kept as `filecrypt` files under its own key, derived from that //! user's master key. Only the signed-in user's areas open; anyone else's //! are refused (and noise to their key anyway). The rest (programs, @@ -42,7 +43,7 @@ fn area(path: &[u8]) -> (Area<'_>, Option<&[u8]>) { let upto = |n: usize| &path[..n]; let base = 6 + user.len() + 1; let area = match (kind, app, rest) { - (Some(k @ (b"home" | b"permissions")), _, _) => Area::Sealed(upto(base + k.len())), + (Some(k @ (b"home" | b"permissions" | b"settings")), _, _) => Area::Sealed(upto(base + k.len())), (Some(k @ (b"apps" | b"data")), Some(a), Some(r)) if !a.is_empty() && !r.is_empty() => Area::Sealed(upto(base + k.len() + 1 + a.len())), _ => Area::Neither, }; diff --git a/crates/servers/storage-server/src/tests/encrypted.rs b/crates/servers/storage-server/src/tests/encrypted.rs index ad09a20..c23626f 100644 --- a/crates/servers/storage-server/src/tests/encrypted.rs +++ b/crates/servers/storage-server/src/tests/encrypted.rs @@ -154,6 +154,7 @@ fn areas_are_told_by_path() { (&b"users/u1/home/readme.txt"[..], "sealed"), (b"/users/u1/home", "sealed"), (b"users/u1/permissions/kos.snake", "sealed"), + (b"users/u1/settings/theme", "sealed"), (b"users/u1/data/kos.notes/a", "sealed"), (b"users/u1/apps/kos.notes/notes", "sealed"), (b"users/u1/data/kos.notes", "neither"), diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.toml new file mode 100644 index 0000000..04f1251 --- /dev/null +++ b/crates/theme/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "theme" +version = "0.1.0" +edition = "2024" + +[dependencies] +types = { workspace = true } +manifest = { workspace = true } +libm = "0.2" diff --git a/crates/theme/src/contrast.rs b/crates/theme/src/contrast.rs new file mode 100644 index 0000000..0d1226c --- /dev/null +++ b/crates/theme/src/contrast.rs @@ -0,0 +1,50 @@ +//! Contrast as WCAG 2 measures it: the ratio of two colours' relative +//! luminance, from 1 (the same) to 21 (black on white). Text needs 4.5, +//! other things the eye must find (the focus ring, a toggle's track) 3. + +use crate::parse::Error; +use crate::{Rgba, Theme}; + +fn channel(c: u8) -> f32 { + let c = c as f32 / 255.0; + if c <= 0.040_45 { c / 12.92 } else { libm::powf((c + 0.055) / 1.055, 2.4) } +} + +fn luminance(c: Rgba) -> f32 { + 0.2126 * channel(c.0) + 0.7152 * channel(c.1) + 0.0722 * channel(c.2) +} + +pub fn ratio(a: Rgba, b: Rgba) -> f32 { + let (la, lb) = (luminance(a), luminance(b)); + (la.max(lb) + 0.05) / (la.min(lb) + 0.05) +} + +const TEXT: f32 = 4.5; +const SHAPE: f32 = 3.0; + +/// Every pairing K-UI draws, each with what it must reach. +fn pairings(t: &Theme) -> [(&'static str, Rgba, Rgba, f32); 10] { + [ + ("text_primary on background", t.text_primary, t.background, TEXT), + ("text_primary on surface", t.text_primary, t.surface, TEXT), + ("text_primary on surface_raised", t.text_primary, t.surface_raised, TEXT), + ("text_secondary on background", t.text_secondary, t.background, TEXT), + ("text_secondary on surface", t.text_secondary, t.surface, TEXT), + ("accent on background", t.accent, t.background, TEXT), + ("on_accent on accent", t.on_accent, t.accent, TEXT), + ("on_destructive on destructive", t.on_destructive, t.destructive, TEXT), + ("accent on surface", t.accent, t.surface, SHAPE), + ("focus on background", t.focus, t.background, SHAPE), + ] +} + +/// The theme, if everything drawn with it can be read. +pub fn check(t: Theme) -> Result { + for (pair, fg, bg, min) in pairings(&t) { + let r = ratio(fg, bg); + if r < min { + return Err(Error::Contrast { pair, ratio_x100: (r * 100.0) as u32 }); + } + } + Ok(t) +} diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs new file mode 100644 index 0000000..558c77a --- /dev/null +++ b/crates/theme/src/lib.rs @@ -0,0 +1,202 @@ +//! A theme's tokens and the words apps describe their UI in (see the +//! vision's K-UI note): apps say what a thing is (a primary button, a +//! heading), never its colour or size. Themes are files (`themes/*.toml` +//! on the system image), read strictly (`parse`) and refused unless every +//! pairing text is drawn in has enough contrast (`check`). The display +//! holds the one in use and hands it to every window (`wire`). Pure. + +#![no_std] +#![forbid(unsafe_code)] + +mod contrast; +mod parse; +pub mod wire; + +#[cfg(test)] +mod tests; + +pub use contrast::{check, ratio}; +pub use parse::{Error, parse}; + +/// A colour as the theme names it: straight (not premultiplied) RGBA. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Rgba(pub u8, pub u8, pub u8, pub u8); + +impl Rgba { + pub const fn opaque(r: u8, g: u8, b: u8) -> Self { + Self(r, g, b, 255) + } + + /// From `0xrrggbb`. + pub const fn rgb(v: u32) -> Self { + Self::opaque((v >> 16) as u8, (v >> 8) as u8, v as u8) + } + + pub const fn to_rgb(self) -> u32 { + (self.0 as u32) << 16 | (self.1 as u32) << 8 | self.2 as u32 + } +} + +/// Steps of the spacing scale. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Spacing { + None, + Xs, + Sm, + Md, + Lg, + Xl, +} + +/// Steps of the type scale. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TextStyle { + Heading, + Subheading, + Body, + Caption, + Label, +} + +/// What a text is, for its colour. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TextColour { + Primary, + Secondary, + Accent, + OnAccent, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ButtonVariant { + Primary, + Secondary, + Ghost, + Destructive, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Weight { + Regular, + SemiBold, +} + +/// A theme's name: what Settings lists and a user's choice records. +pub const NAME_BYTES: usize = types::name::BYTES; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Theme { + pub name: [u8; NAME_BYTES], + pub name_len: usize, + pub background: Rgba, + pub surface: Rgba, + pub surface_raised: Rgba, + pub accent: Rgba, + pub on_accent: Rgba, + pub text_primary: Rgba, + pub text_secondary: Rgba, + pub destructive: Rgba, + pub on_destructive: Rgba, + pub focus: Rgba, + /// Soft colours the wallpaper glows with, behind the glass. + pub orbs: [Rgba; 3], + pub corner_radius: f32, + /// The spacing scale's unit, in pixels. + pub spacing_base: f32, + /// Milliseconds; zero turns motion off. + pub duration_fast: u16, + pub duration_normal: u16, + pub spring_stiffness: u16, + pub spring_damping: u16, + /// Pixels. + pub blur_radius: u8, + /// Percent. + pub panel_opacity: u8, + pub highlight: u8, +} + +pub(crate) const fn name_bytes(s: &[u8]) -> ([u8; NAME_BYTES], usize) { + let mut out = [0u8; NAME_BYTES]; + let mut i = 0; + while i < s.len() && i < NAME_BYTES { + out[i] = s[i]; + i += 1; + } + (out, i) +} + +impl Theme { + /// What a window shows before the display has said, and what the + /// display uses when no theme file loads; `disk/themes/whimsy-dark.toml` + /// says the same (a test keeps them so). + pub const WHIMSY_DARK: Theme = Theme { + name: name_bytes(b"Whimsy Dark").0, + name_len: name_bytes(b"Whimsy Dark").1, + background: Rgba::rgb(0x0f0e17), + surface: Rgba::rgb(0x1a1a2e), + surface_raised: Rgba::rgb(0x2a2a44), + accent: Rgba::rgb(0x4fc3f7), + on_accent: Rgba::rgb(0x0f0e17), + text_primary: Rgba::rgb(0xe0e0e0), + text_secondary: Rgba::rgb(0x9e9eb8), + destructive: Rgba::rgb(0xef5350), + on_destructive: Rgba::rgb(0x1a0a0a), + focus: Rgba::rgb(0xffffff), + orbs: [Rgba::rgb(0x3a1c71), Rgba::rgb(0x0f4c5c), Rgba::rgb(0x5b1a4a)], + corner_radius: 16.0, + spacing_base: 4.0, + duration_fast: 150, + duration_normal: 280, + spring_stiffness: 300, + spring_damping: 30, + blur_radius: 12, + panel_opacity: 72, + highlight: 18, + }; + + pub fn name(&self) -> &[u8] { + &self.name[..self.name_len] + } + + pub fn space(&self, s: Spacing) -> f32 { + self.spacing_base + * match s { + Spacing::None => 0.0, + Spacing::Xs => 1.0, + Spacing::Sm => 2.0, + Spacing::Md => 4.0, + Spacing::Lg => 6.0, + Spacing::Xl => 8.0, + } + } + + /// A text style's size in pixels and its weight. + pub fn text(&self, s: TextStyle) -> (f32, Weight) { + match s { + TextStyle::Heading => (32.0, Weight::SemiBold), + TextStyle::Subheading => (22.0, Weight::SemiBold), + TextStyle::Body => (17.0, Weight::Regular), + TextStyle::Caption => (13.0, Weight::Regular), + TextStyle::Label => (17.0, Weight::SemiBold), + } + } + + pub fn text_colour(&self, c: TextColour) -> Rgba { + match c { + TextColour::Primary => self.text_primary, + TextColour::Secondary => self.text_secondary, + TextColour::Accent => self.accent, + TextColour::OnAccent => self.on_accent, + } + } + + /// A button's fill (none for a ghost) and its label's colour. + pub fn button(&self, v: ButtonVariant) -> (Option, Rgba) { + match v { + ButtonVariant::Primary => (Some(self.accent), self.on_accent), + ButtonVariant::Secondary => (Some(self.surface_raised), self.text_primary), + ButtonVariant::Ghost => (None, self.accent), + ButtonVariant::Destructive => (Some(self.destructive), self.on_destructive), + } + } +} diff --git a/crates/theme/src/parse.rs b/crates/theme/src/parse.rs new file mode 100644 index 0000000..a5e1055 --- /dev/null +++ b/crates/theme/src/parse.rs @@ -0,0 +1,126 @@ +//! A theme file, read strictly: every token present once, nothing unknown, +//! every value in range. Contrast is `check`'s. + +use manifest::Error as Toml; +use manifest::toml::{self, Value}; + +use crate::{NAME_BYTES, Rgba, Theme}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Error { + /// Not the TOML subset, a token given twice or unknown, or out of range. + Toml(Toml), + /// Two colours drawn on one another are too close: the pairing, and + /// the ratio they have, times 100. + Contrast { pair: &'static str, ratio_x100: u32 }, +} + +const COLOURS: [&str; 10] = [ + "background", + "surface", + "surface_raised", + "accent", + "on_accent", + "text_primary", + "text_secondary", + "destructive", + "on_destructive", + "focus", +]; + +/// The whole numbers: table, key, and the range allowed. +const NUMBERS: [(&str, &str, u32, u32); 9] = [ + ("shape", "corner_radius", 0, 32), + ("spacing", "base", 2, 8), + ("motion", "duration_fast", 0, 2000), + ("motion", "duration_normal", 0, 2000), + ("motion", "spring_stiffness", 1, 2000), + ("motion", "spring_damping", 1, 200), + ("glass", "blur_radius", 0, 32), + ("glass", "panel_opacity", 0, 100), + ("glass", "highlight", 0, 100), +]; + +fn colour(s: &str) -> Option { + manifest::Accent::parse(s).map(|a| Rgba::rgb(a.0)) +} + +fn once(slot: &mut Option, value: Option, line: usize) -> Result<(), Toml> { + if slot.is_some() { + return Err(Toml::Duplicate { line }); + } + *slot = Some(value.ok_or(Toml::Invalid { line })?); + Ok(()) +} + +/// Reads a theme file. It is not checked for contrast here. +pub fn parse(text: &str) -> Result { + let mut name: Option<([u8; NAME_BYTES], usize)> = None; + let mut colours: [Option; 10] = [None; 10]; + let mut orbs: Option<[Rgba; 3]> = None; + let mut numbers: [Option; 9] = [None; 9]; + toml::parse(text, |e| { + let line = e.line; + match (e.table, e.key, e.value) { + ("meta", "name", Value::Str(s)) => { + let fits = (1..=NAME_BYTES).contains(&s.len()); + once(&mut name, fits.then(|| crate::name_bytes(s.as_bytes())), line) + } + ("colour", key, Value::Str(s)) => { + let i = COLOURS.iter().position(|k| *k == key).ok_or(Toml::Unknown { line })?; + once(&mut colours[i], colour(s), line) + } + ("wallpaper", "orbs", Value::List(list)) => { + let mut out = [Rgba::rgb(0); 3]; + let mut n = 0; + for s in list.iter() { + *out.get_mut(n).ok_or(Toml::Invalid { line })? = colour(s).ok_or(Toml::Invalid { line })?; + n += 1; + } + once(&mut orbs, (n == 3).then_some(out), line) + } + (table, key, Value::Int(v)) => { + let i = NUMBERS.iter().position(|(t, k, ..)| *t == table && *k == key).ok_or(Toml::Unknown { line })?; + let (_, _, lo, hi) = NUMBERS[i]; + once(&mut numbers[i], (lo..=hi).contains(&v).then_some(v), line) + } + ("meta" | "colour" | "wallpaper" | "shape" | "spacing" | "motion" | "glass", _, _) => Err(Toml::Invalid { line }), + _ => Err(Toml::Unknown { line }), + } + }) + .map_err(Error::Toml)?; + let (name, name_len) = name.ok_or(Error::Toml(Toml::Missing("name")))?; + let mut c = [Rgba::rgb(0); 10]; + for (i, slot) in colours.iter().enumerate() { + c[i] = slot.ok_or(Error::Toml(Toml::Missing(COLOURS[i])))?; + } + let mut n = [0u32; 9]; + for (i, slot) in numbers.iter().enumerate() { + n[i] = slot.ok_or(Error::Toml(Toml::Missing(NUMBERS[i].1)))?; + } + Ok(Theme { + name, + name_len, + background: c[0], + surface: c[1], + surface_raised: c[2], + accent: c[3], + on_accent: c[4], + text_primary: c[5], + text_secondary: c[6], + destructive: c[7], + on_destructive: c[8], + focus: c[9], + orbs: orbs.ok_or(Error::Toml(Toml::Missing("orbs")))?, + corner_radius: n[0] as f32, + spacing_base: n[1] as f32, + // In range, so these fit. + duration_fast: n[2] as u16, + duration_normal: n[3] as u16, + spring_stiffness: n[4] as u16, + spring_damping: n[5] as u16, + blur_radius: n[6] as u8, + panel_opacity: n[7] as u8, + highlight: n[8] as u8, + }) +} diff --git a/crates/theme/src/tests.rs b/crates/theme/src/tests.rs new file mode 100644 index 0000000..da3df15 --- /dev/null +++ b/crates/theme/src/tests.rs @@ -0,0 +1,53 @@ +extern crate std; + +use crate::*; + +const DARK: &str = include_str!("../../../disk/themes/whimsy-dark.toml"); +const LIGHT: &str = include_str!("../../../disk/themes/whimsy-light.toml"); + +#[test] +fn the_shipped_themes_load_and_pass_contrast() { + for text in [DARK, LIGHT] { + let t = parse(text).unwrap(); + check(t).unwrap_or_else(|e| panic!("{}: {e:?}", core::str::from_utf8(t.name()).unwrap())); + } + assert_eq!(parse(LIGHT).unwrap().name(), b"Whimsy Light"); +} + +#[test] +fn the_built_in_theme_is_the_dark_file() { + assert_eq!(parse(DARK).unwrap(), Theme::WHIMSY_DARK); +} + +#[test] +fn a_theme_survives_the_wire_but_its_name() { + for text in [DARK, LIGHT] { + let t = parse(text).unwrap(); + let back = wire::decode(wire::encode(&t)); + assert_eq!(back, Theme { name: [0; NAME_BYTES], name_len: 0, ..t }); + } +} + +#[test] +fn unreadable_pairings_are_refused() { + let grey = DARK.replace("text_secondary = \"#9e9eb8\"", "text_secondary = \"#3a3a4a\""); + assert!(matches!(check(parse(&grey).unwrap()), Err(Error::Contrast { pair: "text_secondary on background", .. }))); + assert!((ratio(Rgba::rgb(0), Rgba::rgb(0xffffff)) - 21.0).abs() < 0.01); +} + +#[test] +fn every_token_is_needed_and_checked() { + use manifest::Error as T; + let missing = DARK.replace("focus = \"#ffffff\"\n", ""); + assert_eq!(parse(&missing), Err(Error::Toml(T::Missing("focus")))); + for (from, to) in [ + ("corner_radius = 16", "corner_radius = 99"), + ("base = 4", "base = \"4\""), + ("\"#ffffff\"", "\"white\""), + ("orbs = [\"#3a1c71\", \"#0f4c5c\", \"#5b1a4a\"]", "orbs = [\"#3a1c71\"]"), + ] { + assert!(matches!(parse(&DARK.replace(from, to)), Err(Error::Toml(T::Invalid { .. }))), "{to}"); + } + 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 { .. })))); +} diff --git a/crates/theme/src/wire.rs b/crates/theme/src/wire.rs new file mode 100644 index 0000000..3dbd14c --- /dev/null +++ b/crates/theme/src/wire.rs @@ -0,0 +1,73 @@ +//! A theme in message words, as the display hands it to a window +//! (`types::display::window::THEME`). Its name stays behind. + +use crate::{Rgba, Theme}; + +pub const WORDS: usize = types::display::window::THEME_WORDS; + +fn colours(t: &Theme) -> [Rgba; 12] { + [ + t.background, + t.surface, + t.surface_raised, + t.accent, + t.on_accent, + t.text_primary, + t.text_secondary, + t.destructive, + t.on_destructive, + t.focus, + t.orbs[0], + t.orbs[1], + ] +} + +pub fn encode(t: &Theme) -> [usize; WORDS] { + let c = colours(t); + let mut w = [0usize; WORDS]; + for i in 0..6 { + w[i] = c[2 * i].to_rgb() as usize | (c[2 * i + 1].to_rgb() as usize) << 32; + } + w[6] = t.corner_radius as usize + | (t.spacing_base as usize) << 8 + | (t.blur_radius as usize) << 16 + | (t.panel_opacity as usize) << 24 + | (t.highlight as usize) << 32 + | (t.duration_fast as usize) << 40; + w[7] = t.duration_normal as usize + | (t.spring_stiffness as usize) << 16 + | (t.spring_damping as usize & 0xff) << 32 + | (t.orbs[2].to_rgb() as usize & 0xff_ffff) << 40; + w +} + +pub fn decode(w: [usize; WORDS]) -> Theme { + let c = |i: usize| Rgba::rgb((w[i / 2] >> (32 * (i % 2))) as u32 & 0xff_ffff); + let byte = |word: usize, at: u32| (w[word] >> at) as u8; + let half = |word: usize, at: u32| (w[word] >> at) as u16; + Theme { + name: [0; crate::NAME_BYTES], + name_len: 0, + background: c(0), + surface: c(1), + surface_raised: c(2), + accent: c(3), + on_accent: c(4), + text_primary: c(5), + text_secondary: c(6), + destructive: c(7), + on_destructive: c(8), + focus: c(9), + orbs: [c(10), c(11), Rgba::rgb((w[7] >> 40) as u32 & 0xff_ffff)], + corner_radius: byte(6, 0) as f32, + spacing_base: byte(6, 8) as f32, + blur_radius: byte(6, 16), + panel_opacity: byte(6, 24), + highlight: byte(6, 32), + duration_fast: half(6, 40), + duration_normal: half(7, 0), + spring_stiffness: half(7, 16), + // At most 200 (see `parse`): a byte. + spring_damping: byte(7, 32) as u16, + } +} diff --git a/crates/types/src/display.rs b/crates/types/src/display.rs index de787e5..060e500 100644 --- a/crates/types/src/display.rs +++ b/crates/types/src/display.rs @@ -94,6 +94,24 @@ pub mod window { /// From init (unbadged) only: word 1 a launcher's badge, which has /// gone; every window it opened closes. pub const FORGET: usize = 8; + /// Through a window: word 1 a part (0 to `THEME_WORDS / 2 - 1`) of + /// the theme in use, as `theme::wire` words it. Reply `[OK, changes | + /// index << 32, word, word]`: `changes` counts switches (a client that + /// sees it move mid-read starts over); `index` is the theme's place in + /// `THEME_NAME`'s list. + pub const THEME: usize = 9; + pub const THEME_WORDS: usize = 8; + /// Through a window: word 1 an index. Reply `[OK, name]` (`pack_name`) + /// for each theme the display has, `MALFORMED` past the last. + pub const THEME_NAME: usize = 10; + /// From init (unbadged) only: the theme named in words 1..4 (empty: + /// the default) is now in use; `MALFORMED` if there is none by that + /// name. + pub const SET_THEME: usize = 11; + /// 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; + /// The function key that brings the next window to the front; the /// display keeps it to itself. pub const SWITCH_KEY: usize = 1; diff --git a/crates/types/src/spawn.rs b/crates/types/src/spawn.rs index bd36119..ea96628 100644 --- a/crates/types/src/spawn.rs +++ b/crates/types/src/spawn.rs @@ -23,6 +23,10 @@ pub const SIGN_OUT: usize = 1; /// Through the same copy: who is signed in. Reply `[role, name...]`, the /// name in three words (`types::name`); role as in `role`. pub const WHO: usize = 2; +/// Through the same copy: the signed-in user's theme is now the one named +/// in words 1..4 (`types::name`; empty: the default). Kept in their folder +/// and shown at once. Reply `[OK, ..]`, or `MALFORMED`. +pub const SET_THEME: usize = 3; pub mod role { pub const OWNER: usize = 1; diff --git a/disk/themes/whimsy-dark.toml b/disk/themes/whimsy-dark.toml new file mode 100644 index 0000000..be8475f --- /dev/null +++ b/disk/themes/whimsy-dark.toml @@ -0,0 +1,37 @@ +# The default theme. Colours are #rrggbb; every pairing text is drawn in +# is checked for contrast when the theme is loaded. + +[meta] +name = "Whimsy Dark" + +[colour] +background = "#0f0e17" +surface = "#1a1a2e" +surface_raised = "#2a2a44" +accent = "#4fc3f7" +on_accent = "#0f0e17" +text_primary = "#e0e0e0" +text_secondary = "#9e9eb8" +destructive = "#ef5350" +on_destructive = "#1a0a0a" +focus = "#ffffff" + +[wallpaper] +orbs = ["#3a1c71", "#0f4c5c", "#5b1a4a"] + +[shape] +corner_radius = 16 + +[spacing] +base = 4 + +[motion] +duration_fast = 150 # ms; 0 turns motion off +duration_normal = 280 +spring_stiffness = 300 +spring_damping = 30 + +[glass] +blur_radius = 12 +panel_opacity = 72 # percent +highlight = 18 # percent diff --git a/disk/themes/whimsy-light.toml b/disk/themes/whimsy-light.toml new file mode 100644 index 0000000..faa670b --- /dev/null +++ b/disk/themes/whimsy-light.toml @@ -0,0 +1,34 @@ +[meta] +name = "Whimsy Light" + +[colour] +background = "#f3f2f8" +surface = "#ffffff" +surface_raised = "#e6e4f0" +accent = "#0b6bcb" +on_accent = "#ffffff" +text_primary = "#1a1a2e" +text_secondary = "#55556e" +destructive = "#c62828" +on_destructive = "#ffffff" +focus = "#1a1a2e" + +[wallpaper] +orbs = ["#c9b8ff", "#a8e6ff", "#ffc6e8"] + +[shape] +corner_radius = 16 + +[spacing] +base = 4 + +[motion] +duration_fast = 150 +duration_normal = 280 +spring_stiffness = 300 +spring_damping = 30 + +[glass] +blur_radius = 12 +panel_opacity = 64 +highlight = 40 diff --git a/docs/apps.md b/docs/apps.md index fddf40a..3dcca44 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -37,9 +37,11 @@ declares `display`. Nothing gets anything "just in case". apps// an app they installed data// an app's private folder (its `storage`, its own key) permissions/ their decisions, one file per app id + settings/ init's record of their choices (the theme) /bin/ system servers and the shell (init starts these) +/themes/ the themes the display offers (`*.toml`) /factory/ plaintext starter content a user's folder is - seeded from (home/, permissions/, data/) + seeded from (home/, permissions/, settings/, data/) ``` Users each decide for themselves, even about the system's apps, and what diff --git a/docs/ipc.md b/docs/ipc.md index acc19ee..5c954fb 100644 --- a/docs/ipc.md +++ b/docs/ipc.md @@ -186,6 +186,19 @@ opener is remembered: when init sees a program that uses the display go, it says so (`FORGET`, through its printer thread so it never waits on the display), and that program's windows close. +The display also holds the theme in use (`theme` crate: tokens, their +TOML files, contrast checks). At start it reads `themes/*.toml` through a +read-only grant, keeps each one that parses and passes contrast (the +built-in Whimsy Dark if none does), and releases the grant (storage has +few connections). Any window can list them (`THEME_NAME`) and read the +one in use (`THEME`, two words a call, `theme::wire`). Only init picks it +(`SET_THEME`, unbadged): then every window, behind or in front, is queued +`THEME_CHANGED` and signalled, and the bar is drawn in it. Init picks the +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`. + Input reaches apps through the display server, the input server's only client. It subscribes to the input server with a notification, drains it when signalled, keeps F1, and queues the rest for the window in front, diff --git a/scripts/make-disk-image.sh b/scripts/make-disk-image.sh index db4bfb3..853a27a 100755 --- a/scripts/make-disk-image.sh +++ b/scripts/make-disk-image.sh @@ -39,7 +39,7 @@ staged() { # Each user's folder in users/ is encrypted, so not filled here: init seeds # a new user's areas from factory/ (plaintext) once they are signed in. -mkdir -p "${ROOT}/bin" "${ROOT}/apps" "${ROOT}/users" "${ROOT}/factory/home" "${ROOT}/factory/permissions" "${ROOT}/factory/data" "${ROOT}/factory/apps" +mkdir -p "${ROOT}/bin" "${ROOT}/apps" "${ROOT}/users" "${ROOT}/factory/home" "${ROOT}/factory/permissions" "${ROOT}/factory/data" "${ROOT}/factory/apps" "${ROOT}/factory/settings" # System servers and the shell: init starts these. copy display-qemu display copy input-qemu input @@ -59,6 +59,7 @@ app settings staged counter inbox/counter # A user's first files. cp -r disk/home/. "${ROOT}/factory/home/" +mkdir -p "${ROOT}/themes" && cp disk/themes/*.toml "${ROOT}/themes/" # Only the test image: its presence makes init run the self-test. if [ -n "${SMPTEST:-}" ]; then copy smptest smptest