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

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

View File

@@ -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<A: App>() -> ! {
libkos::println!("k-ui: no input");
syscall::exit();
}
let mut ui = Ui::<A>::new(&Theme::WHIMSY_DARK);
let mut ui = Ui::<A>::new(theme(canvas.window()));
let mut dirty = true;
loop {
if dirty {
@@ -47,8 +48,16 @@ pub fn run<A: App>() -> ! {
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<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) {
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) {

View File

@@ -80,7 +80,7 @@ impl App for Counter {
#[test]
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);
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::<Counter>::new(T);
let mut ui = Ui::<Counter>::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::<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::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::<Pages>::new(T);
let mut ui = Ui::<Pages>::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");

View File

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

View File

@@ -59,7 +59,7 @@ pub struct Ui<A: App> {
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: Clone>(e: &Element<E>, index: &mut usize, target: usize) -> Optio
}
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 }
}
/// 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> {
let mut out = Vec::new();
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) {
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<A: App> Ui<A> {
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);
}
}