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

11
Cargo.lock generated
View File

@@ -242,6 +242,7 @@ dependencies = [
"port-framebuffer", "port-framebuffer",
"raster", "raster",
"server-framework", "server-framework",
"theme",
"types", "types",
] ]
@@ -467,6 +468,7 @@ dependencies = [
"libkos", "libkos",
"libm", "libm",
"port-framebuffer", "port-framebuffer",
"theme",
"tiny-skia", "tiny-skia",
"types", "types",
] ]
@@ -757,6 +759,15 @@ dependencies = [
"types", "types",
] ]
[[package]]
name = "theme"
version = "0.1.0"
dependencies = [
"libm",
"manifest",
"types",
]
[[package]] [[package]]
name = "tiny-skia" name = "tiny-skia"
version = "0.11.4" version = "0.11.4"

View File

@@ -6,6 +6,7 @@ members = [
"crates/apps/flaky", "crates/apps/flaky",
"crates/apps/tally", "crates/apps/tally",
"crates/apps/settings", "crates/apps/settings",
"crates/theme",
"crates/apps/hello", "crates/apps/hello",
"crates/apps/shell", "crates/apps/shell",
"crates/apps/cat", "crates/apps/cat",
@@ -81,6 +82,7 @@ types = { path = "crates/types" }
domain = { path = "crates/domain" } domain = { path = "crates/domain" }
elf = { path = "crates/elf" } elf = { path = "crates/elf" }
manifest = { path = "crates/manifest" } manifest = { path = "crates/manifest" }
theme = { path = "crates/theme" }
keystore = { path = "crates/keystore" } keystore = { path = "crates/keystore" }
filecrypt = { path = "crates/filecrypt" } filecrypt = { path = "crates/filecrypt" }
# Audited RustCrypto implementations; never our own. # Audited RustCrypto implementations; never our own.

View File

@@ -1,5 +1,6 @@
//! Settings: what each app may use (allow or revoke, through the //! 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 //! `permissions` and `session` are only ever granted to apps on the system
//! image. //! image.
@@ -29,6 +30,7 @@ enum Screen {
Apps, Apps,
/// One app, by its place in the list. /// One app, by its place in the list.
App(usize), App(usize),
Appearance,
About, About,
} }
@@ -37,6 +39,8 @@ enum Event {
Show(Screen), Show(Screen),
/// Allow or revoke this capability of the app on screen. /// Allow or revoke this capability of the app on screen.
Flip(Capability), Flip(Capability),
/// Use the display's `n`th theme.
Theme(usize),
SignOut, SignOut,
} }
@@ -54,6 +58,9 @@ struct Settings {
rows: Vec<AppRow>, rows: Vec<AppRow>,
/// Who is signed in, and how. /// Who is signed in, and how.
who: Option<(String, Role)>, who: Option<(String, Role)>,
/// The display's themes, and which is in use.
themes: Vec<String>,
theme: usize,
/// What went wrong last, shown until the next change. /// What went wrong last, shown until the next change.
trouble: Option<String>, trouble: Option<String>,
} }
@@ -99,6 +106,23 @@ impl Settings {
self.reload(); self.reload();
} }
fn appearance(&self) -> Element<Event> {
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<Event> { fn heading(title: &str) -> k_ui::element::FlexBuilder<Event> {
column().padding(Spacing::Xl).gap(Spacing::Md).child(text(title).style(TextStyle::Heading)) column().padding(Spacing::Xl).gap(Spacing::Md).child(text(title).style(TextStyle::Heading))
} }
@@ -110,6 +134,7 @@ impl Settings {
fn home(&self) -> Element<Event> { fn home(&self) -> Element<Event> {
Self::heading("Settings") Self::heading("Settings")
.child(list_item("Apps", Event::Show(Screen::Apps)).subtitle("What each app may use")) .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(list_item("About", Event::Show(Screen::About)).subtitle("Who is signed in, and this device"))
.child(button("Sign out", Event::SignOut).variant(ButtonVariant::Destructive)) .child(button("Sign out", Event::SignOut).variant(ButtonVariant::Destructive))
.into() .into()
@@ -176,7 +201,15 @@ impl App for Settings {
let who = libkos::start::session() let who = libkos::start::session()
.and_then(|s| libkos::session::who(s, &mut name)) .and_then(|s| libkos::session::who(s, &mut name))
.map(|(len, role)| (String::from_utf8_lossy(&name[..len]).into_owned(), role)); .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.reload();
s s
} }
@@ -195,6 +228,7 @@ impl App for Settings {
self.flip(i, cap); self.flip(i, cap);
} }
} }
Event::Theme(i) => self.choose_theme(i),
Event::SignOut => { Event::SignOut => {
if let Some(s) = libkos::start::session() { if let Some(s) = libkos::start::session() {
libkos::session::sign_out(s); libkos::session::sign_out(s);
@@ -208,6 +242,7 @@ impl App for Settings {
Screen::Home => self.home(), Screen::Home => self.home(),
Screen::Apps => self.app_list(), Screen::Apps => self.app_list(),
Screen::App(i) => self.app(i), Screen::App(i) => self.app(i),
Screen::Appearance => self.appearance(),
Screen::About => self.about(), Screen::About => self.about(),
} }
} }
@@ -217,7 +252,7 @@ impl App for Settings {
self.screen = match self.screen { self.screen = match self.screen {
Screen::Home => return false, Screen::Home => return false,
Screen::App(_) => Screen::Apps, Screen::App(_) => Screen::Apps,
Screen::Apps | Screen::About => Screen::Home, Screen::Apps | Screen::Appearance | Screen::About => Screen::Home,
}; };
true true
} }
@@ -227,7 +262,8 @@ impl App for Settings {
Screen::Home => 0, Screen::Home => 0,
Screen::Apps => 1, Screen::Apps => 1,
Screen::About => 2, Screen::About => 2,
Screen::App(i) => 3 + i, Screen::Appearance => 3,
Screen::App(i) => 4 + i,
} }
} }
} }

View File

@@ -9,6 +9,12 @@ use types::bounded::CapSlot;
/// Where init put them (see init's manifest: own endpoint, then `uses`). /// Where init put them (see init's manifest: own endpoint, then `uses`).
const EP: CapSlot = CapSlot::const_new(0); const EP: CapSlot = CapSlot::const_new(0);
const INPUT: CapSlot = CapSlot::const_new(1); 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); libkos::entry!(main);
@@ -38,7 +44,47 @@ fn subscribe_to_input() -> Option<CapSlot> {
} }
fn server<F: port_framebuffer::Framebuffer>(fb: F, input: Option<CapSlot>) -> display_server::server::DisplayServer<F, display_server::host::KernelHost> { fn server<F: port_framebuffer::Framebuffer>(fb: F, input: Option<CapSlot>) -> display_server::server::DisplayServer<F, display_server::host::KernelHost> {
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)] #[inline(never)]

View File

@@ -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/<id>/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<Self> {
// 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]);
}

View File

@@ -8,7 +8,13 @@ use libkos::fs::Storage;
use types::storage::MAX_PATH; use types::storage::MAX_PATH;
/// Each area of a user's folder, and what it starts with. /// 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. /// Entries handled per listing; a folder with more is listed again.
const BATCH: usize = 32; const BATCH: usize = 32;
const NAME: usize = types::storage::DIR_NAME_SIZE; const NAME: usize = types::storage::DIR_NAME_SIZE;

View File

@@ -3,6 +3,7 @@
extern crate alloc; extern crate alloc;
mod appearance;
mod devices; mod devices;
mod factory; mod factory;
mod launch; mod launch;

View File

@@ -111,13 +111,15 @@ pub const SERVERS: &[Program] = &[
Program { Program {
devices: &[Device::Gpu], devices: &[Device::Gpu],
uses: &["input"], uses: &["input"],
// The themes, read once at start.
storage: &[disk(b"themes", false)],
// Its clients subscribe and take focus. // Its clients subscribe and take focus.
restarts_clients: true, restarts_clients: true,
// Its windows' state (input queues among it) is KiBs. // Its windows' state (input queues among it) is KiBs.
stack_pages: 8, stack_pages: 8,
// The framebuffer is ~1000 pages at 1280x800, and so is each of // The framebuffer is ~1000 pages at 1280x800, and so is each of
// the (at most 4) windows' pixel buffers, which it owns. // 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). // 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, // Its clients wait on lines and Ctrl+C. It draws its own text now,

View File

@@ -26,9 +26,14 @@ pub(crate) struct User {
name_len: usize, name_len: usize,
/// As `types::spawn::role` says. /// As `types::spawn::role` says.
role: usize, role: usize,
pub(crate) theme: crate::appearance::Choice,
} }
impl User { impl User {
pub(crate) fn name(&self) -> &[u8] {
&self.name[..self.name_len]
}
/// The reply to `types::spawn::WHO`. /// The reply to `types::spawn::WHO`.
pub(crate) fn who(&self) -> [usize; 4] { pub(crate) fn who(&self) -> [usize; 4] {
let [a, b, c] = types::name::pack(&self.name[..self.name_len]); 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)?; crate::factory::prepare(fs, folder, scratch)?;
let name = core::str::from_utf8(&h.name[..h.name_len]).unwrap_or("someone"); let name = core::str::from_utf8(&h.name[..h.name_len]).unwrap_or("someone");
crate::outln!("init: {} is in", name); crate::outln!("init: {} is in", name);
if let Some(display) = l.endpoint("display") { let theme = crate::appearance::load(fs, folder);
crate::output::post(display, libkos::display::set_user_words(name.as_bytes()));
}
let role = match (guest, h.role) { let role = match (guest, h.role) {
(true, _) => types::spawn::role::GUEST, (true, _) => types::spawn::role::GUEST,
(_, unlocked::OWNER) => types::spawn::role::OWNER, (_, unlocked::OWNER) => types::spawn::role::OWNER,
_ => types::spawn::role::STANDARD, _ => 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); start_session(l);
Ok(()) Ok(())
} }
@@ -150,10 +154,8 @@ fn start_session(l: &mut Launcher) {
pub(crate) fn sign_out(l: &mut Launcher) { pub(crate) fn sign_out(l: &mut Launcher) {
l.spawner.clear(); l.spawner.clear();
l.stop_session(); 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(); let user = l.user.take();
crate::appearance::tell_display(l);
let (fs, _) = l.storage(); let (fs, _) = l.storage();
let locked = fs.lock().is_ok(); let locked = fs.lock().is_ok();
// A guest leaves nothing behind. // A guest leaves nothing behind.

View File

@@ -188,6 +188,7 @@ impl Server for Launcher<'_> {
([MALFORMED, 0, 0, 0], None) ([MALFORMED, 0, 0, 0], None)
} }
types::spawn::WHO => (self.user.map_or([MALFORMED, 0, 0, 0], |u| u.who()), 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), _ => ([MALFORMED, 0, 0, 0], None),
}; };
} }

View File

@@ -324,6 +324,10 @@ impl Launcher<'_> {
..s ..s
}); });
self.ping(index); self.ping(index);
if s.program.name == "display" {
// What it showed went with it.
crate::appearance::tell_display(self);
}
if s.program.restarts_clients { if s.program.restarts_clients {
self.restart_users_of(s.program.name); self.restart_users_of(s.program.name);
} }

View File

@@ -5,6 +5,7 @@ edition = "2024"
[dependencies] [dependencies]
types = { workspace = true } types = { workspace = true }
theme = { workspace = true }
libkos = { workspace = true } libkos = { workspace = true }
port-framebuffer = { workspace = true } port-framebuffer = { workspace = true }
tiny-skia = { version = "0.11", default-features = false, features = ["no-std-float"] } tiny-skia = { version = "0.11", default-features = false, features = ["no-std-float"] }

View File

@@ -1,9 +1,10 @@
//! An app's event loop on K-OS: its window (which the permission manager //! 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 //! 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 //! 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::input::{InputEvent, Key as Pressed};
use libkos::syscall; use libkos::syscall;
use port_framebuffer::Framebuffer; use port_framebuffer::Framebuffer;
@@ -39,7 +40,7 @@ pub fn run<A: App>() -> ! {
libkos::println!("k-ui: no input"); libkos::println!("k-ui: no input");
syscall::exit(); syscall::exit();
} }
let mut ui = Ui::<A>::new(&Theme::WHIMSY_DARK); let mut ui = Ui::<A>::new(theme(canvas.window()));
let mut dirty = true; let mut dirty = true;
loop { loop {
if dirty { if dirty {
@@ -47,8 +48,16 @@ pub fn run<A: App>() -> ! {
dirty = false; dirty = false;
} }
let _ = syscall::wait(notify); let _ = syscall::wait(notify);
while let Some(event) = display::poll_input(canvas.window()) { while let Some(event) = display::poll(canvas.window()) {
let InputEvent::KeyDown(k) = event else { continue }; 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)) { match key(k).map(|k| ui.key(k)) {
Some(Outcome::Leave) => syscall::exit(), Some(Outcome::Leave) => syscall::exit(),
Some(Outcome::Changed) => dirty = true, Some(Outcome::Changed) => dirty = true,
@@ -58,6 +67,11 @@ pub fn run<A: App>() -> ! {
} }
} }
/// 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<A: App>(ui: &mut Ui<A>, canvas: &mut Canvas, fonts: &Fonts) { fn draw<A: App>(ui: &mut Ui<A>, canvas: &mut Canvas, fonts: &Fonts) {
let (w, h) = (canvas.width(), canvas.height()); 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) { if let Some(mut pm) = PixmapMut::from_bytes(canvas.pixels_mut(), w.raw() as u32, h.raw() as u32) {

View File

@@ -80,7 +80,7 @@ impl App for Counter {
#[test] #[test]
fn keys_move_focus_and_press_the_focused_button() { fn keys_move_focus_and_press_the_focused_button() {
let mut ui = Ui::<Counter>::new(T); let mut ui = Ui::<Counter>::new(Theme::WHIMSY_DARK);
assert_eq!(ui.key(Key::Press), Outcome::Changed, "Add has focus first"); assert_eq!(ui.key(Key::Press), Outcome::Changed, "Add has focus first");
assert_eq!(ui.key(Key::Press), Outcome::Changed); assert_eq!(ui.key(Key::Press), Outcome::Changed);
assert_eq!(ui.app.0, 2); assert_eq!(ui.app.0, 2);
@@ -104,7 +104,7 @@ fn at(pm: &tiny_skia::Pixmap, x: u32, y: u32) -> (u8, u8, u8) {
#[test] #[test]
fn the_view_is_drawn_in_the_theme_with_a_focus_ring() { fn the_view_is_drawn_in_the_theme_with_a_focus_ring() {
let fonts = Fonts::load().expect("the bundled fonts parse"); let fonts = Fonts::load().expect("the bundled fonts parse");
let mut ui = Ui::<Counter>::new(T); let mut ui = Ui::<Counter>::new(Theme::WHIMSY_DARK);
let mut pm = tiny_skia::Pixmap::new(320, 200).unwrap(); let mut pm = tiny_skia::Pixmap::new(320, 200).unwrap();
ui.draw(&mut pm.as_mut(), &fonts); ui.draw(&mut pm.as_mut(), &fonts);
let bg = T.background; let bg = T.background;
@@ -163,7 +163,7 @@ impl App for Pages {
#[test] #[test]
fn rows_open_screens_toggles_flip_and_escape_goes_back_then_leaves() { fn rows_open_screens_toggles_flip_and_escape_goes_back_then_leaves() {
let mut ui = Ui::<Pages>::new(T); let mut ui = Ui::<Pages>::new(Theme::WHIMSY_DARK);
assert_eq!(ui.key(Key::Next), Outcome::Changed); assert_eq!(ui.key(Key::Next), Outcome::Changed);
assert_eq!(ui.key(Key::Press), Outcome::Changed); assert_eq!(ui.key(Key::Press), Outcome::Changed);
assert_eq!(ui.app.open, Some(1), "the second row"); 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] #[test]
fn rows_stretch_across_and_the_view_scrolls_to_the_focused_one() { fn rows_stretch_across_and_the_view_scrolls_to_the_focused_one() {
let fonts = Fonts::load().unwrap(); let fonts = Fonts::load().unwrap();
let mut ui = Ui::<Pages>::new(T); let mut ui = Ui::<Pages>::new(Theme::WHIMSY_DARK);
let view = ui.app.view(); 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); 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"); assert_eq!(rects[1].w, 320.0, "a row fills its column");

View File

@@ -1,130 +1,4 @@
//! The theme's tokens (see the vision's K-UI note): apps say what a thing //! The theme's tokens and vocabulary live in the `theme` crate, shared
//! is (a primary button, a heading), never its colour or size. One theme //! with the display that hands the theme in use to every window.
//! for now, built in; loading themes from TOML comes later.
/// A colour as the theme names it: straight (not premultiplied) RGBA. pub use ::theme::{ButtonVariant, Rgba, Spacing, TextColour, TextStyle, Theme, Weight};
#[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>, 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),
}
}
}

View File

@@ -59,7 +59,7 @@ pub struct Ui<A: App> {
focus: usize, focus: usize,
/// How far down the view is scrolled, in pixels. /// How far down the view is scrolled, in pixels.
scroll: f32, scroll: f32,
theme: &'static Theme, theme: Theme,
} }
/// The pre-order indices of every pressable element in `e`. /// The pre-order indices of every pressable element in `e`.
@@ -87,10 +87,15 @@ fn event_at<E: Clone>(e: &Element<E>, index: &mut usize, target: usize) -> Optio
} }
impl<A: App> Ui<A> { impl<A: App> Ui<A> {
pub fn new(theme: &'static Theme) -> Self { pub fn new(theme: Theme) -> Self {
Self { app: A::init(), focus: 0, scroll: 0.0, theme } 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<A::Event>) -> Vec<usize> { fn pressables(&self, view: &Element<A::Event>) -> Vec<usize> {
let mut out = Vec::new(); let mut out = Vec::new();
pressables(view, &mut 0, &mut out); pressables(view, &mut 0, &mut out);
@@ -143,10 +148,10 @@ impl<A: App> Ui<A> {
pub fn draw(&mut self, pm: &mut PixmapMut, fonts: &Fonts) { pub fn draw(&mut self, pm: &mut PixmapMut, fonts: &Fonts) {
let view = self.app.view(); let view = self.app.view();
let (w, h) = (pm.width() as f32, pm.height() as f32); 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. // As tall as it needs: what does not fit is scrolled to.
let tall = layout::natural(&view, &measurer, self.theme).1.max(h); 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 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(); let focused = self.pressables(&view).get(self.focus).copied();
if let Some(r) = focused.and_then(|i| rects.get(i)) { if let Some(r) = focused.and_then(|i| rects.get(i)) {
self.scroll = keep_in_sight(self.scroll, r.y, r.h, h, tall); self.scroll = keep_in_sight(self.scroll, r.y, r.h, h, tall);
@@ -154,7 +159,7 @@ impl<A: App> Ui<A> {
for r in &mut rects { for r in &mut rects {
r.y -= self.scroll; r.y -= self.scroll;
} }
render::render(pm, &view, &rects, fonts, self.theme, focused); render::render(pm, &view, &rects, fonts, &self.theme, focused);
} }
} }

View File

@@ -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) 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<Event> {
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. /// `window`'s next input event, if one is waiting.
pub fn poll_input(window: CapSlot) -> Option<crate::input::InputEvent> { pub fn poll_input(window: CapSlot) -> Option<crate::input::InputEvent> {
let words = syscall::request(window, [w::POLL, 0, 0, 0]).ok()?; loop {
crate::input::decode(words) 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. /// A changed area, as corners.

View File

@@ -31,3 +31,10 @@ pub fn who(session: CapSlot, name: &mut [u8; types::name::BYTES]) -> Option<(usi
pub fn sign_out(session: CapSlot) -> bool { pub fn sign_out(session: CapSlot) -> bool {
syscall::send_msg(session, [SIGN_OUT, 0, 0, 0], None).is_ok() 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)
}

View File

@@ -9,7 +9,7 @@ mod capability;
mod decision; mod decision;
mod fields; mod fields;
mod manifest; mod manifest;
mod toml; pub mod toml;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View File

@@ -52,7 +52,7 @@ fn set<T>(slot: &mut Option<T>, value: Option<T>, line: usize) -> Result<(), Err
fn text<'a>(value: Value<'a>) -> Option<&'a str> { fn text<'a>(value: Value<'a>) -> Option<&'a str> {
match value { match value {
Value::Str(s) => Some(s), Value::Str(s) => Some(s),
Value::List(_) => None, Value::List(_) | Value::Int(_) => None,
} }
} }

View File

@@ -167,6 +167,8 @@ fn only_the_subset_parses() {
"id = kos.snake", // bare value "id = kos.snake", // bare value
"id \"kos.snake\"", // no = "id \"kos.snake\"", // no =
"[app", // unclosed table "[app", // unclosed table
"id = 12x", // not a whole number
"id = 1.5", // no fractions
] { ] {
assert!( assert!(
matches!( matches!(

View File

@@ -1,6 +1,7 @@
//! The strict TOML subset manifests and decisions are written in: //! The strict TOML subset manifests and decisions are written in:
//! `[table]` headers, `key = "string"`, `key = ["a", "b"]` on one line, //! `[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; use crate::Error;
@@ -18,6 +19,8 @@ pub enum Value<'a> {
Str(&'a str), Str(&'a str),
/// A one-line array of strings, already checked. /// A one-line array of strings, already checked.
List(List<'a>), List(List<'a>),
/// Digits only: no sign, no fraction.
Int(u32),
} }
/// The inside of `[...]`: iterate it for its strings. /// 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<Value<'_>, Error> { fn value_of(s: &str, line: usize) -> Result<Value<'_>, 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 { let Some(inner) = s.strip_prefix('[') else {
return string(s, line).map(Value::Str); return string(s, line).map(Value::Str);
}; };

View File

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

View File

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

View File

@@ -4,7 +4,8 @@
//! it, copied from that window's pixel buffer as its app presents changes. //! 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 //! 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 //! 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 libkos::syscall::Give;
use port_framebuffer::Framebuffer; use port_framebuffer::Framebuffer;
@@ -14,14 +15,11 @@ use types::bounded::CapSlot;
use types::display::window::{self as w, NAME_BYTES}; use types::display::window::{self as w, NAME_BYTES};
use types::display::{Color, Dimension, ScreenCoord}; use types::display::{Color, Dimension, ScreenCoord};
use crate::themes::Themes;
use crate::windows::{MAX_WINDOWS, Windows}; use crate::windows::{MAX_WINDOWS, Windows};
/// The OS bar: one row of text. /// The OS bar: one row of text.
pub const BAR_HEIGHT: u16 = font::HEIGHT; 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. /// Pixels copied per row at a time.
const RUN: usize = 256; const RUN: usize = 256;
@@ -69,11 +67,16 @@ pub struct DisplayServer<F: Framebuffer, H: Host> {
caps: [Option<CapSlot>; MAX_WINDOWS], caps: [Option<CapSlot>; MAX_WINDOWS],
user: [u8; NAME_BYTES], user: [u8; NAME_BYTES],
user_len: usize, 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> { impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
pub fn new(fb: F, host: H) -> Self { 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 }; let mut s = Self { fb, host, windows: Windows::new(), caps: [None; MAX_WINDOWS], user: [0; NAME_BYTES], user_len: 0, themes };
s.repaint(); s.repaint();
s s
} }
@@ -158,7 +161,8 @@ impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
match self.windows.front() { match self.windows.front() {
Some(index) => self.present(index, ScreenCoord::new(0), ScreenCoord::new(0), width, height), Some(index) => self.present(index, ScreenCoord::new(0), ScreenCoord::new(0), width, height),
None => { 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(); 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 /// Each window's name, the front one highlighted; who is signed in at
/// the right. /// the right.
fn draw_bar(&mut self) { 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(); 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 x = 0u16;
let mut label = [b' '; NAME_BYTES + 2]; let mut label = [b' '; NAME_BYTES + 2];
let entries: [Option<([u8; NAME_BYTES], usize, bool)>; MAX_WINDOWS] = { 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[1..=len].copy_from_slice(&name[..len]);
label[len + 1] = b' '; label[len + 1] = b' ';
let text = &label[..len + 2]; 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); 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); x = x.saturating_add(text.len() as u16 * font::WIDTH + font::WIDTH);
} }
let user = &self.user[..self.user_len]; let user = &self.user[..self.user_len];
let at = width.raw().saturating_sub((user.len() as u16 + 1) * font::WIDTH); 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. /// 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::OK, 0, 0, 0], None)
} }
(w::SUBSCRIBE, Some(notify)) if self.windows.subscribe(badge, notify) => ([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::POLL, _) => (self.windows.poll(badge).unwrap_or([0; 4]), None),
(w::CLOSE, _) => { (w::CLOSE, _) => {
self.close(index); self.close(index);
@@ -251,6 +281,18 @@ impl<F: Framebuffer, H: Host> Server for DisplayServer<F, H> {
self.repaint(); self.repaint();
([w::OK, 0, 0, 0], None) ([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 => { w::FORGET if badge == 0 => {
let mut gone = [None; MAX_WINDOWS]; let mut gone = [None; MAX_WINDOWS];
for (slot, index) in gone.iter_mut().zip(self.windows.opened_by(words[1])) { 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>; type Srv = DisplayServer<GridFramebuffer<W, H>, FakeHost>;
fn server() -> Srv { 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. /// 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_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"); 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 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. /// The window `badge`'s next event.
pub fn poll(&mut self, badge: usize) -> Option<[usize; 4]> { pub fn poll(&mut self, badge: usize) -> Option<[usize; 4]> {
let i = self.slot(badge)?; let i = self.slot(badge)?;

View File

@@ -1,6 +1,7 @@
//! The encrypted areas (see the vision's Encryption and Users notes), over //! The encrypted areas (see the vision's Encryption and Users notes), over
//! any file system. Each user's folder `users/<id>/` holds their areas: //! any file system. Each user's folder `users/<id>/` holds their areas:
//! `home/`, `permissions/`, and per app `apps/<app>/` and `data/<app>/`, //! `home/`, `permissions/`, `settings/` (init's record of their choices,
//! such as the theme), and per app `apps/<app>/` and `data/<app>/`,
//! each kept as `filecrypt` files under its own key, derived from that //! 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 //! 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, //! 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 upto = |n: usize| &path[..n];
let base = 6 + user.len() + 1; let base = 6 + user.len() + 1;
let area = match (kind, app, rest) { 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())), (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, _ => Area::Neither,
}; };

View File

@@ -154,6 +154,7 @@ fn areas_are_told_by_path() {
(&b"users/u1/home/readme.txt"[..], "sealed"), (&b"users/u1/home/readme.txt"[..], "sealed"),
(b"/users/u1/home", "sealed"), (b"/users/u1/home", "sealed"),
(b"users/u1/permissions/kos.snake", "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/data/kos.notes/a", "sealed"),
(b"users/u1/apps/kos.notes/notes", "sealed"), (b"users/u1/apps/kos.notes/notes", "sealed"),
(b"users/u1/data/kos.notes", "neither"), (b"users/u1/data/kos.notes", "neither"),

9
crates/theme/Cargo.toml Normal file
View File

@@ -0,0 +1,9 @@
[package]
name = "theme"
version = "0.1.0"
edition = "2024"
[dependencies]
types = { workspace = true }
manifest = { workspace = true }
libm = "0.2"

View File

@@ -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<Theme, Error> {
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)
}

202
crates/theme/src/lib.rs Normal file
View File

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

126
crates/theme/src/parse.rs Normal file
View File

@@ -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<Rgba> {
manifest::Accent::parse(s).map(|a| Rgba::rgb(a.0))
}
fn once<T>(slot: &mut Option<T>, value: Option<T>, 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<Theme, Error> {
let mut name: Option<([u8; NAME_BYTES], usize)> = None;
let mut colours: [Option<Rgba>; 10] = [None; 10];
let mut orbs: Option<[Rgba; 3]> = None;
let mut numbers: [Option<u32>; 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,
})
}

53
crates/theme/src/tests.rs Normal file
View File

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

73
crates/theme/src/wire.rs Normal file
View File

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

View File

@@ -94,6 +94,24 @@ pub mod window {
/// From init (unbadged) only: word 1 a launcher's badge, which has /// From init (unbadged) only: word 1 a launcher's badge, which has
/// gone; every window it opened closes. /// gone; every window it opened closes.
pub const FORGET: usize = 8; 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 /// The function key that brings the next window to the front; the
/// display keeps it to itself. /// display keeps it to itself.
pub const SWITCH_KEY: usize = 1; pub const SWITCH_KEY: usize = 1;

View File

@@ -23,6 +23,10 @@ pub const SIGN_OUT: usize = 1;
/// Through the same copy: who is signed in. Reply `[role, name...]`, the /// Through the same copy: who is signed in. Reply `[role, name...]`, the
/// name in three words (`types::name`); role as in `role`. /// name in three words (`types::name`); role as in `role`.
pub const WHO: usize = 2; 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 mod role {
pub const OWNER: usize = 1; pub const OWNER: usize = 1;

View File

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

View File

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

View File

@@ -37,9 +37,11 @@ declares `display`. Nothing gets anything "just in case".
apps/<id>/ an app they installed apps/<id>/ an app they installed
data/<id>/ an app's private folder (its `storage`, its own key) data/<id>/ an app's private folder (its `storage`, its own key)
permissions/<id> their decisions, one file per app id permissions/<id> 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) /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 /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 Users each decide for themselves, even about the system's apps, and what

View File

@@ -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 it says so (`FORGET`, through its printer thread so it never waits on the
display), and that program's windows close. 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/<id>/settings/theme`.
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
when signalled, keeps F1, and queues the rest for the window in front, when signalled, keeps F1, and queues the rest for the window in front,

View File

@@ -39,7 +39,7 @@ staged() {
# Each user's folder in users/ is encrypted, so not filled here: init seeds # 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. # 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. # System servers and the shell: init starts these.
copy display-qemu display copy display-qemu display
copy input-qemu input copy input-qemu input
@@ -59,6 +59,7 @@ app settings
staged counter inbox/counter staged counter inbox/counter
# A user's first files. # A user's first files.
cp -r disk/home/. "${ROOT}/factory/home/" 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. # Only the test image: its presence makes init run the self-test.
if [ -n "${SMPTEST:-}" ]; then if [ -n "${SMPTEST:-}" ]; then
copy smptest smptest copy smptest smptest