diff --git a/Cargo.lock b/Cargo.lock index 6e1d77e..04ebf77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,6 +31,12 @@ dependencies = [ name = "alarm-server" version = "0.1.0" +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "app-image" version = "0.1.0" @@ -49,6 +55,18 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "audio-capture-server" version = "0.1.0" @@ -85,6 +103,12 @@ dependencies = [ name = "bluetooth-server" version = "0.1.0" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "calendar-server" version = "0.1.0" @@ -150,6 +174,15 @@ version = "0.1.0" name = "contacts-server" version = "0.1.0" +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "counter" version = "0.1.0" @@ -288,6 +321,12 @@ dependencies = [ name = "elf" version = "0.1.0" +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + [[package]] name = "filecrypt" version = "0.1.0" @@ -307,6 +346,22 @@ dependencies = [ "types", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fontdue" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e796cb6894e6418daacea84b28dd4f98d3b32c74d5bb452ba1ecfbf5ad8e84ca" +dependencies = [ + "hashbrown", + "ttf-parser", +] + [[package]] name = "gatekeeper" version = "0.1.0" @@ -335,6 +390,17 @@ dependencies = [ name = "gps-server" version = "0.1.0" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hello" version = "0.1.0" @@ -393,6 +459,18 @@ dependencies = [ "types", ] +[[package]] +name = "k-ui" +version = "0.1.0" +dependencies = [ + "fontdue", + "libkos", + "libm", + "port-framebuffer", + "tiny-skia", + "types", +] + [[package]] name = "kernel" version = "0.1.0" @@ -430,6 +508,18 @@ dependencies = [ "types", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + [[package]] name = "log-server" version = "0.1.0" @@ -618,6 +708,12 @@ dependencies = [ "types", ] +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + [[package]] name = "subtle" version = "2.6.1" @@ -628,6 +724,14 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" name = "sync-server" version = "0.1.0" +[[package]] +name = "tally" +version = "0.1.0" +dependencies = [ + "k-ui", + "libkos", +] + [[package]] name = "telephony-server" version = "0.1.0" @@ -643,6 +747,41 @@ dependencies = [ "types", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "libm", + "strict-num", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + [[package]] name = "typenum" version = "1.20.1" diff --git a/Cargo.toml b/Cargo.toml index c2fb166..0ec0c8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,9 @@ members = [ "crates/adapter-riscv", "crates/raster", + "crates/k-ui", "crates/apps/flaky", + "crates/apps/tally", "crates/apps/hello", "crates/apps/shell", "crates/apps/cat", @@ -129,6 +131,7 @@ libkos = { path = "crates/libkos" } # ports port-framebuffer = { path = "crates/ports/framebuffer" } raster = { path = "crates/raster" } +k-ui = { path = "crates/k-ui" } port-block-device = { path = "crates/ports/block-device" } port-input-device = { path = "crates/ports/input-device" } diff --git a/Makefile b/Makefile index 1c6661a..f4c47ee 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 terminal-server -p shell -p cat -p ls -p counter -p gatekeeper -p flaky -p tally # Programs first: the kernel image packs init and storage (see the # adapter's build.rs). diff --git a/crates/apps/tally/Cargo.toml b/crates/apps/tally/Cargo.toml new file mode 100644 index 0000000..3ee2281 --- /dev/null +++ b/crates/apps/tally/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "tally" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "tally" +test = false +bench = false + +[dependencies] +libkos = { workspace = true } +k-ui = { workspace = true } diff --git a/crates/apps/tally/manifest.toml b/crates/apps/tally/manifest.toml new file mode 100644 index 0000000..6f4be37 --- /dev/null +++ b/crates/apps/tally/manifest.toml @@ -0,0 +1,10 @@ +[app] +id = "kos.tally" +name = "Tally" +version = "1.0.0" +binary = "tally" +author = "K-OS" +memory = "3 MiB" + +[capabilities] +required = ["display"] diff --git a/crates/apps/tally/src/main.rs b/crates/apps/tally/src/main.rs new file mode 100644 index 0000000..9f2abcf --- /dev/null +++ b/crates/apps/tally/src/main.rs @@ -0,0 +1,61 @@ +//! A count, and buttons to change it: the first K-UI app. Tab and the +//! arrows move between the buttons, Enter presses, Escape leaves. + +#![no_std] +#![forbid(unsafe_code)] +#![no_main] + +extern crate alloc; +extern crate libkos; + +use alloc::format; +use k_ui::{App, ButtonVariant, Element, Spacing, TextColour, TextStyle, button, column, row, text}; + +#[derive(Clone, Copy)] +enum Event { + Add, + Subtract, + Reset, +} + +struct Tally(i64); + +impl App for Tally { + type Event = Event; + + fn init() -> Self { + Tally(0) + } + + fn update(&mut self, event: Event) { + match event { + Event::Add => self.0 += 1, + Event::Subtract => self.0 -= 1, + Event::Reset => self.0 = 0, + } + } + + fn view(&self) -> Element { + let mut buttons = row() + .gap(Spacing::Md) + .child(button("Subtract", Event::Subtract)) + .child(button("Add", Event::Add).variant(ButtonVariant::Primary)); + if self.0 != 0 { + buttons = buttons.child(button("Reset", Event::Reset).variant(ButtonVariant::Destructive)); + } + column() + .padding(Spacing::Xl) + .gap(Spacing::Lg) + .child(text("Tally").style(TextStyle::Heading)) + .child(text(format!("{}", self.0)).style(TextStyle::Subheading).colour(TextColour::Accent)) + .child(buttons) + .child(text("Tab moves, Enter presses, Esc leaves").style(TextStyle::Caption).colour(TextColour::Secondary)) + .into() + } +} + +libkos::entry!(main); + +fn main() { + k_ui::run::(); +} diff --git a/crates/init/src/manifest.rs b/crates/init/src/manifest.rs index 70fe447..700c567 100644 --- a/crates/init/src/manifest.rs +++ b/crates/init/src/manifest.rs @@ -138,7 +138,7 @@ pub const SESSION: &[Program] = &[Program { uses: &["terminal", "display"], storage: &[users(b"", true), disk(b"apps", false)], // Room for a few apps at a time. - spawns: Some(Limits { pages: 512, endpoints: 8, notifications: 8, regions: 8 }), + 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, // Installing waits for the user's consent. diff --git a/crates/k-ui/Cargo.toml b/crates/k-ui/Cargo.toml new file mode 100644 index 0000000..a56e27f --- /dev/null +++ b/crates/k-ui/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "k-ui" +version = "0.1.0" +edition = "2024" + +[dependencies] +types = { workspace = true } +libkos = { workspace = true } +port-framebuffer = { workspace = true } +tiny-skia = { version = "0.11", default-features = false, features = ["no-std-float"] } +fontdue = { version = "0.9", default-features = false, features = ["hashbrown"] } +libm = "0.2" diff --git a/crates/k-ui/fonts/LICENSE-noto b/crates/k-ui/fonts/LICENSE-noto new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/crates/k-ui/fonts/LICENSE-noto @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/k-ui/fonts/NotoSans-Regular-Latin.ttf b/crates/k-ui/fonts/NotoSans-Regular-Latin.ttf new file mode 100644 index 0000000..fb7752e Binary files /dev/null and b/crates/k-ui/fonts/NotoSans-Regular-Latin.ttf differ diff --git a/crates/k-ui/fonts/NotoSans-SemiBold-Latin.ttf b/crates/k-ui/fonts/NotoSans-SemiBold-Latin.ttf new file mode 100644 index 0000000..cf7c093 Binary files /dev/null and b/crates/k-ui/fonts/NotoSans-SemiBold-Latin.ttf differ diff --git a/crates/k-ui/src/element.rs b/crates/k-ui/src/element.rs new file mode 100644 index 0000000..7efa8e8 --- /dev/null +++ b/crates/k-ui/src/element.rs @@ -0,0 +1,158 @@ +//! What a view is made of, built with typed builders: `column()`, `row()`, +//! `text(..)`, `button(..)`. Each only offers what makes sense for it +//! (a text has a style, a button a variant); appearance comes from the +//! theme, never from here. + +use alloc::string::String; +use alloc::vec::Vec; + +use crate::theme::{ButtonVariant, Spacing, TextColour, TextStyle}; + +/// How much of its parent's main axis an element takes. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Size { + /// What its content needs. + Shrink, + /// A share of what is left. + Fill, + /// Exactly this many pixels: use sparingly. + Fixed(f32), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Axis { + Horizontal, + Vertical, +} + +pub struct Flex { + pub axis: Axis, + pub gap: Spacing, + pub padding: Spacing, + pub children: Vec>, +} + +pub struct Text { + pub content: String, + pub style: TextStyle, + pub colour: TextColour, +} + +pub struct Button { + pub label: String, + pub variant: ButtonVariant, + pub on_press: E, +} + +pub enum Kind { + Flex(Flex), + Text(Text), + Button(Button), +} + +pub struct Element { + pub kind: Kind, + pub size: Size, +} + +/// Children stacked top to bottom. +pub fn column() -> FlexBuilder { + FlexBuilder(Flex { axis: Axis::Vertical, gap: Spacing::None, padding: Spacing::None, children: Vec::new() }, Size::Shrink) +} + +/// Children side by side. +pub fn row() -> FlexBuilder { + FlexBuilder(Flex { axis: Axis::Horizontal, gap: Spacing::None, padding: Spacing::None, children: Vec::new() }, Size::Shrink) +} + +pub fn text(content: impl Into) -> TextBuilder { + TextBuilder(Text { content: content.into(), style: TextStyle::Body, colour: TextColour::Primary }, Size::Shrink) +} + +/// A button labelled `label` that sends `on_press` when pressed. +pub fn button(label: impl Into, on_press: E) -> ButtonBuilder { + ButtonBuilder(Button { label: label.into(), variant: ButtonVariant::Secondary, on_press }, Size::Shrink) +} + +pub struct FlexBuilder(Flex, Size); +pub struct TextBuilder(Text, Size); +pub struct ButtonBuilder(Button, Size); + +macro_rules! sizing { + () => { + pub fn fill(mut self) -> Self { + self.1 = Size::Fill; + self + } + + pub fn fixed(mut self, px: f32) -> Self { + self.1 = Size::Fixed(px); + self + } + }; +} + +impl FlexBuilder { + sizing!(); + + pub fn gap(mut self, s: Spacing) -> Self { + self.0.gap = s; + self + } + + pub fn padding(mut self, s: Spacing) -> Self { + self.0.padding = s; + self + } + + pub fn child(mut self, c: impl Into>) -> Self { + self.0.children.push(c.into()); + self + } + + pub fn children>>(mut self, cs: impl IntoIterator) -> Self { + self.0.children.extend(cs.into_iter().map(Into::into)); + self + } +} + +impl TextBuilder { + sizing!(); + + pub fn style(mut self, s: TextStyle) -> Self { + self.0.style = s; + self + } + + pub fn colour(mut self, c: TextColour) -> Self { + self.0.colour = c; + self + } +} + +impl ButtonBuilder { + sizing!(); + + pub fn variant(mut self, v: ButtonVariant) -> Self { + self.0.variant = v; + self + } +} + +impl From> for Element { + fn from(b: FlexBuilder) -> Self { + Element { kind: Kind::Flex(b.0), size: b.1 } + } +} + +impl From for Element { + fn from(b: TextBuilder) -> Self { + Element { kind: Kind::Text(b.0), size: b.1 } + } +} + +impl From> for Element { + fn from(b: ButtonBuilder) -> Self { + Element { kind: Kind::Button(b.0), size: b.1 } + } +} diff --git a/crates/k-ui/src/layout.rs b/crates/k-ui/src/layout.rs new file mode 100644 index 0000000..9c6aeb7 --- /dev/null +++ b/crates/k-ui/src/layout.rs @@ -0,0 +1,118 @@ +//! Where everything goes. A tree is laid out into one rectangle per +//! element, in pre-order (an element, then its children), which is the +//! order the renderer and focus walk it in too. +//! +//! Along a container's main axis children take what their content needs +//! (`Shrink`), exactly their `Fixed` size, or a share of what is left +//! (`Fill`). Across it, containers stretch to fill it and everything else +//! keeps what it needs, from the start. + +use alloc::vec::Vec; + +use crate::element::{Axis, Element, Kind, Size}; +use crate::theme::{Spacing, TextStyle, Theme}; + +/// The smallest a pressable thing may be, either way (touch targets). +pub const MIN_TARGET: f32 = 44.0; + +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub struct Rect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +/// How big text is, from whoever has the fonts. +pub trait Measure { + fn text(&self, s: &str, style: TextStyle) -> (f32, f32); +} + +/// A button's room around its label. +pub fn button_padding(theme: &Theme) -> (f32, f32) { + (theme.space(Spacing::Lg), theme.space(Spacing::Sm)) +} + +/// What `e` needs, width and height. +pub fn natural(e: &Element, m: &impl Measure, t: &Theme) -> (f32, f32) { + match &e.kind { + Kind::Text(text) => m.text(&text.content, text.style), + Kind::Button(b) => { + let (w, h) = m.text(&b.label, TextStyle::Label); + let (px, py) = button_padding(t); + ((w + 2.0 * px).max(MIN_TARGET), (h + 2.0 * py).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; + let (mut main, mut cross) = (0.0f32, 0.0f32); + for c in &f.children { + let (w, h) = natural(c, m, t); + let (cm, cc) = along(f.axis, w, h); + main += match c.size { + Size::Fixed(px) => px, + _ => cm, + }; + cross = cross.max(cc); + } + let (w, h) = across(f.axis, main + gaps, cross); + (w + pad, h + pad) + } + } +} + +/// (main, cross) of a (width, height) along `axis`. +fn along(axis: Axis, w: f32, h: f32) -> (f32, f32) { + match axis { + Axis::Horizontal => (w, h), + Axis::Vertical => (h, w), + } +} + +/// (width, height) of a (main, cross) along `axis`. +fn across(axis: Axis, main: f32, cross: f32) -> (f32, f32) { + along(axis, main, cross) +} + +/// Every element's rectangle, in pre-order, the root filling `area`. +pub fn layout(root: &Element, area: Rect, m: &impl Measure, t: &Theme) -> Vec { + let mut out = Vec::new(); + place(root, area, m, t, &mut out); + out +} + +fn place(e: &Element, r: Rect, m: &impl Measure, t: &Theme, out: &mut Vec) { + out.push(r); + let Kind::Flex(f) = &e.kind else { return }; + let pad = t.space(f.padding); + let inner = Rect { x: r.x + pad, y: r.y + pad, w: (r.w - 2.0 * pad).max(0.0), h: (r.h - 2.0 * pad).max(0.0) }; + let (inner_main, inner_cross) = along(f.axis, inner.w, inner.h); + let gap = t.space(f.gap); + let naturals: Vec<(f32, f32)> = f.children.iter().map(|c| natural(c, m, t)).map(|(w, h)| along(f.axis, w, h)).collect(); + let taken: f32 = f + .children + .iter() + .zip(&naturals) + .map(|(c, (main, _))| match c.size { + Size::Fixed(px) => px, + Size::Fill => 0.0, + Size::Shrink => *main, + }) + .sum::() + + gap * f.children.len().saturating_sub(1) as f32; + let fills = f.children.iter().filter(|c| c.size == Size::Fill).count(); + let share = if fills > 0 { (inner_main - taken).max(0.0) / fills as f32 } else { 0.0 }; + let mut at = 0.0; + for (c, (main, cross)) in f.children.iter().zip(&naturals) { + let main = match c.size { + Size::Fixed(px) => px, + Size::Fill => share, + Size::Shrink => *main, + }; + let cross = if matches!(c.kind, Kind::Flex(_)) { 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); + at += main + gap; + } +} diff --git a/crates/k-ui/src/lib.rs b/crates/k-ui/src/lib.rs new file mode 100644 index 0000000..7e3ba08 --- /dev/null +++ b/crates/k-ui/src/lib.rs @@ -0,0 +1,25 @@ +//! K-UI (see the vision's K-UI note): an app says what its UI is, as a +//! pure function of its state; K-UI lays it out, draws it in the theme, +//! and handles focus and keys. It runs inside the app and draws into the +//! app's window. + +#![no_std] +#![forbid(unsafe_code)] + +extern crate alloc; + +pub mod element; +pub mod layout; +pub mod render; +pub mod run; +pub mod text; +pub mod theme; +pub mod ui; + +pub use element::{Element, Size, button, column, row, text}; +pub use run::run; +pub use theme::{ButtonVariant, Spacing, TextColour, TextStyle}; +pub use ui::App; + +#[cfg(test)] +mod tests; diff --git a/crates/k-ui/src/render.rs b/crates/k-ui/src/render.rs new file mode 100644 index 0000000..17a63c7 --- /dev/null +++ b/crates/k-ui/src/render.rs @@ -0,0 +1,131 @@ +//! Drawing a laid-out tree into a window's pixels (RGBA, premultiplied): +//! the theme decides every colour and shape. + +use tiny_skia::{Color, FillRule, Paint, PathBuilder, PixmapMut, Stroke, Transform}; + +use crate::element::{Element, Kind}; +use crate::layout::Rect; +use crate::text::Fonts; +use crate::theme::{Rgba, TextStyle, Theme, Weight}; + +/// The focus ring's distance outside the button, and its width. +const RING_GAP: f32 = 3.0; +const RING_WIDTH: f32 = 2.0; + +fn paint(c: Rgba) -> Paint<'static> { + let mut p = Paint::default(); + p.set_color(Color::from_rgba8(c.0, c.1, c.2, c.3)); + p.anti_alias = true; + p +} + +/// A rectangle with corners of radius `r` (at most half its height). +fn rounded(rect: Rect, r: f32) -> Option { + let r = r.min(rect.h / 2.0).min(rect.w / 2.0).max(0.0); + let (x0, y0, x1, y1) = (rect.x, rect.y, rect.x + rect.w, rect.y + rect.h); + let mut p = PathBuilder::new(); + p.move_to(x0 + r, y0); + p.line_to(x1 - r, y0); + p.quad_to(x1, y0, x1, y0 + r); + p.line_to(x1, y1 - r); + p.quad_to(x1, y1, x1 - r, y1); + p.line_to(x0 + r, y1); + p.quad_to(x0, y1, x0, y1 - r); + p.line_to(x0, y0 + r); + p.quad_to(x0, y0, x0 + r, y0); + p.close(); + p.finish() +} + +/// What drawing needs at hand: the pixels, the fonts, the theme, and which +/// element (pre-order) has focus. +struct Painter<'p, 'a> { + pm: &'p mut PixmapMut<'a>, + fonts: &'p Fonts, + theme: &'p Theme, + focused: Option, +} + +impl Painter<'_, '_> { + /// Glyph coverage blended over the pixels in colour `c`, the pen from + /// `x` on `baseline`. + fn glyphs(&mut self, s: &str, (size, weight): (f32, Weight), x: f32, baseline: f32, c: Rgba) { + let (pw, ph) = (self.pm.width() as i32, self.pm.height() as i32); + let data = self.pm.data_mut(); + self.fonts.glyphs(s, size, weight, |pen, g| { + let m = &g.metrics; + let gx = libm::roundf(x + pen) as i32 + m.xmin; + let gy = libm::roundf(baseline) as i32 - m.height as i32 - m.ymin; + for row in 0..m.height as i32 { + let y = gy + row; + if !(0..ph).contains(&y) { + continue; + } + for col in 0..m.width as i32 { + let x = gx + col; + if !(0..pw).contains(&x) { + continue; + } + let cov = g.coverage[(row * m.width as i32 + col) as usize] as u32 * c.3 as u32 / 255; + if cov == 0 { + continue; + } + let at = ((y * pw + x) * 4) as usize; + let px = &mut data[at..at + 4]; + let inv = 255 - cov; + for (i, src) in [c.0, c.1, c.2].into_iter().enumerate() { + px[i] = ((src as u32 * cov + px[i] as u32 * inv) / 255) as u8; + } + px[3] = (cov + px[3] as u32 * inv / 255) as u8; + } + } + }); + } + + /// Text `s` centred vertically in `r`, from its left (or centred + /// across it too). + fn text_in(&mut self, s: &str, style: TextStyle, r: Rect, centre: bool, c: Rgba) { + let (size, weight) = self.theme.text(style); + let (w, h, ascent) = self.fonts.line(s, size, weight); + let x = if centre { r.x + (r.w - w) / 2.0 } else { r.x }; + let baseline = r.y + (r.h - h) / 2.0 + ascent; + self.glyphs(s, (size, weight), x, baseline, c); + } + + fn draw(&mut self, e: &Element, rects: &[Rect], index: &mut usize) { + let me = *index; + *index += 1; + let Some(&r) = rects.get(me) else { return }; + let theme = self.theme; + match &e.kind { + Kind::Flex(f) => { + for c in &f.children { + self.draw(c, rects, index); + } + } + 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); + } + 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); + } + } + } + } + } +} + +/// Draws `root`, laid out as `rects`, with `focused` (a pre-order index) +/// ringed. +pub fn render(pm: &mut PixmapMut, root: &Element, rects: &[Rect], fonts: &Fonts, theme: &Theme, focused: Option) { + let bg = theme.background; + pm.fill(Color::from_rgba8(bg.0, bg.1, bg.2, bg.3)); + Painter { pm, fonts, theme, focused }.draw(root, rects, &mut 0); +} diff --git a/crates/k-ui/src/run.rs b/crates/k-ui/src/run.rs new file mode 100644 index 0000000..ab9b6fd --- /dev/null +++ b/crates/k-ui/src/run.rs @@ -0,0 +1,65 @@ +//! 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. + +use libkos::display::{self, Canvas}; +use libkos::input::{InputEvent, Key as Pressed}; +use libkos::syscall; +use port_framebuffer::Framebuffer; +use tiny_skia::PixmapMut; +use types::display::ScreenCoord; + +use crate::text::Fonts; +use crate::theme::Theme; +use crate::ui::{App, Key, 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), + _ => None, + } +} + +/// Runs `A` until Escape (or until it is stopped). +pub fn run() -> ! { + let window = libkos::start::display(); + let Some(mut canvas) = window.and_then(Canvas::attach) else { + libkos::println!("k-ui: no window (does the manifest ask for the display?)"); + syscall::exit(); + }; + let (Ok(notify), Some(fonts)) = (syscall::create_notification(), Fonts::load()) else { + libkos::println!("k-ui: cannot start"); + syscall::exit(); + }; + if !display::subscribe(canvas.window(), notify, types::badge::Badge::new(1)) { + libkos::println!("k-ui: no input"); + syscall::exit(); + } + let mut ui = Ui::::new(&Theme::WHIMSY_DARK); + let mut dirty = true; + loop { + if dirty { + draw(&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(_) => {} + } + } + } +} + +fn draw(ui: &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); + } + canvas.touch(ScreenCoord::new(0), ScreenCoord::new(0), w, h); + canvas.flush(); +} diff --git a/crates/k-ui/src/tests.rs b/crates/k-ui/src/tests.rs new file mode 100644 index 0000000..f1b83a9 --- /dev/null +++ b/crates/k-ui/src/tests.rs @@ -0,0 +1,124 @@ +extern crate std; + +use alloc::format; +use alloc::vec::Vec; + +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}; + +/// Every character 10 wide, every line 20 high. +struct Fixed; + +impl Measure for Fixed { + fn text(&self, s: &str, _: TextStyle) -> (f32, f32) { + (10.0 * s.len() as f32, 20.0) + } +} + +const T: &Theme = &Theme::WHIMSY_DARK; +const AREA: Rect = Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }; + +#[test] +fn a_column_stacks_with_gaps_and_padding() { + let view: Element<()> = column().padding(Spacing::Md).gap(Spacing::Sm).child(text("ab")).child(text("abcd")).into(); + let r = layout(&view, AREA, &Fixed, T); + // Padding Md is 16, gap Sm 8. + assert_eq!(r[1], Rect { x: 16.0, y: 16.0, w: 20.0, h: 20.0 }); + assert_eq!(r[2], Rect { x: 16.0, y: 44.0, w: 40.0, h: 20.0 }); +} + +#[test] +fn fill_shares_what_is_left_and_fixed_is_exact() { + let view: Element<()> = row().child(text("abc")).child(text("x").fill()).child(text("y").fixed(50.0)).child(text("z").fill()).into(); + let r = layout(&view, AREA, &Fixed, T); + // 400 - 30 - 50 = 320, two fills: 160 each. + let widths: Vec = r[1..].iter().map(|r| r.w).collect(); + assert_eq!(widths, [30.0, 160.0, 50.0, 160.0]); + assert_eq!(r[4].x, 240.0); +} + +#[test] +fn buttons_are_at_least_a_touch_target_and_containers_stretch_across() { + let view: Element<()> = column().child(row().child(button("+", ()))).into(); + let r = layout(&view, AREA, &Fixed, T); + assert_eq!(r[1].w, 400.0, "the row stretches across the column"); + assert!(r[2].w >= MIN_TARGET && r[2].h >= MIN_TARGET); +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Event { + Add, + Reset, +} + +struct Counter(u32); + +impl App for Counter { + type Event = Event; + fn init() -> Self { + Counter(0) + } + fn update(&mut self, e: Event) { + match e { + Event::Add => self.0 += 1, + Event::Reset => self.0 = 0, + } + } + fn view(&self) -> Element { + let mut c = column().padding(Spacing::Md).gap(Spacing::Md).child(text(format!("{}", self.0)).style(TextStyle::Heading)); + c = c.child(button("Add", Event::Add).variant(ButtonVariant::Primary)); + if self.0 > 0 { + c = c.child(button("Reset", Event::Reset).variant(ButtonVariant::Destructive)); + } + c.into() + } +} + +#[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.app.0, 2); + assert!(ui.key(Key::Next)); + assert!(ui.key(Key::Press), "Reset"); + assert_eq!(ui.app.0, 0); + // Reset is gone, so focus is back on Add. + assert!(ui.key(Key::Press)); + assert_eq!(ui.app.0, 1); + assert!(ui.key(Key::Previous)); + assert!(ui.key(Key::Press)); + assert_eq!(ui.app.0, 0, "Previous wraps round to Reset"); +} + +/// The straight colour at (x, y) of a premultiplied pixmap, roughly. +fn at(pm: &tiny_skia::Pixmap, x: u32, y: u32) -> (u8, u8, u8) { + let p = pm.pixel(x, y).unwrap().demultiply(); + (p.red(), p.green(), p.blue()) +} + +#[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 pm = tiny_skia::Pixmap::new(320, 200).unwrap(); + ui.draw(&mut pm.as_mut(), &fonts); + let bg = T.background; + assert_eq!(at(&pm, 319, 199), (bg.0, bg.1, bg.2), "the background is the theme's"); + // Lay it out the same way to find the button. + let view = ui.app.view(); + let rects = layout(&view, Rect { x: 0.0, y: 0.0, w: 320.0, h: 200.0 }, &crate::text::Measurer { fonts: &fonts, theme: T }, T); + let b = rects[2]; + let accent = T.accent; + assert_eq!(at(&pm, (b.x + 4.0) as u32, (b.y + b.h / 2.0) as u32), (accent.0, accent.1, accent.2), "a primary button is the accent"); + let focus = T.focus; + assert_eq!(at(&pm, (b.x + b.w / 2.0) as u32, (b.y - 3.0) as u32), (focus.0, focus.1, focus.2), "the focus ring sits around it"); + // The heading's text is drawn: something brighter than the background + // in its rectangle. + let h = rects[1]; + 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"); +} diff --git a/crates/k-ui/src/text.rs b/crates/k-ui/src/text.rs new file mode 100644 index 0000000..3d18ceb --- /dev/null +++ b/crates/k-ui/src/text.rs @@ -0,0 +1,77 @@ +//! Fonts: Noto Sans (a Latin subset, see fonts/LICENSE-noto), regular and +//! semibold, rasterised by fontdue; glyphs are kept once made. + +use alloc::collections::BTreeMap; +use alloc::vec::Vec; +use core::cell::RefCell; +use fontdue::{Font, FontSettings, Metrics}; + +use crate::layout::Measure; +use crate::theme::{TextStyle, Theme, Weight}; + +static REGULAR: &[u8] = include_bytes!("../fonts/NotoSans-Regular-Latin.ttf"); +static SEMIBOLD: &[u8] = include_bytes!("../fonts/NotoSans-SemiBold-Latin.ttf"); + +/// A glyph's coverage, a byte per pixel, and where it sits. +pub struct Glyph { + pub metrics: Metrics, + pub coverage: Vec, +} + +pub struct Fonts { + regular: Font, + semibold: Font, + glyphs: RefCell>, +} + +impl Fonts { + pub fn load() -> Option { + let font = |bytes| Font::from_bytes(bytes, FontSettings::default()).ok(); + Some(Self { regular: font(REGULAR)?, semibold: font(SEMIBOLD)?, glyphs: RefCell::new(BTreeMap::new()) }) + } + + fn font(&self, w: Weight) -> &Font { + match w { + Weight::Regular => &self.regular, + Weight::SemiBold => &self.semibold, + } + } + + /// The text's width, and a line's height and ascent, at `size` pixels. + pub fn line(&self, s: &str, size: f32, w: Weight) -> (f32, f32, f32) { + let font = self.font(w); + let width = s.chars().map(|c| font.metrics(c, size).advance_width).sum(); + let (height, ascent) = font.horizontal_line_metrics(size).map_or((size * 1.3, size), |m| (m.new_line_size, m.ascent)); + (width, height, ascent) + } + + /// Calls `draw` with each glyph of `s` and the pen's x for it. + pub fn glyphs(&self, s: &str, size: f32, w: Weight, mut draw: impl FnMut(f32, &Glyph)) { + let font = self.font(w); + let mut glyphs = self.glyphs.borrow_mut(); + let mut x = 0.0; + for c in s.chars() { + let key = (c, size.to_bits(), w == Weight::SemiBold); + let g = glyphs.entry(key).or_insert_with(|| { + let (metrics, coverage) = font.rasterize(c, size); + Glyph { metrics, coverage } + }); + draw(x, g); + x += g.metrics.advance_width; + } + } +} + +/// Text measured in the theme's styles. +pub struct Measurer<'a> { + pub fonts: &'a Fonts, + pub theme: &'a Theme, +} + +impl Measure for Measurer<'_> { + fn text(&self, s: &str, style: TextStyle) -> (f32, f32) { + let (size, weight) = self.theme.text(style); + let (w, h, _) = self.fonts.line(s, size, weight); + (w, h) + } +} diff --git a/crates/k-ui/src/theme.rs b/crates/k-ui/src/theme.rs new file mode 100644 index 0000000..364c83f --- /dev/null +++ b/crates/k-ui/src/theme.rs @@ -0,0 +1,130 @@ +//! The theme's tokens (see the vision's K-UI note): apps say what a thing +//! is (a primary button, a heading), never its colour or size. One theme +//! for now, built in; loading themes from TOML comes later. + +/// A colour as the theme names it: straight (not premultiplied) RGBA. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Rgba(pub u8, pub u8, pub u8, pub u8); + +impl Rgba { + pub const fn opaque(r: u8, g: u8, b: u8) -> Self { + Self(r, g, b, 255) + } +} + +/// Steps of the spacing scale. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Spacing { + None, + Xs, + Sm, + Md, + Lg, + Xl, +} + +/// Steps of the type scale. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TextStyle { + Heading, + Subheading, + Body, + Caption, + Label, +} + +/// What a text is, for its colour. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TextColour { + Primary, + Secondary, + Accent, + OnAccent, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ButtonVariant { + Primary, + Secondary, + Ghost, + Destructive, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Weight { + Regular, + SemiBold, +} + +pub struct Theme { + pub background: Rgba, + pub surface: Rgba, + pub surface_raised: Rgba, + pub accent: Rgba, + pub on_accent: Rgba, + pub text_primary: Rgba, + pub text_secondary: Rgba, + pub destructive: Rgba, + pub focus: Rgba, + pub corner_radius: f32, + /// The spacing scale's unit, in pixels. + pub spacing_base: f32, +} + +impl Theme { + pub const WHIMSY_DARK: Theme = Theme { + background: Rgba::opaque(0x0f, 0x0e, 0x17), + surface: Rgba::opaque(0x1a, 0x1a, 0x2e), + surface_raised: Rgba::opaque(0x2a, 0x2a, 0x44), + accent: Rgba::opaque(0x4f, 0xc3, 0xf7), + on_accent: Rgba::opaque(0x0f, 0x0e, 0x17), + text_primary: Rgba::opaque(0xe0, 0xe0, 0xe0), + text_secondary: Rgba::opaque(0x9e, 0x9e, 0xb8), + destructive: Rgba::opaque(0xef, 0x53, 0x50), + focus: Rgba::opaque(0xff, 0xff, 0xff), + corner_radius: 16.0, + spacing_base: 4.0, + }; + + pub fn space(&self, s: Spacing) -> f32 { + self.spacing_base + * match s { + Spacing::None => 0.0, + Spacing::Xs => 1.0, + Spacing::Sm => 2.0, + Spacing::Md => 4.0, + Spacing::Lg => 6.0, + Spacing::Xl => 8.0, + } + } + + /// A text style's size in pixels and its weight. + pub fn text(&self, s: TextStyle) -> (f32, Weight) { + match s { + TextStyle::Heading => (32.0, Weight::SemiBold), + TextStyle::Subheading => (22.0, Weight::SemiBold), + TextStyle::Body => (17.0, Weight::Regular), + TextStyle::Caption => (13.0, Weight::Regular), + TextStyle::Label => (17.0, Weight::SemiBold), + } + } + + pub fn text_colour(&self, c: TextColour) -> Rgba { + match c { + TextColour::Primary => self.text_primary, + TextColour::Secondary => self.text_secondary, + TextColour::Accent => self.accent, + TextColour::OnAccent => self.on_accent, + } + } + + /// A button's fill (none for a ghost) and its label's colour. + pub fn button(&self, v: ButtonVariant) -> (Option, Rgba) { + 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), + } + } +} diff --git a/crates/k-ui/src/ui.rs b/crates/k-ui/src/ui.rs new file mode 100644 index 0000000..188e312 --- /dev/null +++ b/crates/k-ui/src/ui.rs @@ -0,0 +1,107 @@ +//! An app and what K-UI keeps about it: which pressable thing has focus. +//! Keys move focus (Tab, the arrows) and press (Enter, Space); a press is +//! the button's event, handed to the app. Pure, apart from the pixels it +//! draws into. + +use alloc::vec::Vec; +use tiny_skia::PixmapMut; + +use crate::element::{Element, Kind}; +use crate::layout::{self, Rect}; +use crate::render; +use crate::text::{Fonts, Measurer}; +use crate::theme::Theme; + +/// Every K-UI app. +pub trait App { + type Event: Clone; + + fn init() -> Self; + /// Changes the state; the view follows. + fn update(&mut self, event: Self::Event); + /// What the state looks like: a pure function of it. + fn view(&self) -> Element; +} + +/// A key, as far as K-UI cares. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Key { + Next, + Previous, + Press, +} + +pub struct Ui { + pub app: A, + /// Which pressable element has focus: its place among them, in + /// pre-order. + focus: usize, + theme: &'static Theme, +} + +/// The pre-order indices of every pressable element in `e`. +fn pressables(e: &Element, index: &mut usize, out: &mut Vec) { + if matches!(e.kind, Kind::Button(_)) { + out.push(*index); + } + *index += 1; + if let Kind::Flex(f) = &e.kind { + for c in &f.children { + pressables(c, index, out); + } + } +} + +/// The event of the pre-order `target`th element, if it is a button. +fn event_at(e: &Element, index: &mut usize, target: usize) -> Option { + let me = *index; + *index += 1; + match &e.kind { + Kind::Button(b) if me == target => Some(b.on_press.clone()), + Kind::Flex(f) => f.children.iter().find_map(|c| event_at(c, index, target)), + _ => None, + } +} + +impl Ui { + pub fn new(theme: &'static Theme) -> Self { + Self { app: A::init(), focus: 0, theme } + } + + fn pressables(&self, view: &Element) -> Vec { + let mut out = Vec::new(); + pressables(view, &mut 0, &mut out); + out + } + + /// Handles a key; whether anything changed. + pub fn key(&mut self, key: Key) -> bool { + let view = self.app.view(); + let targets = self.pressables(&view); + if targets.is_empty() { + return false; + } + let n = targets.len(); + match key { + Key::Next => self.focus = (self.focus + 1) % n, + Key::Previous => self.focus = (self.focus + n - 1) % n, + Key::Press => { + let Some(event) = event_at(&view, &mut 0, targets[self.focus.min(n - 1)]) else { return false }; + self.app.update(event); + // The view may have fewer pressable things now. + let now = self.pressables(&self.app.view()).len(); + self.focus = self.focus.min(now.saturating_sub(1)); + } + } + true + } + + /// Lays the view out over the whole of `pm` and draws it. + pub fn draw(&self, pm: &mut PixmapMut, fonts: &Fonts) { + let view = self.app.view(); + let area = Rect { x: 0.0, y: 0.0, w: pm.width() as f32, h: pm.height() as f32 }; + let rects = layout::layout(&view, area, &Measurer { fonts, theme: self.theme }, self.theme); + let focused = self.pressables(&view).get(self.focus).copied(); + render::render(pm, &view, &rects, fonts, self.theme, focused); + } +} diff --git a/crates/manifest/src/fields.rs b/crates/manifest/src/fields.rs index 5814d05..6190335 100644 --- a/crates/manifest/src/fields.rs +++ b/crates/manifest/src/fields.rs @@ -144,3 +144,32 @@ impl Accent { u32::from_str_radix(hex, 16).ok().map(Self) } } + +/// How much memory an app may use, in 4 KiB pages: written `"512 KiB"` or +/// `"3 MiB"`, from 64 KiB to 16 MiB. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Memory(u16); + +impl Memory { + /// What an app gets unless its manifest says more: enough for a small + /// command-line program. + pub const DEFAULT: Self = Self(128); + const MIN_PAGES: u32 = 16; + const MAX_PAGES: u32 = 4096; + + pub fn parse(s: &str) -> Option { + let (n, unit) = s.split_once(' ')?; + let n: u32 = n.parse().ok()?; + let kib = match unit { + "KiB" => n, + "MiB" => n.checked_mul(1024)?, + _ => return None, + }; + let pages = kib.div_ceil(4); + (kib % 4 == 0 && (Self::MIN_PAGES..=Self::MAX_PAGES).contains(&pages)).then_some(Self(pages as u16)) + } + + pub fn pages(&self) -> usize { + self.0 as usize + } +} diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 503b39b..373db0d 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -16,7 +16,7 @@ mod tests; pub use capability::{Capabilities, Capability}; pub use decision::{Decision, MAX_TEXT as MAX_DECISION_TEXT}; -pub use fields::{Accent, AppId, Command, FileName, Text, Version}; +pub use fields::{Accent, AppId, Command, FileName, Memory, Text, Version}; pub use manifest::Manifest; /// Why a manifest or decision was refused; `line` is 1-based. diff --git a/crates/manifest/src/manifest.rs b/crates/manifest/src/manifest.rs index d155012..dc228ab 100644 --- a/crates/manifest/src/manifest.rs +++ b/crates/manifest/src/manifest.rs @@ -2,7 +2,7 @@ use crate::Error; use crate::capability::{Capabilities, Capability}; -use crate::fields::{Accent, AppId, Command, FileName, Text, Version}; +use crate::fields::{Accent, AppId, Command, FileName, Memory, Text, Version}; use crate::toml::{self, Entry, Value}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -15,6 +15,8 @@ pub struct Manifest { pub command: Command, pub author: Option>, pub icon: Option, + /// How much memory it may use. + pub memory: Memory, /// Denied = not installed. pub required: Capabilities, /// The owner decides each. @@ -32,6 +34,7 @@ struct Draft { command: Option, author: Option>, icon: Option, + memory: Option, required: Option, optional: Option, accent: Option, @@ -81,6 +84,7 @@ impl Draft { ("app", "command") => set(&mut self.command, s.and_then(Command::new), l), ("app", "author") => set(&mut self.author, s.and_then(Text::new), l), ("app", "icon") => set(&mut self.icon, s.and_then(FileName::new), l), + ("app", "memory") => set(&mut self.memory, s.and_then(Memory::parse), l), ("capabilities", "required") => { set(&mut self.required, Some(capabilities(e.value, l)?), l) } @@ -116,6 +120,7 @@ impl Manifest { command, author: d.author, icon: d.icon, + memory: d.memory.unwrap_or(Memory::DEFAULT), required, optional, accent: d.accent, diff --git a/crates/manifest/src/tests.rs b/crates/manifest/src/tests.rs index 65b9553..81e716e 100644 --- a/crates/manifest/src/tests.rs +++ b/crates/manifest/src/tests.rs @@ -239,3 +239,13 @@ fn every_capability_name_round_trips_and_fits_the_set() { all ); } + +#[test] +fn memory_is_in_kib_or_mib_within_bounds() { + use crate::Memory; + assert_eq!(Memory::parse("3 MiB").map(|m| m.pages()), Some(768)); + assert_eq!(Memory::parse("512 KiB").map(|m| m.pages()), Some(128)); + for bad in ["3MiB", "3 GiB", "17 MiB", "32 KiB", "6 KiB", "-1 MiB", "x MiB"] { + assert_eq!(Memory::parse(bad), None, "{bad}"); + } +} diff --git a/crates/servers/permission-manager/src/server.rs b/crates/servers/permission-manager/src/server.rs index 41d49ab..50881d6 100644 --- a/crates/servers/permission-manager/src/server.rs +++ b/crates/servers/permission-manager/src/server.rs @@ -22,7 +22,8 @@ use crate::policy::{self, Refusal, Store}; use crate::store::DiskStore; /// What each app gets to run in, out of the permission manager's allowance. -const PROGRAM: Limits = Limits { pages: 128, endpoints: 1, notifications: 1, regions: 1 }; +/// An app's limits; its pages are what its manifest asks for. +const PROGRAM: Limits = Limits { pages: 0, endpoints: 1, notifications: 1, regions: 1 }; /// Apps running at once; a death is a bit each on the notification. const MAX_RUNNING: usize = 8; const MAX_ARGS: usize = 16; @@ -167,7 +168,7 @@ impl PermissionServer { let index = self.running.iter().position(Option::is_none).ok_or(code(reply::BUSY))?; let file = self.store.binary_grant(&plan.app).ok_or(code(reply::FAILED))?; - let spawned = libkos::spawn::spawn(self.init, file, PROGRAM); + let spawned = libkos::spawn::spawn(self.init, file, Limits { pages: plan.app.memory.pages(), ..PROGRAM }); let _ = libkos::fs::release(file); let child = spawned.map_err(|e| match e { libkos::spawn::SpawnError::OverAllowance => code(reply::BUSY), diff --git a/docs/apps.md b/docs/apps.md index a956009..d1c966d 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -62,6 +62,8 @@ binary = "snake" # required: a file in the app's folder command = "snake" # optional: what the shell runs it as (default: binary) author = "K-OS" # optional icon = "icon.png" # optional: a file in the app's folder +memory = "3 MiB" # optional: how much it may use, KiB or MiB, + # 64 KiB to 16 MiB (default 512 KiB) [capabilities] required = ["display"] # cannot work without; denied = not installed diff --git a/scripts/make-disk-image.sh b/scripts/make-disk-image.sh index bc4a78c..6fe10c1 100755 --- a/scripts/make-disk-image.sh +++ b/scripts/make-disk-image.sh @@ -54,6 +54,7 @@ app cat app ls app sleep app snake +app tally staged counter inbox/counter # A user's first files. cp -r disk/home/. "${ROOT}/factory/home/"