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:
@@ -9,6 +9,7 @@ server-framework = { workspace = true }
|
||||
libkos = { workspace = true }
|
||||
port-framebuffer = { workspace = true }
|
||||
raster = { workspace = true }
|
||||
theme = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
driver-framebuffer-mock = { workspace = true }
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
pub mod host;
|
||||
pub mod server;
|
||||
pub mod themes;
|
||||
pub mod windows;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! it, copied from that window's pixel buffer as its app presents changes.
|
||||
//! Apps draw only into their own buffer; the bar is the display's, so no
|
||||
//! app can pose as another. The kernel side (buffers, capabilities, the
|
||||
//! input server) is behind `Host`.
|
||||
//! input server) is behind `Host`. The bar wears the theme in use, which
|
||||
//! the display also hands every window (see `themes`).
|
||||
|
||||
use libkos::syscall::Give;
|
||||
use port_framebuffer::Framebuffer;
|
||||
@@ -14,14 +15,11 @@ use types::bounded::CapSlot;
|
||||
use types::display::window::{self as w, NAME_BYTES};
|
||||
use types::display::{Color, Dimension, ScreenCoord};
|
||||
|
||||
use crate::themes::Themes;
|
||||
use crate::windows::{MAX_WINDOWS, Windows};
|
||||
|
||||
/// The OS bar: one row of text.
|
||||
pub const BAR_HEIGHT: u16 = font::HEIGHT;
|
||||
const BAR_BG: Color = Color::rgb(24, 24, 40);
|
||||
const BAR_FG: Color = Color::rgb(170, 170, 190);
|
||||
const FRONT_BG: Color = Color::rgb(90, 90, 150);
|
||||
const FRONT_FG: Color = Color::rgb(255, 255, 255);
|
||||
/// Pixels copied per row at a time.
|
||||
const RUN: usize = 256;
|
||||
|
||||
@@ -69,11 +67,16 @@ pub struct DisplayServer<F: Framebuffer, H: Host> {
|
||||
caps: [Option<CapSlot>; MAX_WINDOWS],
|
||||
user: [u8; NAME_BYTES],
|
||||
user_len: usize,
|
||||
themes: Themes,
|
||||
}
|
||||
|
||||
fn colour(c: theme::Rgba) -> Color {
|
||||
Color::rgb(c.0, c.1, c.2)
|
||||
}
|
||||
|
||||
impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
|
||||
pub fn new(fb: F, host: H) -> Self {
|
||||
let mut s = Self { fb, host, windows: Windows::new(), caps: [None; MAX_WINDOWS], user: [0; NAME_BYTES], user_len: 0 };
|
||||
pub fn new(fb: F, host: H, themes: Themes) -> Self {
|
||||
let mut s = Self { fb, host, windows: Windows::new(), caps: [None; MAX_WINDOWS], user: [0; NAME_BYTES], user_len: 0, themes };
|
||||
s.repaint();
|
||||
s
|
||||
}
|
||||
@@ -158,7 +161,8 @@ impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
|
||||
match self.windows.front() {
|
||||
Some(index) => self.present(index, ScreenCoord::new(0), ScreenCoord::new(0), width, height),
|
||||
None => {
|
||||
self.fb.fill_rect(ScreenCoord::new(0), ScreenCoord::new(BAR_HEIGHT), width, height, Color::rgb(30, 30, 60));
|
||||
let bg = colour(self.themes.active().background);
|
||||
self.fb.fill_rect(ScreenCoord::new(0), ScreenCoord::new(BAR_HEIGHT), width, height, bg);
|
||||
self.fb.flush();
|
||||
}
|
||||
}
|
||||
@@ -167,8 +171,10 @@ impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
|
||||
/// Each window's name, the front one highlighted; who is signed in at
|
||||
/// the right.
|
||||
fn draw_bar(&mut self) {
|
||||
let t = self.themes.active();
|
||||
let (bar_bg, bar_fg, front_bg, front_fg) = (colour(t.surface), colour(t.text_secondary), colour(t.accent), colour(t.on_accent));
|
||||
let width = self.fb.width();
|
||||
self.fb.fill_rect(ScreenCoord::new(0), ScreenCoord::new(0), width, Dimension::new(BAR_HEIGHT), BAR_BG);
|
||||
self.fb.fill_rect(ScreenCoord::new(0), ScreenCoord::new(0), width, Dimension::new(BAR_HEIGHT), bar_bg);
|
||||
let mut x = 0u16;
|
||||
let mut label = [b' '; NAME_BYTES + 2];
|
||||
let entries: [Option<([u8; NAME_BYTES], usize, bool)>; MAX_WINDOWS] = {
|
||||
@@ -184,13 +190,26 @@ impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
|
||||
label[1..=len].copy_from_slice(&name[..len]);
|
||||
label[len + 1] = b' ';
|
||||
let text = &label[..len + 2];
|
||||
let (fg, bg) = if front { (FRONT_FG, FRONT_BG) } else { (BAR_FG, BAR_BG) };
|
||||
let (fg, bg) = if front { (front_fg, front_bg) } else { (bar_fg, bar_bg) };
|
||||
raster::draw_text(&mut self.fb, ScreenCoord::new(x), ScreenCoord::new(0), fg, bg, text);
|
||||
x = x.saturating_add(text.len() as u16 * font::WIDTH + font::WIDTH);
|
||||
}
|
||||
let user = &self.user[..self.user_len];
|
||||
let at = width.raw().saturating_sub((user.len() as u16 + 1) * font::WIDTH);
|
||||
raster::draw_text(&mut self.fb, ScreenCoord::new(at), ScreenCoord::new(0), BAR_FG, BAR_BG, user);
|
||||
raster::draw_text(&mut self.fb, ScreenCoord::new(at), ScreenCoord::new(0), bar_fg, bar_bg, user);
|
||||
}
|
||||
|
||||
/// Every window is told to ask for the theme again; the bar wears it.
|
||||
fn theme_changed(&mut self) {
|
||||
let mut told = [None; MAX_WINDOWS];
|
||||
for (slot, notify) in told.iter_mut().zip(self.windows.push_all([w::THEME_CHANGED, 0, 0, 0])) {
|
||||
*slot = Some(notify);
|
||||
}
|
||||
for notify in told.into_iter().flatten() {
|
||||
self.host.signal(notify);
|
||||
}
|
||||
self.draw_bar();
|
||||
self.fb.flush();
|
||||
}
|
||||
|
||||
/// A request through window `index`'s capability.
|
||||
@@ -209,6 +228,17 @@ impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
|
||||
([w::OK, 0, 0, 0], None)
|
||||
}
|
||||
(w::SUBSCRIBE, Some(notify)) if self.windows.subscribe(badge, notify) => ([w::OK, 0, 0, 0], None),
|
||||
(w::THEME, _) => match self.themes.part(words[1]) {
|
||||
Some([a, b, c]) => ([w::OK, a, b, c], None),
|
||||
None => ([MALFORMED, 0, 0, 0], None),
|
||||
},
|
||||
(w::THEME_NAME, _) => match self.themes.name(words[1]) {
|
||||
Some(name) => {
|
||||
let [a, b, c] = w::pack_name(name);
|
||||
([w::OK, a, b, c], None)
|
||||
}
|
||||
None => ([MALFORMED, 0, 0, 0], None),
|
||||
},
|
||||
(w::POLL, _) => (self.windows.poll(badge).unwrap_or([0; 4]), None),
|
||||
(w::CLOSE, _) => {
|
||||
self.close(index);
|
||||
@@ -251,6 +281,18 @@ impl<F: Framebuffer, H: Host> Server for DisplayServer<F, H> {
|
||||
self.repaint();
|
||||
([w::OK, 0, 0, 0], None)
|
||||
}
|
||||
w::SET_THEME if badge == 0 => {
|
||||
let (name, len) = w::unpack_name([words[1], words[2], words[3]]);
|
||||
match self.themes.select(&name[..len]) {
|
||||
Some(changed) => {
|
||||
if changed {
|
||||
self.theme_changed();
|
||||
}
|
||||
([w::OK, 0, 0, 0], None)
|
||||
}
|
||||
None => ([MALFORMED, 0, 0, 0], None),
|
||||
}
|
||||
}
|
||||
w::FORGET if badge == 0 => {
|
||||
let mut gone = [None; MAX_WINDOWS];
|
||||
for (slot, index) in gone.iter_mut().zip(self.windows.opened_by(words[1])) {
|
||||
|
||||
@@ -61,7 +61,7 @@ impl Host for FakeHost {
|
||||
type Srv = DisplayServer<GridFramebuffer<W, H>, FakeHost>;
|
||||
|
||||
fn server() -> Srv {
|
||||
DisplayServer::new(GridFramebuffer::new(), FakeHost::default())
|
||||
DisplayServer::new(GridFramebuffer::new(), FakeHost::default(), crate::themes::Themes::new())
|
||||
}
|
||||
|
||||
/// Opens a window through launcher `opener`: its badge.
|
||||
@@ -187,3 +187,48 @@ fn a_present_outside_the_window_is_clipped() {
|
||||
assert_eq!(s.framebuffer().pixel(31, H - 1), Some(RED));
|
||||
assert_ne!(s.framebuffer().pixel(29, H - 1), Some(RED), "left of the rectangle is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_switches_the_theme_and_every_window_hears_of_it() {
|
||||
let light = include_str!("../../../../../disk/themes/whimsy-light.toml");
|
||||
let mut themes = crate::themes::Themes::new();
|
||||
themes.add(light).unwrap();
|
||||
let mut s = DisplayServer::new(GridFramebuffer::new(), FakeHost::default(), themes);
|
||||
let t = open(&mut s, 1, b"Terminal");
|
||||
let snake = open(&mut s, 2, b"Snake");
|
||||
let (n1, n2) = (CapSlot::new(9).unwrap(), CapSlot::new(10).unwrap());
|
||||
s.handle_call(t, [w::SUBSCRIBE, 0, 0, 0], Some(n1));
|
||||
s.handle_call(snake, [w::SUBSCRIBE, 0, 0, 0], Some(n2));
|
||||
let names: Vec<_> = (0..)
|
||||
.map_while(|i| match s.handle_call(t, [w::THEME_NAME, i, 0, 0], None).0 {
|
||||
[w::OK, a, b, c] => {
|
||||
let (n, len) = w::unpack_name([a, b, c]);
|
||||
Some(n[..len].to_vec())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(names, [b"Whimsy Dark".to_vec(), b"Whimsy Light".to_vec()]);
|
||||
|
||||
let read = |s: &mut Srv| -> (usize, theme::Theme) {
|
||||
let mut words = [0; w::THEME_WORDS];
|
||||
let mut head = 0;
|
||||
for part in 0..w::THEME_WORDS / 2 {
|
||||
let r = s.handle_call(snake, [w::THEME, part, 0, 0], None).0;
|
||||
assert_eq!(r[0], w::OK);
|
||||
head = r[1];
|
||||
words[part * 2..part * 2 + 2].copy_from_slice(&r[2..]);
|
||||
}
|
||||
(head >> 32, theme::wire::decode(words))
|
||||
};
|
||||
assert_eq!(read(&mut s).0, 0);
|
||||
let n = w::pack_name(b"Whimsy Light");
|
||||
assert_eq!(s.handle_call(5, [w::SET_THEME, n[0], n[1], n[2]], None).0[0], MALFORMED, "init's alone");
|
||||
assert_eq!(s.handle_call(0, [w::SET_THEME, n[0], n[1], n[2]], None).0[0], w::OK);
|
||||
let (index, t2) = read(&mut s);
|
||||
assert_eq!((index, t2.background), (1, theme::parse(light).unwrap().background));
|
||||
assert!(s.host().signalled.contains(&n1) && s.host().signalled.contains(&n2), "behind or in front");
|
||||
assert_eq!(s.handle_call(t, [w::POLL, 0, 0, 0], None).0[0], w::THEME_CHANGED);
|
||||
let bogus = w::pack_name(b"Nope");
|
||||
assert_eq!(s.handle_call(0, [w::SET_THEME, bogus[0], bogus[1], bogus[2]], None).0[0], MALFORMED);
|
||||
}
|
||||
|
||||
72
crates/servers/display-server/src/themes.rs
Normal file
72
crates/servers/display-server/src/themes.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,14 @@ impl<N: Copy> Windows<N> {
|
||||
w.notify
|
||||
}
|
||||
|
||||
/// Queues an event for every window; whom to tell.
|
||||
pub fn push_all(&mut self, event: [usize; 4]) -> impl Iterator<Item = N> + '_ {
|
||||
self.slots.iter_mut().flatten().filter_map(move |w| {
|
||||
w.queue.push(event);
|
||||
w.notify
|
||||
})
|
||||
}
|
||||
|
||||
/// The window `badge`'s next event.
|
||||
pub fn poll(&mut self, badge: usize) -> Option<[usize; 4]> {
|
||||
let i = self.slot(badge)?;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The encrypted areas (see the vision's Encryption and Users notes), over
|
||||
//! 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
|
||||
//! user's master key. Only the signed-in user's areas open; anyone else's
|
||||
//! are refused (and noise to their key anyway). The rest (programs,
|
||||
@@ -42,7 +43,7 @@ fn area(path: &[u8]) -> (Area<'_>, Option<&[u8]>) {
|
||||
let upto = |n: usize| &path[..n];
|
||||
let base = 6 + user.len() + 1;
|
||||
let area = match (kind, app, rest) {
|
||||
(Some(k @ (b"home" | b"permissions")), _, _) => Area::Sealed(upto(base + k.len())),
|
||||
(Some(k @ (b"home" | b"permissions" | b"settings")), _, _) => Area::Sealed(upto(base + k.len())),
|
||||
(Some(k @ (b"apps" | b"data")), Some(a), Some(r)) if !a.is_empty() && !r.is_empty() => Area::Sealed(upto(base + k.len() + 1 + a.len())),
|
||||
_ => Area::Neither,
|
||||
};
|
||||
|
||||
@@ -154,6 +154,7 @@ fn areas_are_told_by_path() {
|
||||
(&b"users/u1/home/readme.txt"[..], "sealed"),
|
||||
(b"/users/u1/home", "sealed"),
|
||||
(b"users/u1/permissions/kos.snake", "sealed"),
|
||||
(b"users/u1/settings/theme", "sealed"),
|
||||
(b"users/u1/data/kos.notes/a", "sealed"),
|
||||
(b"users/u1/apps/kos.notes/notes", "sealed"),
|
||||
(b"users/u1/data/kos.notes", "neither"),
|
||||
|
||||
Reference in New Issue
Block a user