From 29becf297c54aeba846db26b07bc31086281f032 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 28 Oct 2026 13:14:35 +0100 Subject: [PATCH] feat(settings): settings app: apps list + per-capability allow/revoke, about (who/role), sign out; system-only caps permissions/session (install refused); pm MANAGE_BADGE copy (list/apps/allow/revoke only), APPS op; init WHO + session pass-on; factory decisions seeded for new system apps; k-ui list_item/toggle, screens/back/scroll --- Cargo.lock | 10 + Cargo.toml | 1 + Makefile | 2 +- crates/apps/settings/Cargo.toml | 15 ++ crates/apps/settings/manifest.toml | 10 + crates/apps/settings/src/main.rs | 239 ++++++++++++++++++ crates/apps/shell/src/main.rs | 1 + crates/init/src/factory.rs | 18 ++ crates/init/src/launch.rs | 10 +- crates/init/src/manifest.rs | 19 +- crates/init/src/session.rs | 24 +- crates/init/src/spawn.rs | 12 +- crates/k-ui/src/element.rs | 65 +++++ crates/k-ui/src/layout.rs | 21 +- crates/k-ui/src/lib.rs | 2 +- crates/k-ui/src/render.rs | 53 +++- crates/k-ui/src/run.rs | 21 +- crates/k-ui/src/tests.rs | 89 ++++++- crates/k-ui/src/ui.rs | 96 +++++-- crates/libkos/src/apps.rs | 43 ++++ crates/libkos/src/lib.rs | 1 + crates/libkos/src/session.rs | 33 +++ crates/libkos/src/start.rs | 12 + crates/manifest/src/capability.rs | 9 + crates/servers/permission-manager/src/lib.rs | 5 +- .../servers/permission-manager/src/policy.rs | 6 + .../servers/permission-manager/src/server.rs | 85 ++++++- .../permission-manager/src/tests/mod.rs | 14 + .../permission-manager/src/tests/policy.rs | 13 + crates/types/src/apps.rs | 11 + crates/types/src/display.rs | 22 +- crates/types/src/lib.rs | 1 + crates/types/src/name.rs | 23 ++ crates/types/src/spawn.rs | 9 + crates/types/src/start_arg.rs | 12 + docs/apps.md | 19 +- docs/ipc.md | 5 +- scripts/make-disk-image.sh | 1 + 38 files changed, 947 insertions(+), 85 deletions(-) create mode 100644 crates/apps/settings/Cargo.toml create mode 100644 crates/apps/settings/manifest.toml create mode 100644 crates/apps/settings/src/main.rs create mode 100644 crates/libkos/src/session.rs create mode 100644 crates/types/src/name.rs diff --git a/Cargo.lock b/Cargo.lock index 04ebf77..5498647 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -644,6 +644,16 @@ dependencies = [ "types", ] +[[package]] +name = "settings" +version = "0.1.0" +dependencies = [ + "k-ui", + "libkos", + "manifest", + "types", +] + [[package]] name = "shell" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 0ec0c8b..62982fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/k-ui", "crates/apps/flaky", "crates/apps/tally", + "crates/apps/settings", "crates/apps/hello", "crates/apps/shell", "crates/apps/cat", diff --git a/Makefile b/Makefile index e6e1b64..09c48a9 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,7 @@ kani: kani-types kani-domain kani-kernel PROGRAMS = -p init -p echo -p sleep -p snake \ -p display-qemu -p input-qemu -p storage-qemu \ -p log-server -p permission-manager -p session-server -p smptest -p spawntest -p hello \ - -p terminal-server -p shell -p cat -p ls -p counter -p gatekeeper -p flaky -p tally + -p terminal-server -p shell -p cat -p ls -p counter -p gatekeeper -p flaky -p tally -p settings # Programs first: the kernel image packs init and storage (see the # adapter's build.rs). diff --git a/crates/apps/settings/Cargo.toml b/crates/apps/settings/Cargo.toml new file mode 100644 index 0000000..30d4d12 --- /dev/null +++ b/crates/apps/settings/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "settings" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "settings" +test = false +bench = false + +[dependencies] +types = { workspace = true } +libkos = { workspace = true } +k-ui = { workspace = true } +manifest = { workspace = true } diff --git a/crates/apps/settings/manifest.toml b/crates/apps/settings/manifest.toml new file mode 100644 index 0000000..82390e8 --- /dev/null +++ b/crates/apps/settings/manifest.toml @@ -0,0 +1,10 @@ +[app] +id = "kos.settings" +name = "Settings" +version = "1.0.0" +binary = "settings" +author = "K-OS" +memory = "3 MiB" + +[capabilities] +required = ["display", "permissions", "session"] diff --git a/crates/apps/settings/src/main.rs b/crates/apps/settings/src/main.rs new file mode 100644 index 0000000..b77e3f3 --- /dev/null +++ b/crates/apps/settings/src/main.rs @@ -0,0 +1,239 @@ +//! Settings: what each app may use (allow or revoke, through the +//! permission manager), who is signed in, and signing out. A system app: +//! `permissions` and `session` are only ever granted to apps on the system +//! image. + +#![no_std] +#![forbid(unsafe_code)] +#![no_main] + +extern crate alloc; +extern crate libkos; + +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use k_ui::{App, ButtonVariant, Element, Spacing, TextColour, TextStyle, button, column, list_item, text, toggle}; +use libkos::apps::Apps; +use libkos::session::Role; +use manifest::{Capabilities, Capability}; +use types::address::VirtualAddress; + +/// Where the request page for the permission manager is mapped. +const REQUEST_PAGE_VA: VirtualAddress = VirtualAddress::from_raw(0x0190_0000); +const LISTING: usize = 4096; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Screen { + Home, + Apps, + /// One app, by its place in the list. + App(usize), + About, +} + +#[derive(Clone, Copy)] +enum Event { + Show(Screen), + /// Allow or revoke this capability of the app on screen. + Flip(Capability), + SignOut, +} + +struct AppRow { + id: String, + name: String, + required: Capabilities, + requested: Capabilities, + granted: Capabilities, +} + +struct Settings { + screen: Screen, + apps: Option, + rows: Vec, + /// Who is signed in, and how. + who: Option<(String, Role)>, + /// What went wrong last, shown until the next change. + trouble: Option, +} + +/// What a capability lets an app do, as the user reads it. +fn describe(c: Capability) -> &'static str { + match c { + Capability::Display => "A window, and the keyboard while it is in front", + Capability::Storage => "A folder of its own", + Capability::Permissions => "Change what other apps may use", + Capability::Session => "See who is signed in, and sign out", + Capability::Network => "The network", + _ => c.name(), + } +} + +impl Settings { + fn reload(&mut self) { + self.rows.clear(); + let Some(apps) = &self.apps else { return }; + let mut buf = [0u8; LISTING]; + let Ok(n) = apps.apps(&mut buf) else { + self.trouble = Some("The permission manager did not answer.".to_string()); + return; + }; + let text = core::str::from_utf8(&buf[..n]).unwrap_or(""); + for e in libkos::apps::entries(text) { + let required = Capabilities::from_bits(e.required); + self.rows.push(AppRow { + id: e.id.to_string(), + name: e.name.to_string(), + required, + requested: required.union(Capabilities::from_bits(e.optional)), + granted: Capabilities::from_bits(e.granted), + }); + } + } + + fn flip(&mut self, index: usize, cap: Capability) { + let (Some(apps), Some(row)) = (&self.apps, self.rows.get(index)) else { return }; + let change = if row.granted.contains(cap) { apps.revoke(&row.id, cap.index()) } else { apps.allow(&row.id, cap.index()) }; + self.trouble = change.err().map(|r| format!("That did not change (code {}).", r.code)); + self.reload(); + } + + fn heading(title: &str) -> k_ui::element::FlexBuilder { + column().padding(Spacing::Xl).gap(Spacing::Md).child(text(title).style(TextStyle::Heading)) + } + + fn hint(view: k_ui::element::FlexBuilder, s: &str) -> k_ui::element::FlexBuilder { + view.child(text(s).style(TextStyle::Caption).colour(TextColour::Secondary)) + } + + fn home(&self) -> Element { + Self::heading("Settings") + .child(list_item("Apps", Event::Show(Screen::Apps)).subtitle("What each app may use")) + .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)) + .into() + } + + fn app_list(&self) -> Element { + let rows = self.rows.iter().enumerate().map(|(i, r)| { + let allowed = r.granted.intersect(r.requested).iter().count(); + let asked = r.requested.iter().count(); + list_item(r.name.clone(), Event::Show(Screen::App(i))).subtitle(format!("{} ยท {} of {} allowed", r.id, allowed, asked)) + }); + let view = Self::hint(Self::heading("Apps"), "Esc goes back").children(rows); + self.with_trouble(view).into() + } + + fn app(&self, index: usize) -> Element { + let Some(r) = self.rows.get(index) else { return self.app_list() }; + let mut view = Self::hint(Self::heading(&r.name), &r.id); + if r.requested.is_empty() { + view = Self::hint(view, "It asks for nothing."); + } + for cap in r.requested.iter() { + let label = match r.required.contains(cap) { + true => format!("{} (needed to run)", describe(cap)), + false => describe(cap).to_string(), + }; + view = view.child(toggle(label, r.granted.contains(cap), Event::Flip(cap))); + } + if !r.requested.is_empty() { + view = Self::hint(view, "Taking something away from a running app stops it."); + } + self.with_trouble(view).into() + } + + fn about(&self) -> Element { + let (name, role) = match &self.who { + Some((n, Role::Owner)) => (n.as_str(), "an owner of this device"), + Some((n, Role::Standard)) => (n.as_str(), "a user of this device"), + Some((_, Role::Guest)) => ("Guest", "a guest: nothing is kept"), + None => ("?", "unknown"), + }; + Self::hint(Self::heading("About"), "Esc goes back") + .child(text(format!("Signed in as {name}")).style(TextStyle::Subheading)) + .child(text(format!("You are {role}."))) + .child(text(format!("K-OS {}", env!("CARGO_PKG_VERSION")))) + .child(text("RISC-V, on QEMU's virt board").colour(TextColour::Secondary)) + .into() + } + + fn with_trouble(&self, view: k_ui::element::FlexBuilder) -> k_ui::element::FlexBuilder { + match &self.trouble { + Some(t) => view.child(text(t.clone()).colour(TextColour::Accent)), + None => view, + } + } +} + +impl App for Settings { + type Event = Event; + + fn init() -> Self { + let apps = libkos::start::permissions().and_then(|ep| Apps::connect(ep, REQUEST_PAGE_VA)); + let mut name = [0u8; types::name::BYTES]; + let who = libkos::start::session() + .and_then(|s| libkos::session::who(s, &mut name)) + .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 }; + s.reload(); + s + } + + fn update(&mut self, event: Event) { + match event { + Event::Show(screen) => { + self.trouble = None; + if screen == Screen::Apps { + self.reload(); + } + self.screen = screen; + } + Event::Flip(cap) => { + if let Screen::App(i) = self.screen { + self.flip(i, cap); + } + } + Event::SignOut => { + if let Some(s) = libkos::start::session() { + libkos::session::sign_out(s); + } + } + } + } + + fn view(&self) -> Element { + match self.screen { + Screen::Home => self.home(), + Screen::Apps => self.app_list(), + Screen::App(i) => self.app(i), + Screen::About => self.about(), + } + } + + fn back(&mut self) -> bool { + self.trouble = None; + self.screen = match self.screen { + Screen::Home => return false, + Screen::App(_) => Screen::Apps, + Screen::Apps | Screen::About => Screen::Home, + }; + true + } + + fn screen(&self) -> usize { + match self.screen { + Screen::Home => 0, + Screen::Apps => 1, + Screen::About => 2, + Screen::App(i) => 3 + i, + } + } +} + +libkos::entry!(main); + +fn main() { + k_ui::run::(); +} diff --git a/crates/apps/shell/src/main.rs b/crates/apps/shell/src/main.rs index f9b1218..0a0a6c9 100644 --- a/crates/apps/shell/src/main.rs +++ b/crates/apps/shell/src/main.rs @@ -133,6 +133,7 @@ fn refused(what: &str, r: Refused) { reply::BAD_MANIFEST if r.detail > 0 => println!("shell: {}: bad manifest, line {}", what, r.detail), reply::BAD_MANIFEST => println!("shell: {}: bad manifest", what), reply::NOT_REQUESTED => println!("shell: {}: never asked for that", what), + reply::SYSTEM_ONLY => println!("shell: {}: asks for {}, which only system apps may have", what, names(r.detail)), reply::BUSY => println!("shell: no room to run {}", what), _ => println!("shell: {}: failed", what), } diff --git a/crates/init/src/factory.rs b/crates/init/src/factory.rs index c137686..1036b84 100644 --- a/crates/init/src/factory.rs +++ b/crates/init/src/factory.rs @@ -109,6 +109,19 @@ fn copy_tree(fs: &mut Storage, from: &Path, to: &Path, buf: &mut [u8]) -> Result Ok(()) } +/// Copies the files in `from` that `to` lacks (no folders: decisions are +/// one file per app). +fn add_missing(fs: &mut Storage, from: &Path, to: &Path, buf: &mut [u8]) -> Result<(), &'static str> { + let Some((list, n)) = entries(fs, from) else { return Ok(()) }; + for entry in list[..n].iter().filter(|e| !e.is_dir) { + let dst = to.join(entry.name()).ok_or("path too long")?; + if fs.open(dst.as_bytes()).and_then(|h| fs.close(h)).is_err() { + copy_file(fs, &from.join(entry.name()).ok_or("path too long")?, &dst, buf)?; + } + } + Ok(()) +} + /// Readies `folder`'s areas once storage has its key. pub(crate) fn prepare(fs: &mut Storage, folder: types::user::UserFolder, buf: &mut [u8]) -> Result<(), &'static str> { let name = core::str::from_utf8(folder.as_bytes()).map_err(|_| "a user folder named oddly")?; @@ -121,6 +134,11 @@ pub(crate) fn prepare(fs: &mut Storage, folder: types::user::UserFolder, buf: &m for (area, factory) in AREAS { let path = base.join(area.as_bytes()).ok_or("path too long")?; if fs.list_dir(path.as_bytes(), |_| {}).is_ok() { + if area == "permissions" { + // A system app new since this folder was made comes with + // its decision; one the user changed is left as it is. + add_missing(fs, &Path::new(factory), &path, buf)?; + } continue; } fs.mkdir(path.as_bytes()).map_err(|_| "cannot make an area")?; diff --git a/crates/init/src/launch.rs b/crates/init/src/launch.rs index 0521be3..ef35be9 100644 --- a/crates/init/src/launch.rs +++ b/crates/init/src/launch.rs @@ -164,11 +164,15 @@ impl<'a> Launcher<'a> { let control = crate::devices::system_control().ok_or("no SystemControl to give")?; syscall::delegate(control, child, perms(&[Permission::Execute])).ok_or("cannot give SystemControl")?; } - if p.signs_out { + if p.session != crate::manifest::Session::No { let badge = Badge::new(types::spawn::SESSION_BADGE); - // Delegate only to hand it over: the shell's copy cannot pass it on. let copy = syscall::mint(self.spawn_ep, badge, perms(&[Permission::Execute, Permission::Delegate])).map_err(|_| "cannot mint")?; - let given = syscall::delegate(copy, child, perms(&[Permission::Execute])); + // Only a program that passes it on may. + let rights = match p.session { + crate::manifest::Session::PassOn => perms(&[Permission::Execute, Permission::Delegate]), + _ => perms(&[Permission::Execute]), + }; + let given = syscall::delegate(copy, child, rights); syscall::drop_cap(copy); given.ok_or("cannot give signing out")?; } diff --git a/crates/init/src/manifest.rs b/crates/init/src/manifest.rs index 700c567..3172a7b 100644 --- a/crates/init/src/manifest.rs +++ b/crates/init/src/manifest.rs @@ -2,6 +2,16 @@ use types::budget::Limits; +/// Whether a program holds the session: who is signed in, signing out. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Session { + No, + /// For itself only (the shell). + Own, + /// To pass on to apps granted `session` (the permission manager). + PassOn, +} + /// A board device init hands to a program over its endpoint. #[derive(Clone, Copy, PartialEq, Eq)] pub enum Device { @@ -49,8 +59,8 @@ pub struct Program { pub spawns: Option, /// May halt the machine. pub system_control: bool, - /// May sign the user out (`types::spawn::SIGN_OUT`), in the next slot. - pub signs_out: bool, + /// The session (`types::spawn::SIGN_OUT`, `WHO`), in the next slot. + pub session: Session, /// Takes Ctrl+C: a terminal copy with `types::terminal::CONTROL`, last. pub takes_ctrl_c: bool, /// Started again if it dies (see init's supervise.rs). @@ -84,7 +94,7 @@ const fn server(name: &'static str, file: &'static [u8], limits: Limits) -> Prog console: false, spawns: None, system_control: false, - signs_out: false, + session: Session::No, takes_ctrl_c: false, restart: true, restarts_clients: false, @@ -141,6 +151,7 @@ pub const SESSION: &[Program] = &[Program { spawns: Some(Limits { pages: 2048, endpoints: 8, notifications: 8, regions: 8 }), // The shell holds handles to the apps it ran, which die with it. restarts_clients: true, + session: Session::PassOn, // Installing waits for the user's consent. pinged: false, ..server("permission", b"bin/permission", Limits { pages: 96, ..SMALL }) @@ -167,7 +178,7 @@ pub const DESKTOP: &[Program] = &[Program { serves: false, uses: &["terminal", "permission"], storage: &[users(b"home", true)], - signs_out: true, + session: Session::Own, takes_ctrl_c: true, ..server("shell", b"bin/shell", SMALL) }]; diff --git a/crates/init/src/session.rs b/crates/init/src/session.rs index d9ade65..bb8b4ee 100644 --- a/crates/init/src/session.rs +++ b/crates/init/src/session.rs @@ -22,12 +22,25 @@ const LONGEST_NAME: usize = 16; pub(crate) struct User { pub(crate) folder: UserFolder, pub(crate) guest: bool, + name: [u8; LONGEST_NAME], + name_len: usize, + /// As `types::spawn::role` says. + role: usize, +} + +impl User { + /// The reply to `types::spawn::WHO`. + pub(crate) fn who(&self) -> [usize; 4] { + let [a, b, c] = types::name::pack(&self.name[..self.name_len]); + [self.role, a, b, c] + } } /// All the gatekeeper hands over. struct Handover { flags: usize, id: u64, + role: usize, key: [usize; 4], seed: [usize; 4], name: [u8; LONGEST_NAME], @@ -44,10 +57,10 @@ impl Drop for Handover { /// Waits for the gatekeeper behind `ep` to let someone in. fn ask(ep: CapSlot) -> Result { - let [reply::OK, flags, id, _role] = syscall::request(ep, [UNLOCK, 0, 0, 0]).map_err(|_| "the gatekeeper is gone")? else { + let [reply::OK, flags, id, role] = syscall::request(ep, [UNLOCK, 0, 0, 0]).map_err(|_| "the gatekeeper is gone")? else { return Err("the gatekeeper could not unlock"); }; - let mut h = Handover { flags, id: id as u64, key: [0; 4], seed: [0; 4], name: [0; LONGEST_NAME], name_len: 0, removed: [None; MAX_REMOVED] }; + let mut h = Handover { flags, id: id as u64, role, key: [0; 4], seed: [0; 4], name: [0; LONGEST_NAME], name_len: 0, removed: [None; MAX_REMOVED] }; h.key = syscall::request(ep, [KEY, 0, 0, 0]).map_err(|_| "the gatekeeper gave no key")?; h.seed = syscall::request(ep, [SEED, 0, 0, 0]).map_err(|_| "the gatekeeper gave no seed")?; let name = syscall::request(ep, [NAME, 0, 0, 0]).map_err(|_| "the gatekeeper gave no name")?; @@ -111,7 +124,12 @@ pub(crate) fn sign_in(l: &mut Launcher) -> Result<(), &'static str> { if let Some(display) = l.endpoint("display") { crate::output::post(display, libkos::display::set_user_words(name.as_bytes())); } - l.user = Some(User { folder, guest }); + let role = match (guest, h.role) { + (true, _) => types::spawn::role::GUEST, + (_, unlocked::OWNER) => types::spawn::role::OWNER, + _ => types::spawn::role::STANDARD, + }; + l.user = Some(User { folder, guest, name: h.name, name_len: h.name_len, role }); start_session(l); Ok(()) } diff --git a/crates/init/src/spawn.rs b/crates/init/src/spawn.rs index ea08e95..88d94a1 100644 --- a/crates/init/src/spawn.rs +++ b/crates/init/src/spawn.rs @@ -182,10 +182,14 @@ impl Server for Launcher<'_> { fn handle_call(&mut self, badge: usize, words: [usize; 4], cap: Option) -> ([usize; 4], Option) { if badge == types::spawn::SESSION_BADGE { - if words[0] == types::spawn::SIGN_OUT { - crate::session::sign_out(self); - } - return ([MALFORMED, 0, 0, 0], None); + return match words[0] { + types::spawn::SIGN_OUT => { + crate::session::sign_out(self); + ([MALFORMED, 0, 0, 0], None) + } + types::spawn::WHO => (self.user.map_or([MALFORMED, 0, 0, 0], |u| u.who()), None), + _ => ([MALFORMED, 0, 0, 0], None), + }; } match (Request::decode(words), cap) { (Some(r), Some(binary)) => { diff --git a/crates/k-ui/src/element.rs b/crates/k-ui/src/element.rs index 7efa8e8..02bb582 100644 --- a/crates/k-ui/src/element.rs +++ b/crates/k-ui/src/element.rs @@ -44,10 +44,43 @@ pub struct Button { pub on_press: E, } +/// A row of a list: pressing it sends `on_press`. +pub struct Item { + pub title: String, + pub subtitle: Option, + pub on_press: E, +} + +/// A labelled switch; pressing it sends `on_toggle` (the app flips `on`). +pub struct Toggle { + pub label: String, + pub on: bool, + pub on_toggle: E, +} + pub enum Kind { Flex(Flex), Text(Text), Button(Button), + Item(Item), + Toggle(Toggle), +} + +impl Kind { + /// What pressing it sends, if it can be pressed. + pub fn event(&self) -> Option<&E> { + match self { + Kind::Button(b) => Some(&b.on_press), + Kind::Item(i) => Some(&i.on_press), + Kind::Toggle(t) => Some(&t.on_toggle), + Kind::Flex(_) | Kind::Text(_) => None, + } + } + + /// Whether it fills its column across (rows of a list do). + pub fn stretches(&self) -> bool { + matches!(self, Kind::Flex(_) | Kind::Item(_) | Kind::Toggle(_)) + } } pub struct Element { @@ -74,6 +107,19 @@ pub fn button(label: impl Into, on_press: E) -> ButtonBuilder { ButtonBuilder(Button { label: label.into(), variant: ButtonVariant::Secondary, on_press }, Size::Shrink) } +/// A row of a list titled `title`, sending `on_press` when pressed. +pub fn list_item(title: impl Into, on_press: E) -> ItemBuilder { + ItemBuilder(Item { title: title.into(), subtitle: None, on_press }, Size::Shrink) +} + +/// A switch labelled `label`, shown `on` or off, sending `on_toggle` when +/// pressed. +pub fn toggle(label: impl Into, on: bool, on_toggle: E) -> ToggleBuilder { + ToggleBuilder(Toggle { label: label.into(), on, on_toggle }, Size::Shrink) +} + +pub struct ItemBuilder(Item, Size); +pub struct ToggleBuilder(Toggle, Size); pub struct FlexBuilder(Flex, Size); pub struct TextBuilder(Text, Size); pub struct ButtonBuilder(Button, Size); @@ -139,6 +185,25 @@ impl ButtonBuilder { } } +impl ItemBuilder { + pub fn subtitle(mut self, s: impl Into) -> Self { + self.0.subtitle = Some(s.into()); + self + } +} + +impl From> for Element { + fn from(b: ItemBuilder) -> Self { + Element { kind: Kind::Item(b.0), size: b.1 } + } +} + +impl From> for Element { + fn from(b: ToggleBuilder) -> Self { + Element { kind: Kind::Toggle(b.0), size: b.1 } + } +} + impl From> for Element { fn from(b: FlexBuilder) -> Self { Element { kind: Kind::Flex(b.0), size: b.1 } diff --git a/crates/k-ui/src/layout.rs b/crates/k-ui/src/layout.rs index 9c6aeb7..8b28d18 100644 --- a/crates/k-ui/src/layout.rs +++ b/crates/k-ui/src/layout.rs @@ -33,6 +33,14 @@ pub fn button_padding(theme: &Theme) -> (f32, f32) { (theme.space(Spacing::Lg), theme.space(Spacing::Sm)) } +/// A list row's room around its text. +pub fn row_padding(theme: &Theme) -> f32 { + theme.space(Spacing::Md) +} + +/// A toggle's switch: its track's size. +pub const SWITCH: (f32, f32) = (48.0, 28.0); + /// What `e` needs, width and height. pub fn natural(e: &Element, m: &impl Measure, t: &Theme) -> (f32, f32) { match &e.kind { @@ -42,6 +50,17 @@ pub fn natural(e: &Element, m: &impl Measure, t: &Theme) -> (f32, f32) { let (px, py) = button_padding(t); ((w + 2.0 * px).max(MIN_TARGET), (h + 2.0 * py).max(MIN_TARGET)) } + Kind::Item(i) => { + let (tw, th) = m.text(&i.title, TextStyle::Label); + let (sw, sh) = i.subtitle.as_ref().map_or((0.0, 0.0), |s| m.text(s, TextStyle::Caption)); + let pad = 2.0 * row_padding(t); + (tw.max(sw) + pad, (th + sh + pad).max(MIN_TARGET)) + } + Kind::Toggle(g) => { + let (w, h) = m.text(&g.label, TextStyle::Body); + let pad = 2.0 * row_padding(t); + (w + t.space(Spacing::Lg) + SWITCH.0 + pad, (h.max(SWITCH.1) + pad).max(MIN_TARGET)) + } Kind::Flex(f) => { let pad = 2.0 * t.space(f.padding); let gaps = t.space(f.gap) * f.children.len().saturating_sub(1) as f32; @@ -109,7 +128,7 @@ fn place(e: &Element, r: Rect, m: &impl Measure, t: &Theme, out: &mut Vec< Size::Fill => share, Size::Shrink => *main, }; - let cross = if matches!(c.kind, Kind::Flex(_)) { inner_cross } else { cross.min(inner_cross) }; + let cross = if c.kind.stretches() { inner_cross } else { cross.min(inner_cross) }; let (dx, dy) = across(f.axis, at, 0.0); let (w, h) = across(f.axis, main, cross); place(c, Rect { x: inner.x + dx, y: inner.y + dy, w, h }, m, t, out); diff --git a/crates/k-ui/src/lib.rs b/crates/k-ui/src/lib.rs index 7e3ba08..867865a 100644 --- a/crates/k-ui/src/lib.rs +++ b/crates/k-ui/src/lib.rs @@ -16,7 +16,7 @@ pub mod text; pub mod theme; pub mod ui; -pub use element::{Element, Size, button, column, row, text}; +pub use element::{Element, Size, button, column, list_item, row, text, toggle}; pub use run::run; pub use theme::{ButtonVariant, Spacing, TextColour, TextStyle}; pub use ui::App; diff --git a/crates/k-ui/src/render.rs b/crates/k-ui/src/render.rs index 17a63c7..ce710d5 100644 --- a/crates/k-ui/src/render.rs +++ b/crates/k-ui/src/render.rs @@ -106,19 +106,58 @@ impl Painter<'_, '_> { Kind::Text(t) => self.text_in(&t.content, t.style, r, false, theme.text_colour(t.colour)), Kind::Button(b) => { let (fill, label) = theme.button(b.variant); - if let (Some(fill), Some(path)) = (fill, rounded(r, theme.corner_radius)) { - self.pm.fill_path(&path, &paint(fill), FillRule::Winding, Transform::identity(), None); + if let Some(fill) = fill { + self.fill(r, theme.corner_radius, fill); } self.text_in(&b.label, TextStyle::Label, r, true, label); - if self.focused == Some(me) { - let ring = Rect { x: r.x - RING_GAP, y: r.y - RING_GAP, w: r.w + 2.0 * RING_GAP, h: r.h + 2.0 * RING_GAP }; - if let Some(path) = rounded(ring, theme.corner_radius + RING_GAP) { - let stroke = Stroke { width: RING_WIDTH, ..Stroke::default() }; - self.pm.stroke_path(&path, &paint(theme.focus), &stroke, Transform::identity(), None); + } + Kind::Item(i) => { + self.fill(r, theme.corner_radius, theme.surface); + let inner = self.inset(r); + match &i.subtitle { + Some(sub) => { + let half = Rect { h: inner.h / 2.0, ..inner }; + self.text_in(&i.title, TextStyle::Label, half, false, theme.text_primary); + self.text_in(sub, TextStyle::Caption, Rect { y: inner.y + inner.h / 2.0, ..half }, false, theme.text_secondary); } + None => self.text_in(&i.title, TextStyle::Label, inner, false, theme.text_primary), } + // A chevron: there is more behind it. + let chevron = Rect { x: inner.x + inner.w - 12.0, w: 12.0, ..inner }; + self.text_in(">", TextStyle::Label, chevron, true, theme.text_secondary); + } + Kind::Toggle(g) => { + self.fill(r, theme.corner_radius, theme.surface); + let inner = self.inset(r); + self.text_in(&g.label, TextStyle::Body, inner, false, theme.text_primary); + let (tw, th) = crate::layout::SWITCH; + let track = Rect { x: inner.x + inner.w - tw, y: inner.y + (inner.h - th) / 2.0, w: tw, h: th }; + self.fill(track, th / 2.0, if g.on { theme.accent } else { theme.surface_raised }); + let knob = th - 6.0; + let kx = if g.on { track.x + tw - knob - 3.0 } else { track.x + 3.0 }; + self.fill(Rect { x: kx, y: track.y + 3.0, w: knob, h: knob }, knob / 2.0, theme.text_primary); } } + if self.focused == Some(me) && e.kind.event().is_some() { + let ring = Rect { x: r.x - RING_GAP, y: r.y - RING_GAP, w: r.w + 2.0 * RING_GAP, h: r.h + 2.0 * RING_GAP }; + if let Some(path) = rounded(ring, theme.corner_radius + RING_GAP) { + let stroke = Stroke { width: RING_WIDTH, ..Stroke::default() }; + self.pm.stroke_path(&path, &paint(theme.focus), &stroke, Transform::identity(), None); + } + } + } + + /// `r` filled with `c`, its corners rounded by `radius`. + fn fill(&mut self, r: Rect, radius: f32, c: Rgba) { + if let Some(path) = rounded(r, radius) { + self.pm.fill_path(&path, &paint(c), FillRule::Winding, Transform::identity(), None); + } + } + + /// `r` less a list row's padding all round. + fn inset(&self, r: Rect) -> Rect { + let p = crate::layout::row_padding(self.theme); + Rect { x: r.x + p, y: r.y + p, w: (r.w - 2.0 * p).max(0.0), h: (r.h - 2.0 * p).max(0.0) } } } diff --git a/crates/k-ui/src/run.rs b/crates/k-ui/src/run.rs index ab9b6fd..3ce4124 100644 --- a/crates/k-ui/src/run.rs +++ b/crates/k-ui/src/run.rs @@ -1,6 +1,7 @@ //! An app's event loop on K-OS: its window (which the permission manager //! opened, named from its manifest), keys from the display while it is in -//! front, a redraw after every change. Escape leaves. +//! front, a redraw after every change. Escape goes back, and leaves from +//! the first screen. use libkos::display::{self, Canvas}; use libkos::input::{InputEvent, Key as Pressed}; @@ -11,18 +12,19 @@ use types::display::ScreenCoord; use crate::text::Fonts; use crate::theme::Theme; -use crate::ui::{App, Key, Ui}; +use crate::ui::{App, Key, Outcome, Ui}; fn key(k: Pressed) -> Option { match k { Pressed::Tab | Pressed::Down | Pressed::Right => Some(Key::Next), Pressed::Up | Pressed::Left => Some(Key::Previous), Pressed::Enter | Pressed::Space => Some(Key::Press), + Pressed::Escape => Some(Key::Back), _ => None, } } -/// Runs `A` until Escape (or until it is stopped). +/// Runs `A` until it is left (or stopped). pub fn run() -> ! { let window = libkos::start::display(); let Some(mut canvas) = window.and_then(Canvas::attach) else { @@ -41,21 +43,22 @@ pub fn run() -> ! { let mut dirty = true; loop { if dirty { - draw(&ui, &mut canvas, &fonts); + draw(&mut ui, &mut canvas, &fonts); dirty = false; } let _ = syscall::wait(notify); while let Some(event) = display::poll_input(canvas.window()) { - match event { - InputEvent::KeyDown(Pressed::Escape) => syscall::exit(), - InputEvent::KeyDown(k) => dirty |= key(k).is_some_and(|k| ui.key(k)), - InputEvent::KeyUp(_) => {} + let InputEvent::KeyDown(k) = event else { continue }; + match key(k).map(|k| ui.key(k)) { + Some(Outcome::Leave) => syscall::exit(), + Some(Outcome::Changed) => dirty = true, + Some(Outcome::Nothing) | None => {} } } } } -fn draw(ui: &Ui, canvas: &mut Canvas, fonts: &Fonts) { +fn draw(ui: &mut Ui, canvas: &mut Canvas, fonts: &Fonts) { let (w, h) = (canvas.width(), canvas.height()); if let Some(mut pm) = PixmapMut::from_bytes(canvas.pixels_mut(), w.raw() as u32, h.raw() as u32) { ui.draw(&mut pm, fonts); diff --git a/crates/k-ui/src/tests.rs b/crates/k-ui/src/tests.rs index f1b83a9..162243a 100644 --- a/crates/k-ui/src/tests.rs +++ b/crates/k-ui/src/tests.rs @@ -7,7 +7,8 @@ use crate::element::{Element, button, column, row, text}; use crate::layout::{MIN_TARGET, Measure, Rect, layout}; use crate::text::Fonts; use crate::theme::{ButtonVariant, Spacing, TextStyle, Theme}; -use crate::ui::{App, Key, Ui}; +use crate::element::{list_item, toggle}; +use crate::ui::{App, Key, Outcome, Ui}; /// Every character 10 wide, every line 20 high. struct Fixed; @@ -80,17 +81,17 @@ impl App for Counter { #[test] fn keys_move_focus_and_press_the_focused_button() { let mut ui = Ui::::new(T); - assert!(ui.key(Key::Press), "Add has focus first"); - assert!(ui.key(Key::Press)); + assert_eq!(ui.key(Key::Press), Outcome::Changed, "Add has focus first"); + assert_eq!(ui.key(Key::Press), Outcome::Changed); assert_eq!(ui.app.0, 2); - assert!(ui.key(Key::Next)); - assert!(ui.key(Key::Press), "Reset"); + assert_eq!(ui.key(Key::Next), Outcome::Changed); + assert_eq!(ui.key(Key::Press), Outcome::Changed, "Reset"); assert_eq!(ui.app.0, 0); // Reset is gone, so focus is back on Add. - assert!(ui.key(Key::Press)); + assert_eq!(ui.key(Key::Press), Outcome::Changed); assert_eq!(ui.app.0, 1); - assert!(ui.key(Key::Previous)); - assert!(ui.key(Key::Press)); + assert_eq!(ui.key(Key::Previous), Outcome::Changed); + assert_eq!(ui.key(Key::Press), Outcome::Changed); assert_eq!(ui.app.0, 0, "Previous wraps round to Reset"); } @@ -103,7 +104,7 @@ fn at(pm: &tiny_skia::Pixmap, x: u32, y: u32) -> (u8, u8, u8) { #[test] fn the_view_is_drawn_in_the_theme_with_a_focus_ring() { let fonts = Fonts::load().expect("the bundled fonts parse"); - let ui = Ui::::new(T); + let mut ui = Ui::::new(T); let mut pm = tiny_skia::Pixmap::new(320, 200).unwrap(); ui.draw(&mut pm.as_mut(), &fonts); let bg = T.background; @@ -122,3 +123,73 @@ fn the_view_is_drawn_in_the_theme_with_a_focus_ring() { let lit = (h.x as u32..(h.x + h.w) as u32).any(|x| (h.y as u32..(h.y + h.h) as u32).any(|y| at(&pm, x, y).0 > 150)); assert!(lit, "the count is drawn"); } + +/// Two screens: a list of rows, and a detail with a toggle. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Nav { + Open(usize), + Flip, +} + +struct Pages { + open: Option, + flag: bool, +} + +impl App for Pages { + type Event = Nav; + fn init() -> Self { + Pages { open: None, flag: false } + } + fn update(&mut self, e: Nav) { + match e { + Nav::Open(i) => self.open = Some(i), + Nav::Flip => self.flag = !self.flag, + } + } + fn view(&self) -> Element