feat(k-ui): text_input (label above, app owns text via on_change, caret, erase, submit, secret, placeholder); keys Left/Right/Space/Char/Erase; settings app filter; apps 8-page stack (k-ui overflowed 16K), args page -> 0x1000
This commit is contained in:
@@ -14,7 +14,7 @@ extern crate libkos;
|
|||||||
use alloc::format;
|
use alloc::format;
|
||||||
use alloc::string::{String, ToString};
|
use alloc::string::{String, ToString};
|
||||||
use alloc::vec::Vec;
|
use alloc::vec::Vec;
|
||||||
use k_ui::{App, ButtonVariant, Element, Spacing, TextColour, TextStyle, button, column, list_item, text, toggle};
|
use k_ui::{App, ButtonVariant, Element, Spacing, TextColour, TextStyle, button, column, list_item, text, text_input, toggle};
|
||||||
use libkos::apps::Apps;
|
use libkos::apps::Apps;
|
||||||
use libkos::session::Role;
|
use libkos::session::Role;
|
||||||
use manifest::{Capabilities, Capability};
|
use manifest::{Capabilities, Capability};
|
||||||
@@ -34,11 +34,13 @@ enum Screen {
|
|||||||
About,
|
About,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone)]
|
||||||
enum Event {
|
enum Event {
|
||||||
Show(Screen),
|
Show(Screen),
|
||||||
/// Allow or revoke this capability of the app on screen.
|
/// Allow or revoke this capability of the app on screen.
|
||||||
Flip(Capability),
|
Flip(Capability),
|
||||||
|
/// What the app list is narrowed to.
|
||||||
|
Filter(String),
|
||||||
/// Use the display's `n`th theme.
|
/// Use the display's `n`th theme.
|
||||||
Theme(usize),
|
Theme(usize),
|
||||||
SignOut,
|
SignOut,
|
||||||
@@ -56,6 +58,8 @@ struct Settings {
|
|||||||
screen: Screen,
|
screen: Screen,
|
||||||
apps: Option<Apps>,
|
apps: Option<Apps>,
|
||||||
rows: Vec<AppRow>,
|
rows: Vec<AppRow>,
|
||||||
|
/// Only apps whose name or id has this in it are listed.
|
||||||
|
filter: String,
|
||||||
/// Who is signed in, and how.
|
/// Who is signed in, and how.
|
||||||
who: Option<(String, Role)>,
|
who: Option<(String, Role)>,
|
||||||
/// The display's themes, and which is in use.
|
/// The display's themes, and which is in use.
|
||||||
@@ -81,7 +85,8 @@ impl Settings {
|
|||||||
fn reload(&mut self) {
|
fn reload(&mut self) {
|
||||||
self.rows.clear();
|
self.rows.clear();
|
||||||
let Some(apps) = &self.apps else { return };
|
let Some(apps) = &self.apps else { return };
|
||||||
let mut buf = [0u8; LISTING];
|
// On the heap: an app's stack is a few pages.
|
||||||
|
let mut buf = alloc::vec![0u8; LISTING];
|
||||||
let Ok(n) = apps.apps(&mut buf) else {
|
let Ok(n) = apps.apps(&mut buf) else {
|
||||||
self.trouble = Some("The permission manager did not answer.".to_string());
|
self.trouble = Some("The permission manager did not answer.".to_string());
|
||||||
return;
|
return;
|
||||||
@@ -141,12 +146,15 @@ impl Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn app_list(&self) -> Element<Event> {
|
fn app_list(&self) -> Element<Event> {
|
||||||
let rows = self.rows.iter().enumerate().map(|(i, r)| {
|
let wanted = self.filter.to_ascii_lowercase();
|
||||||
|
let shown = |r: &&AppRow| r.name.to_ascii_lowercase().contains(&wanted) || r.id.contains(&wanted);
|
||||||
|
let rows = self.rows.iter().enumerate().filter(|(_, r)| shown(r)).map(|(i, r)| {
|
||||||
let allowed = r.granted.intersect(r.requested).iter().count();
|
let allowed = r.granted.intersect(r.requested).iter().count();
|
||||||
let asked = r.requested.iter().count();
|
let asked = r.requested.iter().count();
|
||||||
list_item(r.name.clone(), Event::Show(Screen::App(i))).subtitle(format!("{} · {} of {} allowed", r.id, allowed, asked))
|
list_item(r.name.clone(), Event::Show(Screen::App(i))).subtitle(format!("{} · {} of {} allowed", r.id, allowed, asked))
|
||||||
});
|
});
|
||||||
let view = Self::hint(Self::heading("Apps"), "Esc goes back").children(rows);
|
let find = text_input("Find an app", self.filter.clone(), Event::Filter).placeholder("Name or id");
|
||||||
|
let view = Self::hint(Self::heading("Apps"), "Esc goes back, Down goes to the list").child(find).children(rows);
|
||||||
self.with_trouble(view).into()
|
self.with_trouble(view).into()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +217,7 @@ impl App for Settings {
|
|||||||
.collect()
|
.collect()
|
||||||
});
|
});
|
||||||
let theme = window.and_then(libkos::display::theme).map_or(0, |(i, _)| i);
|
let theme = window.and_then(libkos::display::theme).map_or(0, |(i, _)| i);
|
||||||
let mut s = Settings { screen: Screen::Home, apps, rows: Vec::new(), who, themes, theme, trouble: None };
|
let mut s = Settings { screen: Screen::Home, apps, rows: Vec::new(), filter: String::new(), who, themes, theme, trouble: None };
|
||||||
s.reload();
|
s.reload();
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
@@ -219,6 +227,7 @@ impl App for Settings {
|
|||||||
Event::Show(screen) => {
|
Event::Show(screen) => {
|
||||||
self.trouble = None;
|
self.trouble = None;
|
||||||
if screen == Screen::Apps {
|
if screen == Screen::Apps {
|
||||||
|
self.filter.clear();
|
||||||
self.reload();
|
self.reload();
|
||||||
}
|
}
|
||||||
self.screen = screen;
|
self.screen = screen;
|
||||||
@@ -228,6 +237,7 @@ impl App for Settings {
|
|||||||
self.flip(i, cap);
|
self.flip(i, cap);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Event::Filter(f) => self.filter = f,
|
||||||
Event::Theme(i) => self.choose_theme(i),
|
Event::Theme(i) => self.choose_theme(i),
|
||||||
Event::SignOut => {
|
Event::SignOut => {
|
||||||
if let Some(s) = libkos::start::session() {
|
if let Some(s) = libkos::start::session() {
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ const PAGE_SIZE: usize = 4096;
|
|||||||
const USER_CODE_BASE: usize = 0x10000;
|
const USER_CODE_BASE: usize = 0x10000;
|
||||||
/// A program's stack, below its image, unless its manifest says more.
|
/// A program's stack, below its image, unless its manifest says more.
|
||||||
pub(crate) const STACK_PAGES: usize = 4;
|
pub(crate) const STACK_PAGES: usize = 4;
|
||||||
/// The most there is room for under the image (page 0 stays unmapped).
|
/// An app's: drawing (tiny-skia, fontdue) takes more than 16 KiB.
|
||||||
const MAX_STACK_PAGES: usize = USER_CODE_BASE / PAGE_SIZE - 1;
|
pub(crate) const APP_STACK_PAGES: usize = 8;
|
||||||
|
/// The most there is room for under the image, above the arguments page
|
||||||
|
/// (page 0 stays unmapped).
|
||||||
|
const MAX_STACK_PAGES: usize = (USER_CODE_BASE - types::start_arg::ARGS_VA) / PAGE_SIZE - 1;
|
||||||
const CREATE_CAP: CapSlot = CapSlot::const_new(0);
|
const CREATE_CAP: CapSlot = CapSlot::const_new(0);
|
||||||
/// Where a program file is read through on its way into a process, a
|
/// Where a program file is read through on its way into a process, a
|
||||||
/// chunk at a time, so a program may be any size.
|
/// chunk at a time, so a program may be any size.
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ impl Launcher<'_> {
|
|||||||
// The grant is the file: its path inside it is empty.
|
// The grant is the file: its path inside it is empty.
|
||||||
let found = file.open(b"").and_then(|h| file.close(h)).is_ok();
|
let found = file.open(b"").and_then(|h| file.close(h)).is_ok();
|
||||||
let (_, scratch) = self.storage();
|
let (_, scratch) = self.storage();
|
||||||
let loaded = found.then(|| load::load_binary(&mut file, scratch, b"", r.limits, load::STACK_PAGES)).flatten();
|
let loaded = found.then(|| load::load_binary(&mut file, scratch, b"", r.limits, load::APP_STACK_PAGES)).flatten();
|
||||||
file.detach();
|
file.detach();
|
||||||
let Some(loaded) = loaded else {
|
let Some(loaded) = loaded else {
|
||||||
return refused(if found { reply::ERROR } else { reply::NOT_FOUND });
|
return refused(if found { reply::ERROR } else { reply::NOT_FOUND });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! What a view is made of, built with typed builders: `column()`, `row()`,
|
//! What a view is made of, built with typed builders: `column()`, `row()`,
|
||||||
//! `text(..)`, `button(..)`. Each only offers what makes sense for it
|
//! `text(..)`, `button(..)`, `text_input(..)`. Each only offers what makes sense for it
|
||||||
//! (a text has a style, a button a variant); appearance comes from the
|
//! (a text has a style, a button a variant); appearance comes from the
|
||||||
//! theme, never from here.
|
//! theme, never from here.
|
||||||
|
|
||||||
@@ -58,12 +58,27 @@ pub struct Toggle<E> {
|
|||||||
pub on_toggle: E,
|
pub on_toggle: E,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A line the user types into. The text is the app's: each change comes
|
||||||
|
/// back as `on_change(the new text)`, and the view shows what it is given.
|
||||||
|
pub struct Input<E> {
|
||||||
|
/// What it is for: shown above it, and what it is called.
|
||||||
|
pub label: String,
|
||||||
|
pub value: String,
|
||||||
|
pub placeholder: Option<String>,
|
||||||
|
/// Shown as `*`s (a passcode).
|
||||||
|
pub secret: bool,
|
||||||
|
pub on_change: fn(String) -> E,
|
||||||
|
/// What Enter sends, if anything.
|
||||||
|
pub on_submit: Option<E>,
|
||||||
|
}
|
||||||
|
|
||||||
pub enum Kind<E> {
|
pub enum Kind<E> {
|
||||||
Flex(Flex<E>),
|
Flex(Flex<E>),
|
||||||
Text(Text),
|
Text(Text),
|
||||||
Button(Button<E>),
|
Button(Button<E>),
|
||||||
Item(Item<E>),
|
Item(Item<E>),
|
||||||
Toggle(Toggle<E>),
|
Toggle(Toggle<E>),
|
||||||
|
Input(Input<E>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<E> Kind<E> {
|
impl<E> Kind<E> {
|
||||||
@@ -73,13 +88,19 @@ impl<E> Kind<E> {
|
|||||||
Kind::Button(b) => Some(&b.on_press),
|
Kind::Button(b) => Some(&b.on_press),
|
||||||
Kind::Item(i) => Some(&i.on_press),
|
Kind::Item(i) => Some(&i.on_press),
|
||||||
Kind::Toggle(t) => Some(&t.on_toggle),
|
Kind::Toggle(t) => Some(&t.on_toggle),
|
||||||
|
Kind::Input(i) => i.on_submit.as_ref(),
|
||||||
Kind::Flex(_) | Kind::Text(_) => None,
|
Kind::Flex(_) | Kind::Text(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether it fills its column across (rows of a list do).
|
/// Whether it fills its column across (rows of a list do).
|
||||||
pub fn stretches(&self) -> bool {
|
pub fn stretches(&self) -> bool {
|
||||||
matches!(self, Kind::Flex(_) | Kind::Item(_) | Kind::Toggle(_))
|
matches!(self, Kind::Flex(_) | Kind::Item(_) | Kind::Toggle(_) | Kind::Input(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether focus can rest on it: what can be pressed, and fields.
|
||||||
|
pub fn focusable(&self) -> bool {
|
||||||
|
self.event().is_some() || matches!(self, Kind::Input(_))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +139,13 @@ pub fn toggle<E>(label: impl Into<String>, on: bool, on_toggle: E) -> ToggleBuil
|
|||||||
ToggleBuilder(Toggle { label: label.into(), on, on_toggle }, Size::Shrink)
|
ToggleBuilder(Toggle { label: label.into(), on, on_toggle }, Size::Shrink)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A field labelled `label` showing `value`; typing sends
|
||||||
|
/// `on_change(new text)`.
|
||||||
|
pub fn text_input<E>(label: impl Into<String>, value: impl Into<String>, on_change: fn(String) -> E) -> InputBuilder<E> {
|
||||||
|
InputBuilder(Input { label: label.into(), value: value.into(), placeholder: None, secret: false, on_change, on_submit: None })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct InputBuilder<E>(Input<E>);
|
||||||
pub struct ItemBuilder<E>(Item<E>, Size);
|
pub struct ItemBuilder<E>(Item<E>, Size);
|
||||||
pub struct ToggleBuilder<E>(Toggle<E>, Size);
|
pub struct ToggleBuilder<E>(Toggle<E>, Size);
|
||||||
pub struct FlexBuilder<E>(Flex<E>, Size);
|
pub struct FlexBuilder<E>(Flex<E>, Size);
|
||||||
@@ -192,6 +220,32 @@ impl<E> ItemBuilder<E> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<E> InputBuilder<E> {
|
||||||
|
/// Shown, dimmed, while it is empty.
|
||||||
|
pub fn placeholder(mut self, s: impl Into<String>) -> Self {
|
||||||
|
self.0.placeholder = Some(s.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn secret(mut self) -> Self {
|
||||||
|
self.0.secret = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_submit(mut self, e: E) -> Self {
|
||||||
|
self.0.on_submit = Some(e);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Its label above it, then the field.
|
||||||
|
impl<E> From<InputBuilder<E>> for Element<E> {
|
||||||
|
fn from(b: InputBuilder<E>) -> Self {
|
||||||
|
let label = text(b.0.label.clone()).style(TextStyle::Caption).colour(TextColour::Secondary);
|
||||||
|
column().gap(Spacing::Xs).child(label).child(Element { kind: Kind::Input(b.0), size: Size::Shrink }).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<E> From<ItemBuilder<E>> for Element<E> {
|
impl<E> From<ItemBuilder<E>> for Element<E> {
|
||||||
fn from(b: ItemBuilder<E>) -> Self {
|
fn from(b: ItemBuilder<E>) -> Self {
|
||||||
Element { kind: Kind::Item(b.0), size: b.1 }
|
Element { kind: Kind::Item(b.0), size: b.1 }
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ pub fn row_padding(theme: &Theme) -> f32 {
|
|||||||
theme.space(Spacing::Md)
|
theme.space(Spacing::Md)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The least a field is across, empty or not.
|
||||||
|
pub const FIELD_WIDTH: f32 = 240.0;
|
||||||
|
|
||||||
/// A toggle's switch: its track's size.
|
/// A toggle's switch: its track's size.
|
||||||
pub const SWITCH: (f32, f32) = (48.0, 28.0);
|
pub const SWITCH: (f32, f32) = (48.0, 28.0);
|
||||||
|
|
||||||
@@ -61,6 +64,11 @@ pub fn natural<E>(e: &Element<E>, m: &impl Measure, t: &Theme) -> (f32, f32) {
|
|||||||
let pad = 2.0 * row_padding(t);
|
let pad = 2.0 * row_padding(t);
|
||||||
(w + t.space(Spacing::Lg) + SWITCH.0 + pad, (h.max(SWITCH.1) + pad).max(MIN_TARGET))
|
(w + t.space(Spacing::Lg) + SWITCH.0 + pad, (h.max(SWITCH.1) + pad).max(MIN_TARGET))
|
||||||
}
|
}
|
||||||
|
Kind::Input(i) => {
|
||||||
|
let (w, h) = m.text(i.placeholder.as_deref().unwrap_or(&i.value), TextStyle::Body);
|
||||||
|
let pad = 2.0 * row_padding(t);
|
||||||
|
((w + pad).max(FIELD_WIDTH), (h + pad).max(MIN_TARGET))
|
||||||
|
}
|
||||||
Kind::Flex(f) => {
|
Kind::Flex(f) => {
|
||||||
let pad = 2.0 * t.space(f.padding);
|
let pad = 2.0 * t.space(f.padding);
|
||||||
let gaps = t.space(f.gap) * f.children.len().saturating_sub(1) as f32;
|
let gaps = t.space(f.gap) * f.children.len().saturating_sub(1) as f32;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ pub mod text;
|
|||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|
||||||
pub use element::{Element, Size, button, column, list_item, row, text, toggle};
|
pub use element::{Element, Size, button, column, list_item, row, text, text_input, toggle};
|
||||||
pub use run::run;
|
pub use run::run;
|
||||||
pub use theme::{ButtonVariant, Spacing, TextColour, TextStyle};
|
pub use theme::{ButtonVariant, Spacing, TextColour, TextStyle};
|
||||||
pub use ui::App;
|
pub use ui::App;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Drawing a laid-out tree into a window's pixels (RGBA, premultiplied):
|
//! Drawing a laid-out tree into a window's pixels (RGBA, premultiplied):
|
||||||
//! the theme decides every colour and shape.
|
//! the theme decides every colour and shape.
|
||||||
|
|
||||||
|
use alloc::string::String;
|
||||||
use tiny_skia::{Color, FillRule, Paint, PathBuilder, PixmapMut, Stroke, Transform};
|
use tiny_skia::{Color, FillRule, Paint, PathBuilder, PixmapMut, Stroke, Transform};
|
||||||
|
|
||||||
use crate::element::{Element, Kind};
|
use crate::element::{Element, Kind};
|
||||||
@@ -11,6 +12,9 @@ use crate::theme::{Rgba, TextStyle, Theme, Weight};
|
|||||||
/// The focus ring's distance outside the button, and its width.
|
/// The focus ring's distance outside the button, and its width.
|
||||||
const RING_GAP: f32 = 3.0;
|
const RING_GAP: f32 = 3.0;
|
||||||
const RING_WIDTH: f32 = 2.0;
|
const RING_WIDTH: f32 = 2.0;
|
||||||
|
/// A field's outline, and its caret, across.
|
||||||
|
const OUTLINE: f32 = 1.5;
|
||||||
|
const CARET: f32 = 2.0;
|
||||||
|
|
||||||
fn paint(c: Rgba) -> Paint<'static> {
|
fn paint(c: Rgba) -> Paint<'static> {
|
||||||
let mut p = Paint::default();
|
let mut p = Paint::default();
|
||||||
@@ -44,6 +48,8 @@ struct Painter<'p, 'a> {
|
|||||||
fonts: &'p Fonts,
|
fonts: &'p Fonts,
|
||||||
theme: &'p Theme,
|
theme: &'p Theme,
|
||||||
focused: Option<usize>,
|
focused: Option<usize>,
|
||||||
|
/// Where the caret is in the focused field, in characters.
|
||||||
|
caret: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Painter<'_, '_> {
|
impl Painter<'_, '_> {
|
||||||
@@ -137,8 +143,9 @@ impl Painter<'_, '_> {
|
|||||||
let kx = if g.on { track.x + tw - knob - 3.0 } else { track.x + 3.0 };
|
let kx = if g.on { track.x + tw - knob - 3.0 } else { track.x + 3.0 };
|
||||||
self.fill(Rect { x: kx, y: track.y + 3.0, w: knob, h: knob }, knob / 2.0, theme.text_primary);
|
self.fill(Rect { x: kx, y: track.y + 3.0, w: knob, h: knob }, knob / 2.0, theme.text_primary);
|
||||||
}
|
}
|
||||||
|
Kind::Input(i) => self.field(i, r, self.focused == Some(me)),
|
||||||
}
|
}
|
||||||
if self.focused == Some(me) && e.kind.event().is_some() {
|
if self.focused == Some(me) && e.kind.focusable() {
|
||||||
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 };
|
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) {
|
if let Some(path) = rounded(ring, theme.corner_radius + RING_GAP) {
|
||||||
let stroke = Stroke { width: RING_WIDTH, ..Stroke::default() };
|
let stroke = Stroke { width: RING_WIDTH, ..Stroke::default() };
|
||||||
@@ -147,6 +154,46 @@ impl Painter<'_, '_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A field: its text (the end of it, if it is too long), or its
|
||||||
|
/// placeholder dimmed, and the caret while it has focus.
|
||||||
|
fn field<E>(&mut self, i: &crate::element::Input<E>, r: Rect, focused: bool) {
|
||||||
|
let theme = self.theme;
|
||||||
|
self.fill(r, theme.corner_radius, theme.surface);
|
||||||
|
if let Some(path) = rounded(r, theme.corner_radius) {
|
||||||
|
let stroke = Stroke { width: OUTLINE, ..Stroke::default() };
|
||||||
|
self.pm.stroke_path(&path, &paint(theme.surface_raised), &stroke, Transform::identity(), None);
|
||||||
|
}
|
||||||
|
let inner = self.inset(r);
|
||||||
|
let shown: String = match i.secret {
|
||||||
|
true => i.value.chars().map(|_| '*').collect(),
|
||||||
|
false => i.value.clone(),
|
||||||
|
};
|
||||||
|
let (size, weight) = theme.text(TextStyle::Body);
|
||||||
|
let width = |s: &str| self.fonts.line(s, size, weight).0;
|
||||||
|
let caret = self.caret.min(shown.chars().count());
|
||||||
|
let upto: String = shown.chars().take(caret).collect();
|
||||||
|
// Moved left so the caret stays inside.
|
||||||
|
let shift = (width(&upto) - inner.w + CARET).max(0.0);
|
||||||
|
if shown.is_empty() {
|
||||||
|
if let Some(p) = &i.placeholder {
|
||||||
|
self.text_in(p, TextStyle::Body, inner, false, theme.text_secondary);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let start = shown.char_indices().find(|(at, _)| width(&shown[..*at]) >= shift).map_or(shown.len(), |(at, _)| at);
|
||||||
|
let skipped = width(&shown[..start]);
|
||||||
|
let visible: String = shown[start..].chars().scan(skipped, |x, c| {
|
||||||
|
*x += width(c.encode_utf8(&mut [0; 4]));
|
||||||
|
(*x - shift <= inner.w).then_some(c)
|
||||||
|
}).collect();
|
||||||
|
self.text_in(&visible, TextStyle::Body, Rect { x: inner.x + skipped - shift, ..inner }, false, theme.text_primary);
|
||||||
|
}
|
||||||
|
if focused {
|
||||||
|
let (_, h, _) = self.fonts.line("", size, weight);
|
||||||
|
let x = inner.x + width(&upto) - shift;
|
||||||
|
self.fill(Rect { x, y: inner.y + (inner.h - h) / 2.0, w: CARET, h }, 0.0, theme.accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `r` filled with `c`, its corners rounded by `radius`.
|
/// `r` filled with `c`, its corners rounded by `radius`.
|
||||||
fn fill(&mut self, r: Rect, radius: f32, c: Rgba) {
|
fn fill(&mut self, r: Rect, radius: f32, c: Rgba) {
|
||||||
if let Some(path) = rounded(r, radius) {
|
if let Some(path) = rounded(r, radius) {
|
||||||
@@ -162,9 +209,9 @@ impl Painter<'_, '_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Draws `root`, laid out as `rects`, with `focused` (a pre-order index)
|
/// Draws `root`, laid out as `rects`, with `focused` (a pre-order index)
|
||||||
/// ringed.
|
/// ringed, and its caret at `caret` if it is a field.
|
||||||
pub fn render<E>(pm: &mut PixmapMut, root: &Element<E>, rects: &[Rect], fonts: &Fonts, theme: &Theme, focused: Option<usize>) {
|
pub fn render<E>(pm: &mut PixmapMut, root: &Element<E>, rects: &[Rect], fonts: &Fonts, theme: &Theme, focused: Option<usize>, caret: usize) {
|
||||||
let bg = theme.background;
|
let bg = theme.background;
|
||||||
pm.fill(Color::from_rgba8(bg.0, bg.1, bg.2, bg.3));
|
pm.fill(Color::from_rgba8(bg.0, bg.1, bg.2, bg.3));
|
||||||
Painter { pm, fonts, theme, focused }.draw(root, rects, &mut 0);
|
Painter { pm, fonts, theme, focused, caret }.draw(root, rects, &mut 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,15 @@ use crate::ui::{App, Key, Outcome, Ui};
|
|||||||
|
|
||||||
fn key(k: Pressed) -> Option<Key> {
|
fn key(k: Pressed) -> Option<Key> {
|
||||||
match k {
|
match k {
|
||||||
Pressed::Tab | Pressed::Down | Pressed::Right => Some(Key::Next),
|
Pressed::Tab | Pressed::Down => Some(Key::Next),
|
||||||
Pressed::Up | Pressed::Left => Some(Key::Previous),
|
Pressed::Up => Some(Key::Previous),
|
||||||
Pressed::Enter | Pressed::Space => Some(Key::Press),
|
Pressed::Left => Some(Key::Left),
|
||||||
|
Pressed::Right => Some(Key::Right),
|
||||||
|
Pressed::Enter => Some(Key::Press),
|
||||||
|
Pressed::Space => Some(Key::Space),
|
||||||
Pressed::Escape => Some(Key::Back),
|
Pressed::Escape => Some(Key::Back),
|
||||||
|
Pressed::Backspace => Some(Key::Erase),
|
||||||
|
Pressed::Char(c @ b' '..=b'~') => Some(Key::Char(c as char)),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,3 +193,71 @@ fn rows_stretch_across_and_the_view_scrolls_to_the_focused_one() {
|
|||||||
let ringed = (0..200).any(|y| at(&pm, 160, y) == (focus.0, focus.1, focus.2));
|
let ringed = (0..200).any(|y| at(&pm, 160, y) == (focus.0, focus.1, focus.2));
|
||||||
assert!(ringed, "the last row is in sight");
|
assert!(ringed, "the last row is in sight");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A field and a button: what is typed, what was sent.
|
||||||
|
struct Form {
|
||||||
|
name: std::string::String,
|
||||||
|
sent: Option<std::string::String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum FormEvent {
|
||||||
|
Name(std::string::String),
|
||||||
|
Send,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App for Form {
|
||||||
|
type Event = FormEvent;
|
||||||
|
fn init() -> Self {
|
||||||
|
Form { name: "ab".into(), sent: None }
|
||||||
|
}
|
||||||
|
fn update(&mut self, e: FormEvent) {
|
||||||
|
match e {
|
||||||
|
FormEvent::Name(n) => self.name = n,
|
||||||
|
FormEvent::Send => self.sent = Some(self.name.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn view(&self) -> Element<FormEvent> {
|
||||||
|
column()
|
||||||
|
.child(crate::text_input("Name", self.name.clone(), FormEvent::Name).placeholder("Who?").on_submit(FormEvent::Send))
|
||||||
|
.child(button("Send", FormEvent::Send))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_field_takes_typing_its_caret_moves_and_enter_submits() {
|
||||||
|
let mut ui = Ui::<Form>::new(Theme::WHIMSY_DARK);
|
||||||
|
// Focus starts on the field, the caret at its end.
|
||||||
|
ui.key(Key::Char('c'));
|
||||||
|
assert_eq!(ui.app.name, "abc");
|
||||||
|
ui.key(Key::Left);
|
||||||
|
ui.key(Key::Left);
|
||||||
|
ui.key(Key::Space);
|
||||||
|
assert_eq!(ui.app.name, "a bc");
|
||||||
|
ui.key(Key::Erase);
|
||||||
|
ui.key(Key::Erase);
|
||||||
|
assert_eq!(ui.app.name, "bc", "nothing before the caret is left to erase");
|
||||||
|
assert_eq!(ui.key(Key::Erase), Outcome::Nothing);
|
||||||
|
ui.key(Key::Press);
|
||||||
|
assert_eq!(ui.app.sent.as_deref(), Some("bc"));
|
||||||
|
// Down leaves the field: now the keys are the button's.
|
||||||
|
ui.key(Key::Next);
|
||||||
|
assert_eq!(ui.key(Key::Char('x')), Outcome::Nothing);
|
||||||
|
assert_eq!(ui.app.name, "bc");
|
||||||
|
ui.app.sent = None;
|
||||||
|
ui.key(Key::Space);
|
||||||
|
assert_eq!(ui.app.sent.as_deref(), Some("bc"), "Space presses a button");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_field_is_drawn_with_its_label_text_and_caret() {
|
||||||
|
let fonts = Fonts::load().unwrap();
|
||||||
|
let mut ui = Ui::<Form>::new(Theme::WHIMSY_DARK);
|
||||||
|
ui.app.name = std::string::String::new();
|
||||||
|
let mut pm = tiny_skia::Pixmap::new(320, 200).unwrap();
|
||||||
|
ui.draw(&mut pm.as_mut(), &fonts);
|
||||||
|
// Somewhere on the field's left is the accent caret.
|
||||||
|
let caret = (0..320).flat_map(|x| (0..200).map(move |y| (x, y))).any(|(x, y)| at(&pm, x, y) == (T.accent.0, T.accent.1, T.accent.2));
|
||||||
|
assert!(caret, "the focused field shows its caret");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
//! An app and what K-UI keeps about it: which pressable thing has focus,
|
//! An app and what K-UI keeps about it: which thing has focus, where the
|
||||||
//! and how far the view is scrolled to keep it in sight. Keys move focus
|
//! caret is if that is a field, and how far the view is scrolled to keep
|
||||||
//! (Tab, the arrows), press (Enter, Space) and go back (Escape); a press is
|
//! it in sight. Keys move focus (Tab, the arrows), press (Enter, Space) and
|
||||||
//! the element's event, handed to the app. Pure, apart from the pixels it
|
//! go back (Escape); a field takes what is typed, Backspace, and Left and
|
||||||
//! draws into.
|
//! Right for its caret. A press or an edit is the element's event, handed
|
||||||
|
//! to the app. Pure, apart from the pixels it draws into.
|
||||||
|
|
||||||
use alloc::vec::Vec;
|
use alloc::vec::Vec;
|
||||||
use tiny_skia::PixmapMut;
|
use tiny_skia::PixmapMut;
|
||||||
|
|
||||||
use crate::element::{Element, Kind};
|
use crate::element::{Element, Input, Kind};
|
||||||
use crate::layout::{self, Rect};
|
use crate::layout::{self, Rect};
|
||||||
use crate::render;
|
use crate::render;
|
||||||
use crate::text::{Fonts, Measurer};
|
use crate::text::{Fonts, Measurer};
|
||||||
@@ -37,10 +38,20 @@ pub trait App {
|
|||||||
/// A key, as far as K-UI cares.
|
/// A key, as far as K-UI cares.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum Key {
|
pub enum Key {
|
||||||
|
/// Tab, Down.
|
||||||
Next,
|
Next,
|
||||||
|
/// Up.
|
||||||
Previous,
|
Previous,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
/// Enter.
|
||||||
Press,
|
Press,
|
||||||
|
Space,
|
||||||
Back,
|
Back,
|
||||||
|
/// A printable character typed.
|
||||||
|
Char(char),
|
||||||
|
/// Backspace.
|
||||||
|
Erase,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a key did.
|
/// What a key did.
|
||||||
@@ -59,12 +70,15 @@ pub struct Ui<A: App> {
|
|||||||
focus: usize,
|
focus: usize,
|
||||||
/// How far down the view is scrolled, in pixels.
|
/// How far down the view is scrolled, in pixels.
|
||||||
scroll: f32,
|
scroll: f32,
|
||||||
|
/// The caret in the focused field, in characters; at most its length
|
||||||
|
/// (more means the end).
|
||||||
|
caret: usize,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The pre-order indices of every pressable element in `e`.
|
/// The pre-order indices of every element focus can rest on in `e`.
|
||||||
fn pressables<E>(e: &Element<E>, index: &mut usize, out: &mut Vec<usize>) {
|
fn pressables<E>(e: &Element<E>, index: &mut usize, out: &mut Vec<usize>) {
|
||||||
if e.kind.event().is_some() {
|
if e.kind.focusable() {
|
||||||
out.push(*index);
|
out.push(*index);
|
||||||
}
|
}
|
||||||
*index += 1;
|
*index += 1;
|
||||||
@@ -86,9 +100,20 @@ fn event_at<E: Clone>(e: &Element<E>, index: &mut usize, target: usize) -> Optio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pre-order `target`th element, if it is a field.
|
||||||
|
fn input_at<'e, E>(e: &'e Element<E>, index: &mut usize, target: usize) -> Option<&'e Input<E>> {
|
||||||
|
let me = *index;
|
||||||
|
*index += 1;
|
||||||
|
match &e.kind {
|
||||||
|
Kind::Flex(f) => f.children.iter().find_map(|c| input_at(c, index, target)),
|
||||||
|
Kind::Input(i) if me == target => Some(i),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<A: App> Ui<A> {
|
impl<A: App> Ui<A> {
|
||||||
pub fn new(theme: Theme) -> Self {
|
pub fn new(theme: Theme) -> Self {
|
||||||
Self { app: A::init(), focus: 0, scroll: 0.0, theme }
|
Self { app: A::init(), focus: 0, scroll: 0.0, caret: usize::MAX, theme }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A new theme: everything is drawn in it from the next frame.
|
/// A new theme: everything is drawn in it from the next frame.
|
||||||
@@ -109,13 +134,13 @@ impl<A: App> Ui<A> {
|
|||||||
return Outcome::Leave;
|
return Outcome::Leave;
|
||||||
}
|
}
|
||||||
// A new screen: from its top.
|
// A new screen: from its top.
|
||||||
(self.focus, self.scroll) = (0, 0.0);
|
(self.focus, self.scroll, self.caret) = (0, 0.0, usize::MAX);
|
||||||
return Outcome::Changed;
|
return Outcome::Changed;
|
||||||
}
|
}
|
||||||
if self.press(key) { Outcome::Changed } else { Outcome::Nothing }
|
if self.press(key) { Outcome::Changed } else { Outcome::Nothing }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves focus or presses; whether anything changed.
|
/// Moves focus, presses or edits; whether anything changed.
|
||||||
fn press(&mut self, key: Key) -> bool {
|
fn press(&mut self, key: Key) -> bool {
|
||||||
let view = self.app.view();
|
let view = self.app.view();
|
||||||
let targets = self.pressables(&view);
|
let targets = self.pressables(&view);
|
||||||
@@ -123,25 +148,80 @@ impl<A: App> Ui<A> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let n = targets.len();
|
let n = targets.len();
|
||||||
|
let target = targets[self.focus.min(n - 1)];
|
||||||
|
if let Some(field) = input_at(&view, &mut 0, target) {
|
||||||
|
let caret_only = matches!(key, Key::Left | Key::Right);
|
||||||
|
if let Some(event) = self.edit(field, key) {
|
||||||
|
self.send(event);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if caret_only {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not a field's key: the arrows across move focus too, Space presses.
|
||||||
|
let key = match key {
|
||||||
|
Key::Left => Key::Previous,
|
||||||
|
Key::Right => Key::Next,
|
||||||
|
Key::Space => Key::Press,
|
||||||
|
k => k,
|
||||||
|
};
|
||||||
match key {
|
match key {
|
||||||
Key::Back => return false,
|
|
||||||
Key::Next => self.focus = (self.focus + 1) % n,
|
Key::Next => self.focus = (self.focus + 1) % n,
|
||||||
Key::Previous => self.focus = (self.focus + n - 1) % n,
|
Key::Previous => self.focus = (self.focus + n - 1) % n,
|
||||||
Key::Press => {
|
Key::Press => {
|
||||||
let Some(event) = event_at(&view, &mut 0, targets[self.focus.min(n - 1)]) else { return false };
|
let Some(event) = event_at(&view, &mut 0, target) else { return false };
|
||||||
|
self.send(event);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
self.caret = usize::MAX;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `key` does to the focused `field`: the event carrying its new
|
||||||
|
/// text, if it changed it. The caret moves either way.
|
||||||
|
fn edit(&mut self, field: &Input<A::Event>, key: Key) -> Option<A::Event> {
|
||||||
|
let len = field.value.chars().count();
|
||||||
|
let caret = self.caret.min(len);
|
||||||
|
let byte = |at: usize| field.value.char_indices().nth(at).map_or(field.value.len(), |(b, _)| b);
|
||||||
|
let mut text = field.value.clone();
|
||||||
|
match key {
|
||||||
|
Key::Char(c) => text.insert(byte(caret), c),
|
||||||
|
Key::Space => text.insert(byte(caret), ' '),
|
||||||
|
Key::Erase if caret > 0 => {
|
||||||
|
text.remove(byte(caret - 1));
|
||||||
|
}
|
||||||
|
Key::Left => {
|
||||||
|
self.caret = caret.saturating_sub(1);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Key::Right => {
|
||||||
|
self.caret = (caret + 1).min(len);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
self.caret = match key {
|
||||||
|
Key::Erase => caret - 1,
|
||||||
|
_ => caret + 1,
|
||||||
|
};
|
||||||
|
Some((field.on_change)(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hands `event` to the app; a new screen starts from its top.
|
||||||
|
fn send(&mut self, event: A::Event) {
|
||||||
let screen = self.app.screen();
|
let screen = self.app.screen();
|
||||||
self.app.update(event);
|
self.app.update(event);
|
||||||
if self.app.screen() != screen {
|
if self.app.screen() != screen {
|
||||||
(self.focus, self.scroll) = (0, 0.0);
|
(self.focus, self.scroll, self.caret) = (0, 0.0, usize::MAX);
|
||||||
} else {
|
} else {
|
||||||
// The view may have fewer pressable things now.
|
// The view may have fewer things to focus now.
|
||||||
let now = self.pressables(&self.app.view()).len();
|
let now = self.pressables(&self.app.view()).len();
|
||||||
self.focus = self.focus.min(now.saturating_sub(1));
|
self.focus = self.focus.min(now.saturating_sub(1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lays the view out over the whole of `pm` and draws it, scrolled so
|
/// Lays the view out over the whole of `pm` and draws it, scrolled so
|
||||||
/// the focused element is in sight.
|
/// the focused element is in sight.
|
||||||
@@ -159,7 +239,7 @@ impl<A: App> Ui<A> {
|
|||||||
for r in &mut rects {
|
for r in &mut rects {
|
||||||
r.y -= self.scroll;
|
r.y -= self.scroll;
|
||||||
}
|
}
|
||||||
render::render(pm, &view, &rects, fonts, &self.theme, focused);
|
render::render(pm, &view, &rects, fonts, &self.theme, focused, self.caret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const SESSION_SHIFT: u32 = 56;
|
|||||||
|
|
||||||
/// Where the arguments page sits: the program's arguments, NUL separated,
|
/// Where the arguments page sits: the program's arguments, NUL separated,
|
||||||
/// the first its name. Below the stack, which ends at the image.
|
/// the first its name. Below the stack, which ends at the image.
|
||||||
pub const ARGS_VA: usize = 0x8000;
|
pub const ARGS_VA: usize = 0x1000;
|
||||||
pub const ARGS_SIZE: usize = 4096;
|
pub const ARGS_SIZE: usize = 4096;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
|||||||
@@ -78,7 +78,10 @@ A thread made by `SpawnThread`/`SpawnThreadIn` starts with the caller's
|
|||||||
the slots of the console, log and storage capabilities it was handed, and
|
the slots of the console, log and storage capabilities it was handed, and
|
||||||
whether its parent put an arguments page at `ARGS_VA` (NUL-separated, the
|
whether its parent put an arguments page at `ARGS_VA` (NUL-separated, the
|
||||||
program's name first; the parent fills a page and `MapPage`s it over, so
|
program's name first; the parent fills a page and `MapPage`s it over, so
|
||||||
the program owns it and it goes when the program does).
|
the program owns it and it goes when the program does). It sits at
|
||||||
|
`0x1000`, above the unmapped page 0; the stack runs from just below the
|
||||||
|
image (`0x10000`) down toward it: 4 pages for system programs unless init's
|
||||||
|
table says more, 8 for apps.
|
||||||
|
|
||||||
Boot loads only init and storage, both packed into the kernel image (they
|
Boot loads only init and storage, both packed into the kernel image (they
|
||||||
are what reads the disk, so they cannot come from it). The disk is one ext4
|
are what reads the disk, so they cannot come from it). The disk is one ext4
|
||||||
|
|||||||
Reference in New Issue
Block a user