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:
11
Cargo.lock
generated
11
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
90
crates/init/src/appearance.rs
Normal file
90
crates/init/src/appearance.rs
Normal 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]);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
extern crate alloc;
|
extern crate alloc;
|
||||||
|
|
||||||
|
mod appearance;
|
||||||
mod devices;
|
mod devices;
|
||||||
mod factory;
|
mod factory;
|
||||||
mod launch;
|
mod launch;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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])) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
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
|
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)?;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
9
crates/theme/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "theme"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
types = { workspace = true }
|
||||||
|
manifest = { workspace = true }
|
||||||
|
libm = "0.2"
|
||||||
50
crates/theme/src/contrast.rs
Normal file
50
crates/theme/src/contrast.rs
Normal 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
202
crates/theme/src/lib.rs
Normal 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
126
crates/theme/src/parse.rs
Normal 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
53
crates/theme/src/tests.rs
Normal 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
73
crates/theme/src/wire.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
37
disk/themes/whimsy-dark.toml
Normal file
37
disk/themes/whimsy-dark.toml
Normal 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
|
||||||
34
disk/themes/whimsy-light.toml
Normal file
34
disk/themes/whimsy-light.toml
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
13
docs/ipc.md
13
docs/ipc.md
@@ -186,6 +186,19 @@ opener is remembered: when init sees a program that uses the display go,
|
|||||||
it says so (`FORGET`, through its printer thread so it never waits on the
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user