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:
@@ -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"] }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user