feat(k-ui): first slice: App trait + run loop, builders (column/row/text/button), flex layout, theme tokens (Whimsy Dark), tiny-skia + fontdue render (Noto Sans Latin subset), keyboard focus ring; tally demo; manifest memory key (pm spawns with it)
This commit is contained in:
13
crates/apps/tally/Cargo.toml
Normal file
13
crates/apps/tally/Cargo.toml
Normal file
@@ -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 }
|
||||
10
crates/apps/tally/manifest.toml
Normal file
10
crates/apps/tally/manifest.toml
Normal file
@@ -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"]
|
||||
61
crates/apps/tally/src/main.rs
Normal file
61
crates/apps/tally/src/main.rs
Normal file
@@ -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<Event> {
|
||||
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::<Tally>();
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
12
crates/k-ui/Cargo.toml
Normal file
12
crates/k-ui/Cargo.toml
Normal file
@@ -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"
|
||||
201
crates/k-ui/fonts/LICENSE-noto
Normal file
201
crates/k-ui/fonts/LICENSE-noto
Normal file
@@ -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.
|
||||
BIN
crates/k-ui/fonts/NotoSans-Regular-Latin.ttf
Normal file
BIN
crates/k-ui/fonts/NotoSans-Regular-Latin.ttf
Normal file
Binary file not shown.
BIN
crates/k-ui/fonts/NotoSans-SemiBold-Latin.ttf
Normal file
BIN
crates/k-ui/fonts/NotoSans-SemiBold-Latin.ttf
Normal file
Binary file not shown.
158
crates/k-ui/src/element.rs
Normal file
158
crates/k-ui/src/element.rs
Normal file
@@ -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<E> {
|
||||
pub axis: Axis,
|
||||
pub gap: Spacing,
|
||||
pub padding: Spacing,
|
||||
pub children: Vec<Element<E>>,
|
||||
}
|
||||
|
||||
pub struct Text {
|
||||
pub content: String,
|
||||
pub style: TextStyle,
|
||||
pub colour: TextColour,
|
||||
}
|
||||
|
||||
pub struct Button<E> {
|
||||
pub label: String,
|
||||
pub variant: ButtonVariant,
|
||||
pub on_press: E,
|
||||
}
|
||||
|
||||
pub enum Kind<E> {
|
||||
Flex(Flex<E>),
|
||||
Text(Text),
|
||||
Button(Button<E>),
|
||||
}
|
||||
|
||||
pub struct Element<E> {
|
||||
pub kind: Kind<E>,
|
||||
pub size: Size,
|
||||
}
|
||||
|
||||
/// Children stacked top to bottom.
|
||||
pub fn column<E>() -> FlexBuilder<E> {
|
||||
FlexBuilder(Flex { axis: Axis::Vertical, gap: Spacing::None, padding: Spacing::None, children: Vec::new() }, Size::Shrink)
|
||||
}
|
||||
|
||||
/// Children side by side.
|
||||
pub fn row<E>() -> FlexBuilder<E> {
|
||||
FlexBuilder(Flex { axis: Axis::Horizontal, gap: Spacing::None, padding: Spacing::None, children: Vec::new() }, Size::Shrink)
|
||||
}
|
||||
|
||||
pub fn text(content: impl Into<String>) -> 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<E>(label: impl Into<String>, on_press: E) -> ButtonBuilder<E> {
|
||||
ButtonBuilder(Button { label: label.into(), variant: ButtonVariant::Secondary, on_press }, Size::Shrink)
|
||||
}
|
||||
|
||||
pub struct FlexBuilder<E>(Flex<E>, Size);
|
||||
pub struct TextBuilder(Text, Size);
|
||||
pub struct ButtonBuilder<E>(Button<E>, 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<E> FlexBuilder<E> {
|
||||
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<Element<E>>) -> Self {
|
||||
self.0.children.push(c.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn children<C: Into<Element<E>>>(mut self, cs: impl IntoIterator<Item = C>) -> 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<E> ButtonBuilder<E> {
|
||||
sizing!();
|
||||
|
||||
pub fn variant(mut self, v: ButtonVariant) -> Self {
|
||||
self.0.variant = v;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<FlexBuilder<E>> for Element<E> {
|
||||
fn from(b: FlexBuilder<E>) -> Self {
|
||||
Element { kind: Kind::Flex(b.0), size: b.1 }
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<TextBuilder> for Element<E> {
|
||||
fn from(b: TextBuilder) -> Self {
|
||||
Element { kind: Kind::Text(b.0), size: b.1 }
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<ButtonBuilder<E>> for Element<E> {
|
||||
fn from(b: ButtonBuilder<E>) -> Self {
|
||||
Element { kind: Kind::Button(b.0), size: b.1 }
|
||||
}
|
||||
}
|
||||
118
crates/k-ui/src/layout.rs
Normal file
118
crates/k-ui/src/layout.rs
Normal file
@@ -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>(e: &Element<E>, 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<E>(root: &Element<E>, area: Rect, m: &impl Measure, t: &Theme) -> Vec<Rect> {
|
||||
let mut out = Vec::new();
|
||||
place(root, area, m, t, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn place<E>(e: &Element<E>, r: Rect, m: &impl Measure, t: &Theme, out: &mut Vec<Rect>) {
|
||||
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::<f32>()
|
||||
+ 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;
|
||||
}
|
||||
}
|
||||
25
crates/k-ui/src/lib.rs
Normal file
25
crates/k-ui/src/lib.rs
Normal file
@@ -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;
|
||||
131
crates/k-ui/src/render.rs
Normal file
131
crates/k-ui/src/render.rs
Normal file
@@ -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<tiny_skia::Path> {
|
||||
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<usize>,
|
||||
}
|
||||
|
||||
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<E>(&mut self, e: &Element<E>, 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<E>(pm: &mut PixmapMut, root: &Element<E>, rects: &[Rect], fonts: &Fonts, theme: &Theme, focused: Option<usize>) {
|
||||
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);
|
||||
}
|
||||
65
crates/k-ui/src/run.rs
Normal file
65
crates/k-ui/src/run.rs
Normal file
@@ -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<Key> {
|
||||
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<A: App>() -> ! {
|
||||
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::<A>::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<A: App>(ui: &Ui<A>, canvas: &mut Canvas, fonts: &Fonts) {
|
||||
let (w, h) = (canvas.width(), canvas.height());
|
||||
if let Some(mut pm) = PixmapMut::from_bytes(canvas.pixels_mut(), w.raw() as u32, h.raw() as u32) {
|
||||
ui.draw(&mut pm, fonts);
|
||||
}
|
||||
canvas.touch(ScreenCoord::new(0), ScreenCoord::new(0), w, h);
|
||||
canvas.flush();
|
||||
}
|
||||
124
crates/k-ui/src/tests.rs
Normal file
124
crates/k-ui/src/tests.rs
Normal file
@@ -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<f32> = 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<Event> {
|
||||
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::<Counter>::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::<Counter>::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");
|
||||
}
|
||||
77
crates/k-ui/src/text.rs
Normal file
77
crates/k-ui/src/text.rs
Normal file
@@ -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<u8>,
|
||||
}
|
||||
|
||||
pub struct Fonts {
|
||||
regular: Font,
|
||||
semibold: Font,
|
||||
glyphs: RefCell<BTreeMap<(char, u32, bool), Glyph>>,
|
||||
}
|
||||
|
||||
impl Fonts {
|
||||
pub fn load() -> Option<Self> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
130
crates/k-ui/src/theme.rs
Normal file
130
crates/k-ui/src/theme.rs
Normal file
@@ -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>, 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
107
crates/k-ui/src/ui.rs
Normal file
107
crates/k-ui/src/ui.rs
Normal file
@@ -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<Self::Event>;
|
||||
}
|
||||
|
||||
/// A key, as far as K-UI cares.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Key {
|
||||
Next,
|
||||
Previous,
|
||||
Press,
|
||||
}
|
||||
|
||||
pub struct Ui<A: App> {
|
||||
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>(e: &Element<E>, index: &mut usize, out: &mut Vec<usize>) {
|
||||
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: Clone>(e: &Element<E>, index: &mut usize, target: usize) -> Option<E> {
|
||||
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<A: App> Ui<A> {
|
||||
pub fn new(theme: &'static Theme) -> Self {
|
||||
Self { app: A::init(), focus: 0, theme }
|
||||
}
|
||||
|
||||
fn pressables(&self, view: &Element<A::Event>) -> Vec<usize> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Text<64>>,
|
||||
pub icon: Option<FileName>,
|
||||
/// 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<Command>,
|
||||
author: Option<Text<64>>,
|
||||
icon: Option<FileName>,
|
||||
memory: Option<Memory>,
|
||||
required: Option<Capabilities>,
|
||||
optional: Option<Capabilities>,
|
||||
accent: Option<Accent>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user