Introduction
kui is a UI library for Rust. You describe what is on screen as a tree of boxes and text. kui lays it out, draws it, and turns what the user does into plain data.
Two minutes to a window
cargo new hello && cd hello
kui is on crates.io, as an alpha:
cargo add kui-native
Replace src/main.rs with this:
//! Step 1 — a window with text. The smallest kui program: an `App` whose
//! `view` declares one text node, and a launcher that opens a window
//! around it. Chapter 2 of the book (`docs/book`).
//!
//! Run: cargo run -p kui-native --example tutorial_01_hello
use kui_native::{App, TextStyle, Ui};
/// The app is any type. It holds the state; this one has none yet.
struct Hello;
impl App for Hello {
/// Called once per frame. Everything on screen is declared here,
/// from scratch, every time.
fn view(&mut self, ui: &mut Ui<'_>) {
let theme = ui.theme();
ui.text("Hello, kui", TextStyle::new(24.0).color(theme.fg));
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Hello").size(360.0, 200.0).run(Hello)
}
cargo run
The first build takes a few minutes; it compiles a GPU renderer. Then a
window opens with one line of text in it. That program is the whole of
kui: an app is a type with a view, and a launcher opens a window
around it. The next chapter reads it line by line.
One idea, held throughout
Everything in kui follows from one rule:
A frame is a function of the tree you declare, the input so far, and the time.
Your app has a view. It runs once per frame and declares the whole
screen from scratch: every box, every text, every button. Nothing is
retained by you between frames. kui matches this frame’s tree to the
last one by key and does the rest.
What the user does comes back as data, not callbacks. A click is a
value. A key press is a value. Your app has an on_event that receives
those values and changes its state. The next view shows the change.
The core that does this owns no window, no clock and no GPU. Those are handed in from outside. That is why the same app runs in a window, in a test with no window, and — with the same tree — from Node, Lua or C.
How this book works
Each chapter adds one idea and ends with a program you can run. The
programs live in the kui repository
under examples/rust/tutorial/,
and the code you read here is pulled from those files, so it cannot go
stale. A code block shows the lines the chapter is about; the eye icon
in its corner reveals the rest of the file.
Read the chapters in order the first time. Each assumes the last. Later, the sidebar is an index.
Run each chapter’s program, and open the devtools while it runs (chapter 12 shows how; it is one line). Watch the events tab as you click. kui is easier to believe when you can see the data.
Who this is for
Someone who knows Rust and has not used kui. You should be comfortable with closures and enums. You do not need to know any other UI library.
If you already know what you want and need its name, the reference is a
better door. docs.rs/kui-native is the API
reference, docs/howto.md
answers “how do I…” questions, and
docs/props.md
lists every prop and event.
Setup
The introduction’s three commands are the whole setup on macOS and Windows. This page is the rest: the dependency in detail, your project, Linux, and the devtools.
The dependency
kui’s crates publish to crates.io, so the dependency is one line:
[dependencies]
kui-native = "0.1.0-alpha.42"
cargo add kui-native writes that line for you. Every version is an
alpha for now, and a version requirement does not hold an alpha still:
"0.1.0-alpha.33" admits every later 0.1.0 alpha, so it is a floor,
and Cargo.lock is what keeps the version you tested. Write
"=0.1.0-alpha.33" to pin it in the manifest.
kui-native is the batteries-included crate: a window, a GPU renderer,
the stock widgets and the App trait. Everything the book uses is
reachable from it, and its API reference is on
docs.rs. This book is the path in; docs.rs
is the map once you know the names.
Versions before 0.1.0-alpha.33 are on kui’s own Forgejo registry only. A
project that still names that registry (registry = "drydock9" on the
dependency) should drop the key: from 0.1.0-alpha.34 on, a crate taken
from there resolves its kui-* siblings from crates.io, so naming two
kui-* crates from it puts two kui_cores in one build.
Your project
The book’s programs are the files under
examples/rust/tutorial
in the kui repository, and each is complete on the page: a chapter’s code
block shows the lines it is about, and the eye icon in its corner reveals
the rest of the file. Copy the whole program into src/main.rs of a
project set up as above, and run it:
cargo run
Every chapter’s Run it line is spelled that way. From a checkout of
the repository the same program is cargo run -p kui-native --example tutorial_01_hello, with each file’s own number and name. The testing
chapter’s program carries its tests, and cargo test runs them.
Linux
The build wants pkg-config and ALSA’s headers (libasound2-dev on
Debian and Ubuntu). A window loads the rest at run time: libxkbcommon
for the keyboard, the X11 or Wayland client libraries, and Vulkan or
EGL for the GPU. A missing one fails when the window opens, with the
library’s name in the error.
macOS and Windows need nothing beyond a Rust toolchain.
The devtools
Every app can open a panel that shows the frame’s events, the node tree and the runtime’s facts. Turn it on with one call on the launcher:
kui_native::app("Hello").devtools(true).run(Hello)
or from outside, with KUI_DEVTOOLS=1 in the environment. Keep it open
while you read. Chapter 12 walks through its tabs.
Building this book
The published copy is at https://kui-book.qxuken.dev. It is built
with mdBook from docs/book in a
checkout of the repository:
cargo install mdbook
mdbook serve docs/book --open
serve rebuilds on every save and reloads the browser.
Hello, window
At the end of this chapter, a window shows one line of text.
The whole program
//! Step 1 — a window with text. The smallest kui program: an `App` whose
//! `view` declares one text node, and a launcher that opens a window
//! around it. Chapter 2 of the book (`docs/book`).
//!
//! Run: cargo run -p kui-native --example tutorial_01_hello
use kui_native::{App, TextStyle, Ui};
/// The app is any type. It holds the state; this one has none yet.
struct Hello;
impl App for Hello {
/// Called once per frame. Everything on screen is declared here,
/// from scratch, every time.
fn view(&mut self, ui: &mut Ui<'_>) {
let theme = ui.theme();
ui.text("Hello, kui", TextStyle::new(24.0).color(theme.fg));
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Hello").size(360.0, 200.0).run(Hello)
}
Run it:
cargo run
What each line does
struct Hello; — the app is a type you own. It will hold your state.
This one has none, so it is a unit struct.
impl App for Hello — App is the trait the runner drives. It has
one required method, view. The others (on_event, setup,
teardown) have defaults, and later chapters fill them in.
fn view(&mut self, ui: &mut Ui<'_>) — called once per frame. ui
is the builder for this frame’s tree. Whatever you declare through it
is what the frame shows. When view returns, the frame is complete.
ui.theme() — the colours the platform asked for: light or dark,
with the system accent. theme.fg is the foreground. Use the theme’s
colours rather than literals and the app follows the OS.
ui.text(..) — one text node. TextStyle::new(24.0) is the size in
logical pixels; .color(..) sets the colour.
kui_native::app("Hello") — a launcher. .size(w, h) is the
window’s initial size, .run(app) opens it and drives the loop until
the window closes.
Why there is no render()
You never draw. You declare, and the frame is drawn from the declaration. The next frame is declared from scratch, and kui works out what changed. This is the rule from the introduction, and it is the only thing to hold on to for the next few chapters.
Try this
- Change the text and the size. Save, run again.
- Add a second
ui.text(..)below the first. Where does it go? (Below: the root is a column. The next chapter says why.)
Where this is decided
- The
Apptrait:kui_native::App. - The frame as a function of its inputs: the Testing without a window chapter.
Layout
At the end of this chapter, the window has a header, a sidebar beside a content area, and a footer — and resizing the window keeps them in place.
Boxes
A frame is a tree of boxes. A box is a NodeSpec: a row or a column,
with a size, padding, a gap between its children, and paint.
You open a box with ui.with(spec, |ui| { ... }). The closure declares
its children. When the closure returns, the box is closed.
//! Step 2 — rows, columns and sizes. Builds on step 1 by putting the text
//! inside boxes: a header row, a body with a fixed sidebar beside a
//! growing content area, and a footer. Chapter 3 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_02_layout
use kui_native::{Align, App, NodeSpec, TextStyle, Ui};
struct Layout;
impl App for Layout {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
// The root: a column that fills the window. `with` opens a box,
// runs the closure for its children, and closes it.
ui.with(NodeSpec::column().fill().bg(t.bg), |ui| {
// A row: children go left to right. `SpaceBetween` pushes the
// title and the badge to opposite ends.
ui.with(
NodeSpec::row()
.grow_width()
.pad(16.0)
.main_align(Align::SpaceBetween)
.cross_align(Align::Center)
.bg(t.surface)
.border(1.0, t.border),
|ui| {
ui.text("Layout", TextStyle::new(18.0).color(t.fg));
ui.text_in(
NodeSpec::row()
.pad_xy(10.0, 4.0)
.radius(10.0)
.bg(t.accent_soft),
"step 2",
TextStyle::new(12.0).color(t.accent),
);
},
);
// The body takes whatever height the header and footer leave.
ui.with(NodeSpec::row().grow_width().grow_height(), |ui| {
// A fixed width...
ui.with(
NodeSpec::column()
.width(160.0)
.grow_height()
.pad(16.0)
.gap(8.0)
.bg(t.sunken),
|ui| {
for item in ["Inbox", "Drafts", "Sent"] {
ui.text(item, TextStyle::new(14.0).color(t.muted));
}
},
);
// ...and the rest. `grow_width` shares the leftover space;
// with one grower it takes all of it.
ui.with(
NodeSpec::column()
.grow_width()
.grow_height()
.center()
.gap(8.0),
|ui| {
ui.text("Sizes are three words", TextStyle::new(20.0).color(t.fg));
ui.text(
"fit (the default), fixed, or grow",
TextStyle::new(14.0).color(t.muted),
);
},
);
});
ui.text_in(
NodeSpec::row().grow_width().pad_xy(16.0, 8.0).bg(t.surface),
"a footer, as tall as its text",
TextStyle::new(12.0).color(t.faint),
);
});
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Layout").size(560.0, 360.0).run(Layout)
}
Sizes are three words
Every box has a width and a height, and each is one of three things:
| word | meaning | spelled |
|---|---|---|
| fit | as big as its children | the default |
| fixed | this many logical pixels | .width(160.0) |
| grow | share the parent’s leftover space | .grow_width() |
.fill() is grow on both axes. Two growers split the leftover evenly.
A grower with no siblings that grow takes all of it.
The sidebar above is fixed at 160. The content area grows, so it takes the rest. When the window is resized, the sidebar stays and the content moves.
Rows and columns
A row lays its children left to right. A column lays them top to bottom. That axis is the main axis; the other is the cross axis.
.main_align(..) says where leftover space goes along the main axis:
Start, Center, End, or SpaceBetween to push children apart.
.cross_align(..) aligns children on the other axis. .center() is
both at once.
The header is a row with SpaceBetween, so its title sits at the left
and its badge at the right, whatever the width.
Padding, gap, paint
.pad(16.0) is space inside the box, on all four sides. .pad_xy(x, y)
sets the horizontal and vertical padding separately. .gap(8.0) is
space between children.
.bg(colour), .radius(r) and .border(width, colour) paint the box.
A box with no paint is invisible and only takes up room.
ui.text_in(spec, text, style) is a box with one text child. It is how
you give a text a background or a size of its own.
Try this
- Make the sidebar grow too. What happens to the split? (Two growers: half each.)
- Change the body’s
row()tocolumn(). The sidebar is now above the content, and its fixed width becomes a fixed height? No — width is still width. Give it.height(80.0)and see. - Remove
.fill()from the root. The tree shrinks to fit its content.
Where this is decided
- Every layout prop, with its Node, Lua and C spelling:
docs/props.md, container props. - The layout solver, pass by pass:
docs/design.md, Layout.
State and messages
At the end of this chapter, three buttons change a number.
The shape of an app
An app has three parts:
- A model: the state, held in your struct.
- A view: a function of the model that declares the frame.
- An
on_event: a function that receives what the user did and changes the model.
The loop is: view runs, the frame is drawn, the user clicks, the
click becomes an event, on_event changes the model, view runs
again. You write the three parts. kui runs the loop.
The model
//! Step 3 — state and messages. Builds on step 2 by giving the app a
//! model, buttons that carry a message, and an `on_event` that moves the
//! model. Chapter 4 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_03_counter
use kui_native::widgets;
use kui_native::{App, Message, NodeSpec, TextStyle, Ui, UiEvent};
/// What the view can say to the app. `derive(Message)` turns each variant
/// into plain data (`{kind: "inc"}`) on the way out and back into `Msg`
/// on the way in.
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Inc,
Dec,
Reset,
}
#[derive(Default)]
struct Counter {
count: i64,
}
impl App for Counter {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column().fill().center().gap(16.0).bg(t.bg),
|ui| {
ui.text(&self.count.to_string(), TextStyle::new(56.0).color(t.fg));
ui.with(NodeSpec::row().gap(8.0), |ui| {
// A button takes the message it sends when clicked.
widgets::button(ui, "-1", Msg::Dec);
widgets::button(ui, "+1", Msg::Inc);
widgets::button(ui, "reset", Msg::Reset);
});
},
);
}
/// Every event the frame produced comes here, one at a time, after
/// the frame. A click carries the button's message.
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Inc) => self.count += 1,
Some(Msg::Dec) => self.count -= 1,
Some(Msg::Reset) => self.count = 0,
// Not ours: a resize, a focus change, a window event.
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Counter")
.size(360.0, 240.0)
.run(Counter::default())
}
The messages
A button needs to say what it means when clicked. In kui, it says so
with a value that travels through the frame and comes back in
on_event. The value is any Value — a map, a string, a number — but
building those by hand is tedious and untyped.
#[derive(Message)] does it for you:
//! Step 3 — state and messages. Builds on step 2 by giving the app a
//! model, buttons that carry a message, and an `on_event` that moves the
//! model. Chapter 4 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_03_counter
use kui_native::widgets;
use kui_native::{App, Message, NodeSpec, TextStyle, Ui, UiEvent};
/// What the view can say to the app. `derive(Message)` turns each variant
/// into plain data (`{kind: "inc"}`) on the way out and back into `Msg`
/// on the way in.
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Inc,
Dec,
Reset,
}
#[derive(Default)]
struct Counter {
count: i64,
}
impl App for Counter {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column().fill().center().gap(16.0).bg(t.bg),
|ui| {
ui.text(&self.count.to_string(), TextStyle::new(56.0).color(t.fg));
ui.with(NodeSpec::row().gap(8.0), |ui| {
// A button takes the message it sends when clicked.
widgets::button(ui, "-1", Msg::Dec);
widgets::button(ui, "+1", Msg::Inc);
widgets::button(ui, "reset", Msg::Reset);
});
},
);
}
/// Every event the frame produced comes here, one at a time, after
/// the frame. A click carries the button's message.
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Inc) => self.count += 1,
Some(Msg::Dec) => self.count -= 1,
Some(Msg::Reset) => self.count = 0,
// Not ours: a resize, a focus change, a window event.
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Counter")
.size(360.0, 240.0)
.run(Counter::default())
}
Each variant becomes a small map on the way out: Msg::Inc is
{kind: "inc"}. On the way back, ev.message::<Msg>() turns the map
into Msg::Inc again. Variants can carry fields; chapter 6 uses one.
The view
//! Step 3 — state and messages. Builds on step 2 by giving the app a
//! model, buttons that carry a message, and an `on_event` that moves the
//! model. Chapter 4 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_03_counter
use kui_native::widgets;
use kui_native::{App, Message, NodeSpec, TextStyle, Ui, UiEvent};
/// What the view can say to the app. `derive(Message)` turns each variant
/// into plain data (`{kind: "inc"}`) on the way out and back into `Msg`
/// on the way in.
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Inc,
Dec,
Reset,
}
#[derive(Default)]
struct Counter {
count: i64,
}
impl App for Counter {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column().fill().center().gap(16.0).bg(t.bg),
|ui| {
ui.text(&self.count.to_string(), TextStyle::new(56.0).color(t.fg));
ui.with(NodeSpec::row().gap(8.0), |ui| {
// A button takes the message it sends when clicked.
widgets::button(ui, "-1", Msg::Dec);
widgets::button(ui, "+1", Msg::Inc);
widgets::button(ui, "reset", Msg::Reset);
});
},
);
}
/// Every event the frame produced comes here, one at a time, after
/// the frame. A click carries the button's message.
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Inc) => self.count += 1,
Some(Msg::Dec) => self.count -= 1,
Some(Msg::Reset) => self.count = 0,
// Not ours: a resize, a focus change, a window event.
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Counter")
.size(360.0, 240.0)
.run(Counter::default())
}
widgets::button(ui, text, message) is the stock button. It draws its
rest, hover and pressed states, takes keyboard focus, and sends its
message when clicked. You give it the message and forget about it.
The handler
//! Step 3 — state and messages. Builds on step 2 by giving the app a
//! model, buttons that carry a message, and an `on_event` that moves the
//! model. Chapter 4 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_03_counter
use kui_native::widgets;
use kui_native::{App, Message, NodeSpec, TextStyle, Ui, UiEvent};
/// What the view can say to the app. `derive(Message)` turns each variant
/// into plain data (`{kind: "inc"}`) on the way out and back into `Msg`
/// on the way in.
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Inc,
Dec,
Reset,
}
#[derive(Default)]
struct Counter {
count: i64,
}
impl App for Counter {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column().fill().center().gap(16.0).bg(t.bg),
|ui| {
ui.text(&self.count.to_string(), TextStyle::new(56.0).color(t.fg));
ui.with(NodeSpec::row().gap(8.0), |ui| {
// A button takes the message it sends when clicked.
widgets::button(ui, "-1", Msg::Dec);
widgets::button(ui, "+1", Msg::Inc);
widgets::button(ui, "reset", Msg::Reset);
});
},
);
}
/// Every event the frame produced comes here, one at a time, after
/// the frame. A click carries the button's message.
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Inc) => self.count += 1,
Some(Msg::Dec) => self.count -= 1,
Some(Msg::Reset) => self.count = 0,
// Not ours: a resize, a focus change, a window event.
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Counter")
.size(360.0, 240.0)
.run(Counter::default())
}
ev.message::<Msg>() is Some when the event carries one of your
messages, and None when it is something else the frame produced — a
resize, a window losing focus. Match exhaustively and the compiler
tells you when you add a variant and forget to handle it.
on_event runs after the frame, once per event, in order. It never
runs during view. So the model changes between frames, never in the
middle of one.
Why data, not callbacks
A closure on the button would be shorter. But a closure is opaque: it cannot be logged, replayed, tested or sent across a language boundary. A message can. The devtools show every message as it arrives. A test asserts on them. Node, Lua and C receive the same maps.
Try this
- Add
Msg::Double. The compiler will point at thematch. - Put
.on_click(Msg::Inc)on the root column instead of a button (NodeSpec::column().fill().on_click(Msg::Inc)). Now the whole window is a button. Any box can carry a message. - Open the devtools (
.devtools(true)on the launcher) and watch the events tab as you click.
Where this is decided
#[derive(Message)]and its attributes:kui_deriveon docs.rs, anddocs/howto.md.- The full click payload, and every other event’s shape:
docs/props.md, events. - The reference counter, with a context menu:
examples/rust/apps/counter.rs.
Controls
At the end of this chapter, a settings card has a switch, a checkbox, a slider, a text field and a select, and each one reports through the model.
The controls keep no state
This is the thing to notice. widgets::switch(ui, "Notifications", self.notify, Msg::Notify) draws the switch on when self.notify is
true. The switch does not remember whether it is on. Your model does.
Click it, and Msg::Notify arrives. Your handler flips self.notify.
The next frame draws the switch from the new value. If your handler did
nothing, the switch would not move.
//! Step 4 — the stock controls. Builds on step 3 by replacing the
//! buttons with a settings card: a switch, a checkbox, a slider, a text
//! field and a select, each drawn from the model and each reporting
//! through a message. Chapter 5 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_04_controls
use kui_native::widgets;
use kui_native::{Align, App, Key, Message, NodeSpec, TextStyle, Ui, UiEvent};
const LANGUAGES: [&str; 3] = ["English", "Deutsch", "日本語"];
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
/// A toggle flips: the click is the message, nothing else.
Notify,
Sound,
/// A slider proposes a value: the message is a tag, the value rides
/// beside it on the event.
Volume,
}
struct Settings {
notify: bool,
sound: bool,
volume: f32,
language: usize,
/// The select's key, kept so `on_event` knows its choice by key.
language_key: Option<Key>,
}
impl Default for Settings {
fn default() -> Self {
Self {
notify: true,
sound: false,
volume: 40.0,
language: 0,
language_key: None,
}
}
}
impl App for Settings {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(NodeSpec::column().fill().center().bg(t.bg), |ui| {
ui.with(
NodeSpec::column()
.width(320.0)
.pad(20.0)
.gap(14.0)
.cross_align(Align::Start)
.bg(t.surface)
.radius(12.0)
.border(1.0, t.border),
|ui| {
ui.text("Settings", TextStyle::new(18.0).color(t.fg));
// Each control is drawn from the model: `self.notify`
// says whether the switch is on. The control keeps no
// state of its own.
widgets::switch(ui, "Notifications", self.notify, Msg::Notify);
widgets::checkbox(ui, "Play a sound", self.sound, Msg::Sound);
ui.with(NodeSpec::row().gap(12.0).cross_align(Align::Center), |ui| {
widgets::slider(ui, "Volume", self.volume, 0.0, 100.0, 5.0, Msg::Volume);
ui.text(
&format!("{:.0}", self.volume),
TextStyle::new(13.0).color(t.muted),
);
});
// The editor owns its text between frames. The view
// reads it back by key.
let name = widgets::text_input(ui, "Display name", "");
let typed = ui.edit_text(name).unwrap_or_default();
ui.text(
&format!(
"Hello, {}",
if typed.is_empty() { "stranger" } else { &typed }
),
TextStyle::new(13.0).color(t.muted),
);
// A select posts the chosen row on its own key.
self.language_key = Some(widgets::select(
ui,
"Language",
&LANGUAGES,
Some(self.language),
));
},
);
});
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Notify) => self.notify = !self.notify,
Some(Msg::Sound) => self.sound = !self.sound,
Some(Msg::Volume) => {
// A `change` event: the message was its tag, the value is
// a field beside it.
if let Some(v) = ev.payload.get_float("value") {
self.volume = v as f32;
}
}
None => {
// The select speaks by key, not by message: a `menu`
// event with the chosen label as `item`.
if Some(ev.key) == self.language_key
&& let Some(item) = ev.payload.get_str("item")
&& let Some(i) = LANGUAGES.iter().position(|l| *l == item)
{
self.language = i;
}
}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Settings")
.size(480.0, 420.0)
.run(Settings::default())
}
The model and its messages
//! Step 4 — the stock controls. Builds on step 3 by replacing the
//! buttons with a settings card: a switch, a checkbox, a slider, a text
//! field and a select, each drawn from the model and each reporting
//! through a message. Chapter 5 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_04_controls
use kui_native::widgets;
use kui_native::{Align, App, Key, Message, NodeSpec, TextStyle, Ui, UiEvent};
const LANGUAGES: [&str; 3] = ["English", "Deutsch", "日本語"];
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
/// A toggle flips: the click is the message, nothing else.
Notify,
Sound,
/// A slider proposes a value: the message is a tag, the value rides
/// beside it on the event.
Volume,
}
struct Settings {
notify: bool,
sound: bool,
volume: f32,
language: usize,
/// The select's key, kept so `on_event` knows its choice by key.
language_key: Option<Key>,
}
impl Default for Settings {
fn default() -> Self {
Self {
notify: true,
sound: false,
volume: 40.0,
language: 0,
language_key: None,
}
}
}
impl App for Settings {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(NodeSpec::column().fill().center().bg(t.bg), |ui| {
ui.with(
NodeSpec::column()
.width(320.0)
.pad(20.0)
.gap(14.0)
.cross_align(Align::Start)
.bg(t.surface)
.radius(12.0)
.border(1.0, t.border),
|ui| {
ui.text("Settings", TextStyle::new(18.0).color(t.fg));
// Each control is drawn from the model: `self.notify`
// says whether the switch is on. The control keeps no
// state of its own.
widgets::switch(ui, "Notifications", self.notify, Msg::Notify);
widgets::checkbox(ui, "Play a sound", self.sound, Msg::Sound);
ui.with(NodeSpec::row().gap(12.0).cross_align(Align::Center), |ui| {
widgets::slider(ui, "Volume", self.volume, 0.0, 100.0, 5.0, Msg::Volume);
ui.text(
&format!("{:.0}", self.volume),
TextStyle::new(13.0).color(t.muted),
);
});
// The editor owns its text between frames. The view
// reads it back by key.
let name = widgets::text_input(ui, "Display name", "");
let typed = ui.edit_text(name).unwrap_or_default();
ui.text(
&format!(
"Hello, {}",
if typed.is_empty() { "stranger" } else { &typed }
),
TextStyle::new(13.0).color(t.muted),
);
// A select posts the chosen row on its own key.
self.language_key = Some(widgets::select(
ui,
"Language",
&LANGUAGES,
Some(self.language),
));
},
);
});
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Notify) => self.notify = !self.notify,
Some(Msg::Sound) => self.sound = !self.sound,
Some(Msg::Volume) => {
// A `change` event: the message was its tag, the value is
// a field beside it.
if let Some(v) = ev.payload.get_float("value") {
self.volume = v as f32;
}
}
None => {
// The select speaks by key, not by message: a `menu`
// event with the chosen label as `item`.
if Some(ev.key) == self.language_key
&& let Some(item) = ev.payload.get_str("item")
&& let Some(i) = LANGUAGES.iter().position(|l| *l == item)
{
self.language = i;
}
}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Settings")
.size(480.0, 420.0)
.run(Settings::default())
}
//! Step 4 — the stock controls. Builds on step 3 by replacing the
//! buttons with a settings card: a switch, a checkbox, a slider, a text
//! field and a select, each drawn from the model and each reporting
//! through a message. Chapter 5 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_04_controls
use kui_native::widgets;
use kui_native::{Align, App, Key, Message, NodeSpec, TextStyle, Ui, UiEvent};
const LANGUAGES: [&str; 3] = ["English", "Deutsch", "日本語"];
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
/// A toggle flips: the click is the message, nothing else.
Notify,
Sound,
/// A slider proposes a value: the message is a tag, the value rides
/// beside it on the event.
Volume,
}
struct Settings {
notify: bool,
sound: bool,
volume: f32,
language: usize,
/// The select's key, kept so `on_event` knows its choice by key.
language_key: Option<Key>,
}
impl Default for Settings {
fn default() -> Self {
Self {
notify: true,
sound: false,
volume: 40.0,
language: 0,
language_key: None,
}
}
}
impl App for Settings {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(NodeSpec::column().fill().center().bg(t.bg), |ui| {
ui.with(
NodeSpec::column()
.width(320.0)
.pad(20.0)
.gap(14.0)
.cross_align(Align::Start)
.bg(t.surface)
.radius(12.0)
.border(1.0, t.border),
|ui| {
ui.text("Settings", TextStyle::new(18.0).color(t.fg));
// Each control is drawn from the model: `self.notify`
// says whether the switch is on. The control keeps no
// state of its own.
widgets::switch(ui, "Notifications", self.notify, Msg::Notify);
widgets::checkbox(ui, "Play a sound", self.sound, Msg::Sound);
ui.with(NodeSpec::row().gap(12.0).cross_align(Align::Center), |ui| {
widgets::slider(ui, "Volume", self.volume, 0.0, 100.0, 5.0, Msg::Volume);
ui.text(
&format!("{:.0}", self.volume),
TextStyle::new(13.0).color(t.muted),
);
});
// The editor owns its text between frames. The view
// reads it back by key.
let name = widgets::text_input(ui, "Display name", "");
let typed = ui.edit_text(name).unwrap_or_default();
ui.text(
&format!(
"Hello, {}",
if typed.is_empty() { "stranger" } else { &typed }
),
TextStyle::new(13.0).color(t.muted),
);
// A select posts the chosen row on its own key.
self.language_key = Some(widgets::select(
ui,
"Language",
&LANGUAGES,
Some(self.language),
));
},
);
});
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Notify) => self.notify = !self.notify,
Some(Msg::Sound) => self.sound = !self.sound,
Some(Msg::Volume) => {
// A `change` event: the message was its tag, the value is
// a field beside it.
if let Some(v) = ev.payload.get_float("value") {
self.volume = v as f32;
}
}
None => {
// The select speaks by key, not by message: a `menu`
// event with the chosen label as `item`.
if Some(ev.key) == self.language_key
&& let Some(item) = ev.payload.get_str("item")
&& let Some(i) = LANGUAGES.iter().position(|l| *l == item)
{
self.language = i;
}
}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Settings")
.size(480.0, 420.0)
.run(Settings::default())
}
A slider proposes a value
A toggle’s message is the whole story: the click is the change. A slider is different. The user drags to some value, and the slider needs to tell you which.
//! Step 4 — the stock controls. Builds on step 3 by replacing the
//! buttons with a settings card: a switch, a checkbox, a slider, a text
//! field and a select, each drawn from the model and each reporting
//! through a message. Chapter 5 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_04_controls
use kui_native::widgets;
use kui_native::{Align, App, Key, Message, NodeSpec, TextStyle, Ui, UiEvent};
const LANGUAGES: [&str; 3] = ["English", "Deutsch", "日本語"];
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
/// A toggle flips: the click is the message, nothing else.
Notify,
Sound,
/// A slider proposes a value: the message is a tag, the value rides
/// beside it on the event.
Volume,
}
struct Settings {
notify: bool,
sound: bool,
volume: f32,
language: usize,
/// The select's key, kept so `on_event` knows its choice by key.
language_key: Option<Key>,
}
impl Default for Settings {
fn default() -> Self {
Self {
notify: true,
sound: false,
volume: 40.0,
language: 0,
language_key: None,
}
}
}
impl App for Settings {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(NodeSpec::column().fill().center().bg(t.bg), |ui| {
ui.with(
NodeSpec::column()
.width(320.0)
.pad(20.0)
.gap(14.0)
.cross_align(Align::Start)
.bg(t.surface)
.radius(12.0)
.border(1.0, t.border),
|ui| {
ui.text("Settings", TextStyle::new(18.0).color(t.fg));
// Each control is drawn from the model: `self.notify`
// says whether the switch is on. The control keeps no
// state of its own.
widgets::switch(ui, "Notifications", self.notify, Msg::Notify);
widgets::checkbox(ui, "Play a sound", self.sound, Msg::Sound);
ui.with(NodeSpec::row().gap(12.0).cross_align(Align::Center), |ui| {
widgets::slider(ui, "Volume", self.volume, 0.0, 100.0, 5.0, Msg::Volume);
ui.text(
&format!("{:.0}", self.volume),
TextStyle::new(13.0).color(t.muted),
);
});
// The editor owns its text between frames. The view
// reads it back by key.
let name = widgets::text_input(ui, "Display name", "");
let typed = ui.edit_text(name).unwrap_or_default();
ui.text(
&format!(
"Hello, {}",
if typed.is_empty() { "stranger" } else { &typed }
),
TextStyle::new(13.0).color(t.muted),
);
// A select posts the chosen row on its own key.
self.language_key = Some(widgets::select(
ui,
"Language",
&LANGUAGES,
Some(self.language),
));
},
);
});
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Notify) => self.notify = !self.notify,
Some(Msg::Sound) => self.sound = !self.sound,
Some(Msg::Volume) => {
// A `change` event: the message was its tag, the value is
// a field beside it.
if let Some(v) = ev.payload.get_float("value") {
self.volume = v as f32;
}
}
None => {
// The select speaks by key, not by message: a `menu`
// event with the chosen label as `item`.
if Some(ev.key) == self.language_key
&& let Some(item) = ev.payload.get_str("item")
&& let Some(i) = LANGUAGES.iter().position(|l| *l == item)
{
self.language = i;
}
}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Settings")
.size(480.0, 420.0)
.run(Settings::default())
}
The message you give a slider is a tag. When the user drags, a
change event arrives with your tag on it and the proposed value
beside it. Store the value, and the next frame draws the knob there.
The value is snapped to the slider’s step (5, here) and clamped to its range before it reaches you.
Two kinds of data on an event
This is the first event that carries more than your message, so it is
worth being precise about what ev holds.
ev.payload is a Value: a small JSON-like map. Two things write into
it.
- You, through the message.
Msg::Volumebecomes{kind: "volume"}, andev.message::<Msg>()turns it back into the enum. The next chapter puts fields on a message —Msg::Toggle { id }— and those come back typed too, because the derive knows them. - The core, through the event’s own fields. A
changeevent addsvalue. A drag addsx,yandphase. A key press addscodeand the modifiers. The core does not know your enum, so these sit beside your message in the map, and you read them by name:ev.payload.get_float("value"),get_str("code"),get_int(..),get_bool(..). Each returns anOption,Nonewhen the field is absent.
So the pattern for any event that carries more than a click is: match
the message to learn which control spoke, then read the core’s fields
to learn what it said. The events table of
docs/props.md lists every event’s fields.
A text field owns its text
//! Step 4 — the stock controls. Builds on step 3 by replacing the
//! buttons with a settings card: a switch, a checkbox, a slider, a text
//! field and a select, each drawn from the model and each reporting
//! through a message. Chapter 5 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_04_controls
use kui_native::widgets;
use kui_native::{Align, App, Key, Message, NodeSpec, TextStyle, Ui, UiEvent};
const LANGUAGES: [&str; 3] = ["English", "Deutsch", "日本語"];
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
/// A toggle flips: the click is the message, nothing else.
Notify,
Sound,
/// A slider proposes a value: the message is a tag, the value rides
/// beside it on the event.
Volume,
}
struct Settings {
notify: bool,
sound: bool,
volume: f32,
language: usize,
/// The select's key, kept so `on_event` knows its choice by key.
language_key: Option<Key>,
}
impl Default for Settings {
fn default() -> Self {
Self {
notify: true,
sound: false,
volume: 40.0,
language: 0,
language_key: None,
}
}
}
impl App for Settings {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(NodeSpec::column().fill().center().bg(t.bg), |ui| {
ui.with(
NodeSpec::column()
.width(320.0)
.pad(20.0)
.gap(14.0)
.cross_align(Align::Start)
.bg(t.surface)
.radius(12.0)
.border(1.0, t.border),
|ui| {
ui.text("Settings", TextStyle::new(18.0).color(t.fg));
// Each control is drawn from the model: `self.notify`
// says whether the switch is on. The control keeps no
// state of its own.
widgets::switch(ui, "Notifications", self.notify, Msg::Notify);
widgets::checkbox(ui, "Play a sound", self.sound, Msg::Sound);
ui.with(NodeSpec::row().gap(12.0).cross_align(Align::Center), |ui| {
widgets::slider(ui, "Volume", self.volume, 0.0, 100.0, 5.0, Msg::Volume);
ui.text(
&format!("{:.0}", self.volume),
TextStyle::new(13.0).color(t.muted),
);
});
// The editor owns its text between frames. The view
// reads it back by key.
let name = widgets::text_input(ui, "Display name", "");
let typed = ui.edit_text(name).unwrap_or_default();
ui.text(
&format!(
"Hello, {}",
if typed.is_empty() { "stranger" } else { &typed }
),
TextStyle::new(13.0).color(t.muted),
);
// A select posts the chosen row on its own key.
self.language_key = Some(widgets::select(
ui,
"Language",
&LANGUAGES,
Some(self.language),
));
},
);
});
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Notify) => self.notify = !self.notify,
Some(Msg::Sound) => self.sound = !self.sound,
Some(Msg::Volume) => {
// A `change` event: the message was its tag, the value is
// a field beside it.
if let Some(v) = ev.payload.get_float("value") {
self.volume = v as f32;
}
}
None => {
// The select speaks by key, not by message: a `menu`
// event with the chosen label as `item`.
if Some(ev.key) == self.language_key
&& let Some(item) = ev.payload.get_str("item")
&& let Some(i) = LANGUAGES.iter().position(|l| *l == item)
{
self.language = i;
}
}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Settings")
.size(480.0, 420.0)
.run(Settings::default())
}
Typing is the one thing the frame cannot redo from the model every
frame — the caret, the selection and the undo history live between
frames. So an editor keeps its text, and the view reads it back with
ui.edit_text(key). text_input returns the key.
If you need to set the text, ui.set_edit_text(key, "..") does it.
A select speaks by key
//! Step 4 — the stock controls. Builds on step 3 by replacing the
//! buttons with a settings card: a switch, a checkbox, a slider, a text
//! field and a select, each drawn from the model and each reporting
//! through a message. Chapter 5 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_04_controls
use kui_native::widgets;
use kui_native::{Align, App, Key, Message, NodeSpec, TextStyle, Ui, UiEvent};
const LANGUAGES: [&str; 3] = ["English", "Deutsch", "日本語"];
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
/// A toggle flips: the click is the message, nothing else.
Notify,
Sound,
/// A slider proposes a value: the message is a tag, the value rides
/// beside it on the event.
Volume,
}
struct Settings {
notify: bool,
sound: bool,
volume: f32,
language: usize,
/// The select's key, kept so `on_event` knows its choice by key.
language_key: Option<Key>,
}
impl Default for Settings {
fn default() -> Self {
Self {
notify: true,
sound: false,
volume: 40.0,
language: 0,
language_key: None,
}
}
}
impl App for Settings {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(NodeSpec::column().fill().center().bg(t.bg), |ui| {
ui.with(
NodeSpec::column()
.width(320.0)
.pad(20.0)
.gap(14.0)
.cross_align(Align::Start)
.bg(t.surface)
.radius(12.0)
.border(1.0, t.border),
|ui| {
ui.text("Settings", TextStyle::new(18.0).color(t.fg));
// Each control is drawn from the model: `self.notify`
// says whether the switch is on. The control keeps no
// state of its own.
widgets::switch(ui, "Notifications", self.notify, Msg::Notify);
widgets::checkbox(ui, "Play a sound", self.sound, Msg::Sound);
ui.with(NodeSpec::row().gap(12.0).cross_align(Align::Center), |ui| {
widgets::slider(ui, "Volume", self.volume, 0.0, 100.0, 5.0, Msg::Volume);
ui.text(
&format!("{:.0}", self.volume),
TextStyle::new(13.0).color(t.muted),
);
});
// The editor owns its text between frames. The view
// reads it back by key.
let name = widgets::text_input(ui, "Display name", "");
let typed = ui.edit_text(name).unwrap_or_default();
ui.text(
&format!(
"Hello, {}",
if typed.is_empty() { "stranger" } else { &typed }
),
TextStyle::new(13.0).color(t.muted),
);
// A select posts the chosen row on its own key.
self.language_key = Some(widgets::select(
ui,
"Language",
&LANGUAGES,
Some(self.language),
));
},
);
});
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Notify) => self.notify = !self.notify,
Some(Msg::Sound) => self.sound = !self.sound,
Some(Msg::Volume) => {
// A `change` event: the message was its tag, the value is
// a field beside it.
if let Some(v) = ev.payload.get_float("value") {
self.volume = v as f32;
}
}
None => {
// The select speaks by key, not by message: a `menu`
// event with the chosen label as `item`.
if Some(ev.key) == self.language_key
&& let Some(item) = ev.payload.get_str("item")
&& let Some(i) = LANGUAGES.iter().position(|l| *l == item)
{
self.language = i;
}
}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Settings")
.size(480.0, 420.0)
.run(Settings::default())
}
A select opens a menu of its options. Choosing one posts a menu event
on the select’s own key, with the chosen label as item. The handler
recognises it by comparing ev.key to the key the view kept.
The handler
//! Step 4 — the stock controls. Builds on step 3 by replacing the
//! buttons with a settings card: a switch, a checkbox, a slider, a text
//! field and a select, each drawn from the model and each reporting
//! through a message. Chapter 5 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_04_controls
use kui_native::widgets;
use kui_native::{Align, App, Key, Message, NodeSpec, TextStyle, Ui, UiEvent};
const LANGUAGES: [&str; 3] = ["English", "Deutsch", "日本語"];
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
/// A toggle flips: the click is the message, nothing else.
Notify,
Sound,
/// A slider proposes a value: the message is a tag, the value rides
/// beside it on the event.
Volume,
}
struct Settings {
notify: bool,
sound: bool,
volume: f32,
language: usize,
/// The select's key, kept so `on_event` knows its choice by key.
language_key: Option<Key>,
}
impl Default for Settings {
fn default() -> Self {
Self {
notify: true,
sound: false,
volume: 40.0,
language: 0,
language_key: None,
}
}
}
impl App for Settings {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(NodeSpec::column().fill().center().bg(t.bg), |ui| {
ui.with(
NodeSpec::column()
.width(320.0)
.pad(20.0)
.gap(14.0)
.cross_align(Align::Start)
.bg(t.surface)
.radius(12.0)
.border(1.0, t.border),
|ui| {
ui.text("Settings", TextStyle::new(18.0).color(t.fg));
// Each control is drawn from the model: `self.notify`
// says whether the switch is on. The control keeps no
// state of its own.
widgets::switch(ui, "Notifications", self.notify, Msg::Notify);
widgets::checkbox(ui, "Play a sound", self.sound, Msg::Sound);
ui.with(NodeSpec::row().gap(12.0).cross_align(Align::Center), |ui| {
widgets::slider(ui, "Volume", self.volume, 0.0, 100.0, 5.0, Msg::Volume);
ui.text(
&format!("{:.0}", self.volume),
TextStyle::new(13.0).color(t.muted),
);
});
// The editor owns its text between frames. The view
// reads it back by key.
let name = widgets::text_input(ui, "Display name", "");
let typed = ui.edit_text(name).unwrap_or_default();
ui.text(
&format!(
"Hello, {}",
if typed.is_empty() { "stranger" } else { &typed }
),
TextStyle::new(13.0).color(t.muted),
);
// A select posts the chosen row on its own key.
self.language_key = Some(widgets::select(
ui,
"Language",
&LANGUAGES,
Some(self.language),
));
},
);
});
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Notify) => self.notify = !self.notify,
Some(Msg::Sound) => self.sound = !self.sound,
Some(Msg::Volume) => {
// A `change` event: the message was its tag, the value is
// a field beside it.
if let Some(v) = ev.payload.get_float("value") {
self.volume = v as f32;
}
}
None => {
// The select speaks by key, not by message: a `menu`
// event with the chosen label as `item`.
if Some(ev.key) == self.language_key
&& let Some(item) = ev.payload.get_str("item")
&& let Some(i) = LANGUAGES.iter().position(|l| *l == item)
{
self.language = i;
}
}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Settings")
.size(480.0, 420.0)
.run(Settings::default())
}
Try this
- Disable the checkbox while notifications are off. The stock widgets
have a
_withform that takes a spec:toggle_with(.., toggle_spec(&m) .checked(..).disabled(!self.notify).on_click(..), None). Look atwidgets/controls.rs. - Show the volume as a bar whose width is
Sizing::Percent(self.volume / 100.0).
Where this is decided
- Every stock widget:
kui_core::widgets, re-exported askui_native::widgets. - The stock controls are built over the accessibility roles, so a switch
is a
switchto a screen reader and a Tab stop with nothing more from you. - The
change,changed,submitandmenuevents:docs/props.md, events.
Lists and keys
At the end of this chapter, a to-do list adds, ticks, removes and filters rows, in a box that scrolls.
Rows from a Vec
A list is a loop in view. For each item in the model, declare a row.
That is all.
//! Step 5 — a list with keys. Builds on step 4 by drawing a list of
//! items from a `Vec`, each row under a key of its own so it keeps its
//! hover and focus when rows above it come and go, inside a box that
//! scrolls. Chapter 6 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_05_list
use kui_native::widgets;
use kui_native::{Align, App, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Add,
/// A message can carry data: which item.
Toggle {
id: u64,
},
Remove {
id: u64,
},
Filter {
done: bool,
},
ShowAll,
}
struct Item {
id: u64,
text: String,
done: bool,
}
struct Todo {
items: Vec<Item>,
next_id: u64,
/// `None` shows everything; `Some(done)` only those.
filter: Option<bool>,
}
impl Default for Todo {
fn default() -> Self {
let mut todo = Self {
items: Vec::new(),
next_id: 1,
filter: None,
};
for _ in 0..3 {
todo.add();
}
todo
}
}
impl Todo {
fn add(&mut self) {
self.items.push(Item {
id: self.next_id,
text: format!("Task {}", self.next_id),
done: false,
});
self.next_id += 1;
}
}
impl App for Todo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg),
|ui| {
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Add", Msg::Add);
widgets::button(ui, "All", Msg::ShowAll);
widgets::button(ui, "Active", Msg::Filter { done: false });
widgets::button(ui, "Done", Msg::Filter { done: true });
});
// A fixed height and `scroll_y`: the rows inside can be
// taller than the box, and the wheel moves them.
ui.with(
NodeSpec::column()
.grow_width()
.height(220.0)
.scroll_y()
.gap(4.0)
.pad(8.0)
.bg(t.surface)
.radius(8.0)
.border(1.0, t.border),
|ui| {
let shown = self
.items
.iter()
.filter(|i| self.filter.is_none_or(|d| i.done == d));
for item in shown {
// The row's key is the item's id, not its
// position. Remove the row above and this one
// is still itself: same hover, same focus.
ui.with_indexed(
item.id,
NodeSpec::row()
.grow_width()
.gap(8.0)
.pad_xy(8.0, 4.0)
.radius(6.0)
.cross_align(Align::Center)
.hover_bg(t.sunken),
|ui| {
widgets::checkbox(
ui,
&item.text,
item.done,
Msg::Toggle { id: item.id },
);
ui.leaf(NodeSpec::row().grow_width());
widgets::button(ui, "×", Msg::Remove { id: item.id });
},
);
}
},
);
let done = self.items.iter().filter(|i| i.done).count();
ui.text(
&format!("{done} of {} done", self.items.len()),
TextStyle::new(12.0).color(t.muted),
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Add) => self.add(),
Some(Msg::Toggle { id }) => {
if let Some(item) = self.items.iter_mut().find(|i| i.id == id) {
item.done = !item.done;
}
}
Some(Msg::Remove { id }) => self.items.retain(|i| i.id != id),
Some(Msg::Filter { done }) => self.filter = Some(done),
Some(Msg::ShowAll) => self.filter = None,
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Todo")
.size(420.0, 360.0)
.run(Todo::default())
}
//! Step 5 — a list with keys. Builds on step 4 by drawing a list of
//! items from a `Vec`, each row under a key of its own so it keeps its
//! hover and focus when rows above it come and go, inside a box that
//! scrolls. Chapter 6 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_05_list
use kui_native::widgets;
use kui_native::{Align, App, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Add,
/// A message can carry data: which item.
Toggle {
id: u64,
},
Remove {
id: u64,
},
Filter {
done: bool,
},
ShowAll,
}
struct Item {
id: u64,
text: String,
done: bool,
}
struct Todo {
items: Vec<Item>,
next_id: u64,
/// `None` shows everything; `Some(done)` only those.
filter: Option<bool>,
}
impl Default for Todo {
fn default() -> Self {
let mut todo = Self {
items: Vec::new(),
next_id: 1,
filter: None,
};
for _ in 0..3 {
todo.add();
}
todo
}
}
impl Todo {
fn add(&mut self) {
self.items.push(Item {
id: self.next_id,
text: format!("Task {}", self.next_id),
done: false,
});
self.next_id += 1;
}
}
impl App for Todo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg),
|ui| {
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Add", Msg::Add);
widgets::button(ui, "All", Msg::ShowAll);
widgets::button(ui, "Active", Msg::Filter { done: false });
widgets::button(ui, "Done", Msg::Filter { done: true });
});
// A fixed height and `scroll_y`: the rows inside can be
// taller than the box, and the wheel moves them.
ui.with(
NodeSpec::column()
.grow_width()
.height(220.0)
.scroll_y()
.gap(4.0)
.pad(8.0)
.bg(t.surface)
.radius(8.0)
.border(1.0, t.border),
|ui| {
let shown = self
.items
.iter()
.filter(|i| self.filter.is_none_or(|d| i.done == d));
for item in shown {
// The row's key is the item's id, not its
// position. Remove the row above and this one
// is still itself: same hover, same focus.
ui.with_indexed(
item.id,
NodeSpec::row()
.grow_width()
.gap(8.0)
.pad_xy(8.0, 4.0)
.radius(6.0)
.cross_align(Align::Center)
.hover_bg(t.sunken),
|ui| {
widgets::checkbox(
ui,
&item.text,
item.done,
Msg::Toggle { id: item.id },
);
ui.leaf(NodeSpec::row().grow_width());
widgets::button(ui, "×", Msg::Remove { id: item.id });
},
);
}
},
);
let done = self.items.iter().filter(|i| i.done).count();
ui.text(
&format!("{done} of {} done", self.items.len()),
TextStyle::new(12.0).color(t.muted),
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Add) => self.add(),
Some(Msg::Toggle { id }) => {
if let Some(item) = self.items.iter_mut().find(|i| i.id == id) {
item.done = !item.done;
}
}
Some(Msg::Remove { id }) => self.items.retain(|i| i.id != id),
Some(Msg::Filter { done }) => self.filter = Some(done),
Some(Msg::ShowAll) => self.filter = None,
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Todo")
.size(420.0, 360.0)
.run(Todo::default())
}
Keys
Here is the question keys answer. Frame 1 declares rows A, B, C. Frame 2 declares B, C — A was removed. Is frame 2’s first row a new row, or is it B moved up?
kui needs to know, because a row carries things between frames: its hover, its focus, its scroll position, a transition in flight. If the first row is “B moved up”, B keeps its hover. If it is “a new row”, everything resets.
By default a child’s key is its position, so frame 2’s first row would be taken for A. That is wrong for a list. So each row gets a key of its own, from the data:
ui.with_indexed(id, spec, ..)— the key is a number you own: an id, a database row.ui.with_keyed("name", spec, ..)— the key is a string. The stock widgets use their text this way, which is why a button is found by its label.ui.with(spec, ..)— the key is the position. Fine for a layout that does not change shape.
Two rows with the same key in one frame is a mistake, and kui says so
with a duplicate-key warning.
Messages with fields
Each row’s checkbox and remove button need to say which row. A message variant carries the id:
//! Step 5 — a list with keys. Builds on step 4 by drawing a list of
//! items from a `Vec`, each row under a key of its own so it keeps its
//! hover and focus when rows above it come and go, inside a box that
//! scrolls. Chapter 6 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_05_list
use kui_native::widgets;
use kui_native::{Align, App, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Add,
/// A message can carry data: which item.
Toggle {
id: u64,
},
Remove {
id: u64,
},
Filter {
done: bool,
},
ShowAll,
}
struct Item {
id: u64,
text: String,
done: bool,
}
struct Todo {
items: Vec<Item>,
next_id: u64,
/// `None` shows everything; `Some(done)` only those.
filter: Option<bool>,
}
impl Default for Todo {
fn default() -> Self {
let mut todo = Self {
items: Vec::new(),
next_id: 1,
filter: None,
};
for _ in 0..3 {
todo.add();
}
todo
}
}
impl Todo {
fn add(&mut self) {
self.items.push(Item {
id: self.next_id,
text: format!("Task {}", self.next_id),
done: false,
});
self.next_id += 1;
}
}
impl App for Todo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg),
|ui| {
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Add", Msg::Add);
widgets::button(ui, "All", Msg::ShowAll);
widgets::button(ui, "Active", Msg::Filter { done: false });
widgets::button(ui, "Done", Msg::Filter { done: true });
});
// A fixed height and `scroll_y`: the rows inside can be
// taller than the box, and the wheel moves them.
ui.with(
NodeSpec::column()
.grow_width()
.height(220.0)
.scroll_y()
.gap(4.0)
.pad(8.0)
.bg(t.surface)
.radius(8.0)
.border(1.0, t.border),
|ui| {
let shown = self
.items
.iter()
.filter(|i| self.filter.is_none_or(|d| i.done == d));
for item in shown {
// The row's key is the item's id, not its
// position. Remove the row above and this one
// is still itself: same hover, same focus.
ui.with_indexed(
item.id,
NodeSpec::row()
.grow_width()
.gap(8.0)
.pad_xy(8.0, 4.0)
.radius(6.0)
.cross_align(Align::Center)
.hover_bg(t.sunken),
|ui| {
widgets::checkbox(
ui,
&item.text,
item.done,
Msg::Toggle { id: item.id },
);
ui.leaf(NodeSpec::row().grow_width());
widgets::button(ui, "×", Msg::Remove { id: item.id });
},
);
}
},
);
let done = self.items.iter().filter(|i| i.done).count();
ui.text(
&format!("{done} of {} done", self.items.len()),
TextStyle::new(12.0).color(t.muted),
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Add) => self.add(),
Some(Msg::Toggle { id }) => {
if let Some(item) = self.items.iter_mut().find(|i| i.id == id) {
item.done = !item.done;
}
}
Some(Msg::Remove { id }) => self.items.retain(|i| i.id != id),
Some(Msg::Filter { done }) => self.filter = Some(done),
Some(Msg::ShowAll) => self.filter = None,
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Todo")
.size(420.0, 360.0)
.run(Todo::default())
}
//! Step 5 — a list with keys. Builds on step 4 by drawing a list of
//! items from a `Vec`, each row under a key of its own so it keeps its
//! hover and focus when rows above it come and go, inside a box that
//! scrolls. Chapter 6 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_05_list
use kui_native::widgets;
use kui_native::{Align, App, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Add,
/// A message can carry data: which item.
Toggle {
id: u64,
},
Remove {
id: u64,
},
Filter {
done: bool,
},
ShowAll,
}
struct Item {
id: u64,
text: String,
done: bool,
}
struct Todo {
items: Vec<Item>,
next_id: u64,
/// `None` shows everything; `Some(done)` only those.
filter: Option<bool>,
}
impl Default for Todo {
fn default() -> Self {
let mut todo = Self {
items: Vec::new(),
next_id: 1,
filter: None,
};
for _ in 0..3 {
todo.add();
}
todo
}
}
impl Todo {
fn add(&mut self) {
self.items.push(Item {
id: self.next_id,
text: format!("Task {}", self.next_id),
done: false,
});
self.next_id += 1;
}
}
impl App for Todo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg),
|ui| {
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Add", Msg::Add);
widgets::button(ui, "All", Msg::ShowAll);
widgets::button(ui, "Active", Msg::Filter { done: false });
widgets::button(ui, "Done", Msg::Filter { done: true });
});
// A fixed height and `scroll_y`: the rows inside can be
// taller than the box, and the wheel moves them.
ui.with(
NodeSpec::column()
.grow_width()
.height(220.0)
.scroll_y()
.gap(4.0)
.pad(8.0)
.bg(t.surface)
.radius(8.0)
.border(1.0, t.border),
|ui| {
let shown = self
.items
.iter()
.filter(|i| self.filter.is_none_or(|d| i.done == d));
for item in shown {
// The row's key is the item's id, not its
// position. Remove the row above and this one
// is still itself: same hover, same focus.
ui.with_indexed(
item.id,
NodeSpec::row()
.grow_width()
.gap(8.0)
.pad_xy(8.0, 4.0)
.radius(6.0)
.cross_align(Align::Center)
.hover_bg(t.sunken),
|ui| {
widgets::checkbox(
ui,
&item.text,
item.done,
Msg::Toggle { id: item.id },
);
ui.leaf(NodeSpec::row().grow_width());
widgets::button(ui, "×", Msg::Remove { id: item.id });
},
);
}
},
);
let done = self.items.iter().filter(|i| i.done).count();
ui.text(
&format!("{done} of {} done", self.items.len()),
TextStyle::new(12.0).color(t.muted),
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Add) => self.add(),
Some(Msg::Toggle { id }) => {
if let Some(item) = self.items.iter_mut().find(|i| i.id == id) {
item.done = !item.done;
}
}
Some(Msg::Remove { id }) => self.items.retain(|i| i.id != id),
Some(Msg::Filter { done }) => self.filter = Some(done),
Some(Msg::ShowAll) => self.filter = None,
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Todo")
.size(420.0, 360.0)
.run(Todo::default())
}
Scrolling
.scroll_y() on a box with a fixed height makes its content scroll.
The wheel moves it, a scrollbar appears when it overflows, and the
scroll position is kept between frames — under the box’s key, which is
why the list box would need a key of its own if it ever moved.
When the list is long
A loop over ten thousand rows declares ten thousand rows every frame.
For that, widgets::uniform_list declares only the rows in view and
two spacers, from a row height and a count. It is the same idea with
the loop inverted; see
widgets/virtual_list.rs
when you need it.
Try this
- Remove
with_indexedand useui.with(..)for the rows. Hover a row, then remove the row above it. The hover jumps. - Add a “Clear done” button. One
retain.
Where this is decided
- Keys, and the
duplicate-keywarning:docs/props.md, warnings.
Keyboard and focus
At the end of this chapter, the arrow keys move a cursor over a grid, space lights a cell, Tab walks the cells and the button, and the screen says where focus is.
Focus is one node
At any moment, one node has keyboard focus, or none does. Tab moves it to the next node in the ring: every button, control and editor, in tree order. Shift-Tab moves back. Enter or space presses the focused button.
You get all of that without writing anything. The stock widgets are in
the ring. A plain box joins it with .focusable().
//! Step 6 — keys and focus. Builds on step 5 by listening to the
//! keyboard: a sink at the root hears every key as data, the arrows move
//! a cursor over a grid, Tab walks the buttons, and the view reads where
//! focus is. Chapter 7 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_06_keyboard
use kui_native::widgets;
use kui_native::{Align, App, KeyCode, KeyPhase, Message, NodeSpec, TextStyle, Ui, UiEvent};
const SIDE: usize = 4;
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
/// The tag on the key sink: every key press arrives under it.
Key,
Pick {
cell: usize,
},
Clear,
}
#[derive(Default)]
struct Grid {
cursor: usize,
lit: [bool; SIDE * SIDE],
last_key: String,
}
impl App for Grid {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
// `on_key` makes this box a key sink. A key goes to the focused
// node and bubbles up to the nearest sink above it, so the root
// hears the arrows even while a cell or a button has focus.
// `take_key_focus` below gives the sink focus on its first frame,
// so it hears keys before anything is clicked.
let sink = ui.with_keyed(
"app",
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg)
.on_key(Msg::Key),
|ui| {
// Where focus is, as the core knows it, by the node's label.
let focused = ui
.focused()
.and_then(|k| ui.core().label_of(k).map(str::to_string))
.unwrap_or_else(|| "nothing".into());
let by_keyboard = ui.focus_visible();
ui.text(
&format!(
"focus: {focused}{} · last key: {}",
if by_keyboard { " (by keyboard)" } else { "" },
self.last_key
),
TextStyle::new(12.0).color(t.muted),
);
ui.with(NodeSpec::column().gap(4.0), |ui| {
for row in 0..SIDE {
ui.with(NodeSpec::row().gap(4.0), |ui| {
for col in 0..SIDE {
let i = row * SIDE + col;
let bg = match (self.lit[i], i == self.cursor) {
(true, _) => t.accent,
(false, true) => t.accent_soft,
(false, false) => t.sunken,
};
// `focusable` puts a plain box in the Tab
// ring; `focus_bg` is what it shows there.
// A clickable box is a button to a screen
// reader, so `label` gives it a name.
ui.leaf_keyed(
&format!("cell {i}"),
NodeSpec::row()
.size(36.0, 36.0)
.radius(6.0)
.bg(bg)
.label(format!("cell {i}"))
.focusable()
.focus_bg(t.accent_hover)
.on_click(Msg::Pick { cell: i }),
);
}
});
}
});
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Clear", Msg::Clear);
ui.text(
"arrows move · space lights · Tab walks · Enter presses",
TextStyle::new(12.0).color(t.faint),
);
});
},
);
ui.take_key_focus(sink);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Key) => {
// The message says which sink; `key_press` reads the key
// event's own fields off the same payload, typed.
let Some((KeyPhase::Down, key)) = ev.key_press() else {
return;
};
self.last_key = format!("{:?}", key.code);
let (row, col) = (self.cursor / SIDE, self.cursor % SIDE);
match key.code {
KeyCode::Left => self.cursor = row * SIDE + col.saturating_sub(1),
KeyCode::Right => self.cursor = row * SIDE + (col + 1).min(SIDE - 1),
KeyCode::Up => self.cursor = row.saturating_sub(1) * SIDE + col,
KeyCode::Down => self.cursor = (row + 1).min(SIDE - 1) * SIDE + col,
KeyCode::Space => self.lit[self.cursor] = !self.lit[self.cursor],
_ => {}
}
}
Some(Msg::Pick { cell }) => {
self.cursor = cell;
self.lit[cell] = !self.lit[cell];
}
Some(Msg::Clear) => self.lit = [false; SIDE * SIDE],
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Keyboard")
.size(420.0, 320.0)
.run(Grid::default())
}
.focus_bg(..) is what a focusable box shows while focused. Without
it, kui draws a ring.
Reading focus
The view can ask where focus is:
//! Step 6 — keys and focus. Builds on step 5 by listening to the
//! keyboard: a sink at the root hears every key as data, the arrows move
//! a cursor over a grid, Tab walks the buttons, and the view reads where
//! focus is. Chapter 7 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_06_keyboard
use kui_native::widgets;
use kui_native::{Align, App, KeyCode, KeyPhase, Message, NodeSpec, TextStyle, Ui, UiEvent};
const SIDE: usize = 4;
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
/// The tag on the key sink: every key press arrives under it.
Key,
Pick {
cell: usize,
},
Clear,
}
#[derive(Default)]
struct Grid {
cursor: usize,
lit: [bool; SIDE * SIDE],
last_key: String,
}
impl App for Grid {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
// `on_key` makes this box a key sink. A key goes to the focused
// node and bubbles up to the nearest sink above it, so the root
// hears the arrows even while a cell or a button has focus.
// `take_key_focus` below gives the sink focus on its first frame,
// so it hears keys before anything is clicked.
let sink = ui.with_keyed(
"app",
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg)
.on_key(Msg::Key),
|ui| {
// Where focus is, as the core knows it, by the node's label.
let focused = ui
.focused()
.and_then(|k| ui.core().label_of(k).map(str::to_string))
.unwrap_or_else(|| "nothing".into());
let by_keyboard = ui.focus_visible();
ui.text(
&format!(
"focus: {focused}{} · last key: {}",
if by_keyboard { " (by keyboard)" } else { "" },
self.last_key
),
TextStyle::new(12.0).color(t.muted),
);
ui.with(NodeSpec::column().gap(4.0), |ui| {
for row in 0..SIDE {
ui.with(NodeSpec::row().gap(4.0), |ui| {
for col in 0..SIDE {
let i = row * SIDE + col;
let bg = match (self.lit[i], i == self.cursor) {
(true, _) => t.accent,
(false, true) => t.accent_soft,
(false, false) => t.sunken,
};
// `focusable` puts a plain box in the Tab
// ring; `focus_bg` is what it shows there.
// A clickable box is a button to a screen
// reader, so `label` gives it a name.
ui.leaf_keyed(
&format!("cell {i}"),
NodeSpec::row()
.size(36.0, 36.0)
.radius(6.0)
.bg(bg)
.label(format!("cell {i}"))
.focusable()
.focus_bg(t.accent_hover)
.on_click(Msg::Pick { cell: i }),
);
}
});
}
});
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Clear", Msg::Clear);
ui.text(
"arrows move · space lights · Tab walks · Enter presses",
TextStyle::new(12.0).color(t.faint),
);
});
},
);
ui.take_key_focus(sink);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Key) => {
// The message says which sink; `key_press` reads the key
// event's own fields off the same payload, typed.
let Some((KeyPhase::Down, key)) = ev.key_press() else {
return;
};
self.last_key = format!("{:?}", key.code);
let (row, col) = (self.cursor / SIDE, self.cursor % SIDE);
match key.code {
KeyCode::Left => self.cursor = row * SIDE + col.saturating_sub(1),
KeyCode::Right => self.cursor = row * SIDE + (col + 1).min(SIDE - 1),
KeyCode::Up => self.cursor = row.saturating_sub(1) * SIDE + col,
KeyCode::Down => self.cursor = (row + 1).min(SIDE - 1) * SIDE + col,
KeyCode::Space => self.lit[self.cursor] = !self.lit[self.cursor],
_ => {}
}
}
Some(Msg::Pick { cell }) => {
self.cursor = cell;
self.lit[cell] = !self.lit[cell];
}
Some(Msg::Clear) => self.lit = [false; SIDE * SIDE],
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Keyboard")
.size(420.0, 320.0)
.run(Grid::default())
}
ui.focus_visible() is true when focus got there by keyboard, and
false after a click. It is how a view shows a focus ring only when the
ring is useful.
Keys are events on a sink
A key press is data, like a click. It is delivered to a key sink: a
box with .on_key(tag). The press goes to the focused node and bubbles
up to the nearest sink above it. So a sink at the root hears every key,
whatever is focused inside it.
The tag is a message of yours, like a button’s — except a sink sends it on every key rather than on a click. This app has one sink, so one variant with no fields is enough to say “a key arrived”:
//! Step 6 — keys and focus. Builds on step 5 by listening to the
//! keyboard: a sink at the root hears every key as data, the arrows move
//! a cursor over a grid, Tab walks the buttons, and the view reads where
//! focus is. Chapter 7 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_06_keyboard
use kui_native::widgets;
use kui_native::{Align, App, KeyCode, KeyPhase, Message, NodeSpec, TextStyle, Ui, UiEvent};
const SIDE: usize = 4;
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
/// The tag on the key sink: every key press arrives under it.
Key,
Pick {
cell: usize,
},
Clear,
}
#[derive(Default)]
struct Grid {
cursor: usize,
lit: [bool; SIDE * SIDE],
last_key: String,
}
impl App for Grid {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
// `on_key` makes this box a key sink. A key goes to the focused
// node and bubbles up to the nearest sink above it, so the root
// hears the arrows even while a cell or a button has focus.
// `take_key_focus` below gives the sink focus on its first frame,
// so it hears keys before anything is clicked.
let sink = ui.with_keyed(
"app",
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg)
.on_key(Msg::Key),
|ui| {
// Where focus is, as the core knows it, by the node's label.
let focused = ui
.focused()
.and_then(|k| ui.core().label_of(k).map(str::to_string))
.unwrap_or_else(|| "nothing".into());
let by_keyboard = ui.focus_visible();
ui.text(
&format!(
"focus: {focused}{} · last key: {}",
if by_keyboard { " (by keyboard)" } else { "" },
self.last_key
),
TextStyle::new(12.0).color(t.muted),
);
ui.with(NodeSpec::column().gap(4.0), |ui| {
for row in 0..SIDE {
ui.with(NodeSpec::row().gap(4.0), |ui| {
for col in 0..SIDE {
let i = row * SIDE + col;
let bg = match (self.lit[i], i == self.cursor) {
(true, _) => t.accent,
(false, true) => t.accent_soft,
(false, false) => t.sunken,
};
// `focusable` puts a plain box in the Tab
// ring; `focus_bg` is what it shows there.
// A clickable box is a button to a screen
// reader, so `label` gives it a name.
ui.leaf_keyed(
&format!("cell {i}"),
NodeSpec::row()
.size(36.0, 36.0)
.radius(6.0)
.bg(bg)
.label(format!("cell {i}"))
.focusable()
.focus_bg(t.accent_hover)
.on_click(Msg::Pick { cell: i }),
);
}
});
}
});
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Clear", Msg::Clear);
ui.text(
"arrows move · space lights · Tab walks · Enter presses",
TextStyle::new(12.0).color(t.faint),
);
});
},
);
ui.take_key_focus(sink);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Key) => {
// The message says which sink; `key_press` reads the key
// event's own fields off the same payload, typed.
let Some((KeyPhase::Down, key)) = ev.key_press() else {
return;
};
self.last_key = format!("{:?}", key.code);
let (row, col) = (self.cursor / SIDE, self.cursor % SIDE);
match key.code {
KeyCode::Left => self.cursor = row * SIDE + col.saturating_sub(1),
KeyCode::Right => self.cursor = row * SIDE + (col + 1).min(SIDE - 1),
KeyCode::Up => self.cursor = row.saturating_sub(1) * SIDE + col,
KeyCode::Down => self.cursor = (row + 1).min(SIDE - 1) * SIDE + col,
KeyCode::Space => self.lit[self.cursor] = !self.lit[self.cursor],
_ => {}
}
}
Some(Msg::Pick { cell }) => {
self.cursor = cell;
self.lit[cell] = !self.lit[cell];
}
Some(Msg::Clear) => self.lit = [false; SIDE * SIDE],
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Keyboard")
.size(420.0, 320.0)
.run(Grid::default())
}
//! Step 6 — keys and focus. Builds on step 5 by listening to the
//! keyboard: a sink at the root hears every key as data, the arrows move
//! a cursor over a grid, Tab walks the buttons, and the view reads where
//! focus is. Chapter 7 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_06_keyboard
use kui_native::widgets;
use kui_native::{Align, App, KeyCode, KeyPhase, Message, NodeSpec, TextStyle, Ui, UiEvent};
const SIDE: usize = 4;
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
/// The tag on the key sink: every key press arrives under it.
Key,
Pick {
cell: usize,
},
Clear,
}
#[derive(Default)]
struct Grid {
cursor: usize,
lit: [bool; SIDE * SIDE],
last_key: String,
}
impl App for Grid {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
// `on_key` makes this box a key sink. A key goes to the focused
// node and bubbles up to the nearest sink above it, so the root
// hears the arrows even while a cell or a button has focus.
// `take_key_focus` below gives the sink focus on its first frame,
// so it hears keys before anything is clicked.
let sink = ui.with_keyed(
"app",
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg)
.on_key(Msg::Key),
|ui| {
// Where focus is, as the core knows it, by the node's label.
let focused = ui
.focused()
.and_then(|k| ui.core().label_of(k).map(str::to_string))
.unwrap_or_else(|| "nothing".into());
let by_keyboard = ui.focus_visible();
ui.text(
&format!(
"focus: {focused}{} · last key: {}",
if by_keyboard { " (by keyboard)" } else { "" },
self.last_key
),
TextStyle::new(12.0).color(t.muted),
);
ui.with(NodeSpec::column().gap(4.0), |ui| {
for row in 0..SIDE {
ui.with(NodeSpec::row().gap(4.0), |ui| {
for col in 0..SIDE {
let i = row * SIDE + col;
let bg = match (self.lit[i], i == self.cursor) {
(true, _) => t.accent,
(false, true) => t.accent_soft,
(false, false) => t.sunken,
};
// `focusable` puts a plain box in the Tab
// ring; `focus_bg` is what it shows there.
// A clickable box is a button to a screen
// reader, so `label` gives it a name.
ui.leaf_keyed(
&format!("cell {i}"),
NodeSpec::row()
.size(36.0, 36.0)
.radius(6.0)
.bg(bg)
.label(format!("cell {i}"))
.focusable()
.focus_bg(t.accent_hover)
.on_click(Msg::Pick { cell: i }),
);
}
});
}
});
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Clear", Msg::Clear);
ui.text(
"arrows move · space lights · Tab walks · Enter presses",
TextStyle::new(12.0).color(t.faint),
);
});
},
);
ui.take_key_focus(sink);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Key) => {
// The message says which sink; `key_press` reads the key
// event's own fields off the same payload, typed.
let Some((KeyPhase::Down, key)) = ev.key_press() else {
return;
};
self.last_key = format!("{:?}", key.code);
let (row, col) = (self.cursor / SIDE, self.cursor % SIDE);
match key.code {
KeyCode::Left => self.cursor = row * SIDE + col.saturating_sub(1),
KeyCode::Right => self.cursor = row * SIDE + (col + 1).min(SIDE - 1),
KeyCode::Up => self.cursor = row.saturating_sub(1) * SIDE + col,
KeyCode::Down => self.cursor = (row + 1).min(SIDE - 1) * SIDE + col,
KeyCode::Space => self.lit[self.cursor] = !self.lit[self.cursor],
_ => {}
}
}
Some(Msg::Pick { cell }) => {
self.cursor = cell;
self.lit[cell] = !self.lit[cell];
}
Some(Msg::Clear) => self.lit = [false; SIDE * SIDE],
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Keyboard")
.size(420.0, 320.0)
.run(Grid::default())
}
A sink hears keys only while something under it has focus. On the
first frame nothing does, so the view gives the sink focus itself with
ui.take_key_focus(sink). That runs on the frame the declaration
starts and not again, so a later Tab is not undone.
Reading a key
//! Step 6 — keys and focus. Builds on step 5 by listening to the
//! keyboard: a sink at the root hears every key as data, the arrows move
//! a cursor over a grid, Tab walks the buttons, and the view reads where
//! focus is. Chapter 7 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_06_keyboard
use kui_native::widgets;
use kui_native::{Align, App, KeyCode, KeyPhase, Message, NodeSpec, TextStyle, Ui, UiEvent};
const SIDE: usize = 4;
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
/// The tag on the key sink: every key press arrives under it.
Key,
Pick {
cell: usize,
},
Clear,
}
#[derive(Default)]
struct Grid {
cursor: usize,
lit: [bool; SIDE * SIDE],
last_key: String,
}
impl App for Grid {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
// `on_key` makes this box a key sink. A key goes to the focused
// node and bubbles up to the nearest sink above it, so the root
// hears the arrows even while a cell or a button has focus.
// `take_key_focus` below gives the sink focus on its first frame,
// so it hears keys before anything is clicked.
let sink = ui.with_keyed(
"app",
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg)
.on_key(Msg::Key),
|ui| {
// Where focus is, as the core knows it, by the node's label.
let focused = ui
.focused()
.and_then(|k| ui.core().label_of(k).map(str::to_string))
.unwrap_or_else(|| "nothing".into());
let by_keyboard = ui.focus_visible();
ui.text(
&format!(
"focus: {focused}{} · last key: {}",
if by_keyboard { " (by keyboard)" } else { "" },
self.last_key
),
TextStyle::new(12.0).color(t.muted),
);
ui.with(NodeSpec::column().gap(4.0), |ui| {
for row in 0..SIDE {
ui.with(NodeSpec::row().gap(4.0), |ui| {
for col in 0..SIDE {
let i = row * SIDE + col;
let bg = match (self.lit[i], i == self.cursor) {
(true, _) => t.accent,
(false, true) => t.accent_soft,
(false, false) => t.sunken,
};
// `focusable` puts a plain box in the Tab
// ring; `focus_bg` is what it shows there.
// A clickable box is a button to a screen
// reader, so `label` gives it a name.
ui.leaf_keyed(
&format!("cell {i}"),
NodeSpec::row()
.size(36.0, 36.0)
.radius(6.0)
.bg(bg)
.label(format!("cell {i}"))
.focusable()
.focus_bg(t.accent_hover)
.on_click(Msg::Pick { cell: i }),
);
}
});
}
});
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Clear", Msg::Clear);
ui.text(
"arrows move · space lights · Tab walks · Enter presses",
TextStyle::new(12.0).color(t.faint),
);
});
},
);
ui.take_key_focus(sink);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Key) => {
// The message says which sink; `key_press` reads the key
// event's own fields off the same payload, typed.
let Some((KeyPhase::Down, key)) = ev.key_press() else {
return;
};
self.last_key = format!("{:?}", key.code);
let (row, col) = (self.cursor / SIDE, self.cursor % SIDE);
match key.code {
KeyCode::Left => self.cursor = row * SIDE + col.saturating_sub(1),
KeyCode::Right => self.cursor = row * SIDE + (col + 1).min(SIDE - 1),
KeyCode::Up => self.cursor = row.saturating_sub(1) * SIDE + col,
KeyCode::Down => self.cursor = (row + 1).min(SIDE - 1) * SIDE + col,
KeyCode::Space => self.lit[self.cursor] = !self.lit[self.cursor],
_ => {}
}
}
Some(Msg::Pick { cell }) => {
self.cursor = cell;
self.lit[cell] = !self.lit[cell];
}
Some(Msg::Clear) => self.lit = [false; SIDE * SIDE],
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Keyboard")
.size(420.0, 320.0)
.run(Grid::default())
}
The message says which sink. ev.key_press() reads the key itself:
a KeyPhase (down or up — a sink hears releases only if it asks with
.key_up()) and a KeyPress with the code, the modifiers and the
text the press would type.
KeyCode::Char('a') is a character key, as the keyboard layout
produced it. KeyCode::Left and the rest are the named keys.
Try this
- Bind
KeyCode::Enterto lighting the cell too. - Press Tab until a cell has focus, then space. The cell lights —
and it was the sink that heard the space, not the cell. Now make the
cell a real button (
.on_click) and see Enter press it.
Where this is decided
- The full
keyevent:docs/props.md, events. - The reference example, with focus regions and the verbs that move
focus:
examples/rust/features/focus.rs.
Floats and modals
At the end of this chapter, hovering a badge shows a tooltip, and a Delete button opens a dialog that Escape closes.
A float is out of the flow
Everything so far sat inside its parent and took up room. A float does neither: it is positioned against an anchor and drawn on top. Tooltips, menus and dialogs are floats.
The tooltip prop
//! Step 7 — floats and a modal. Builds on step 6 by drawing over the
//! page: a tooltip the core floats under a hovered node, and a confirm
//! dialog the app declares as a `modal` float and stops declaring when
//! it is answered or dismissed. Chapter 8 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_07_floats
use kui_native::widgets;
use kui_native::{Align, App, FloatConfig, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Ask,
Confirm,
Cancel,
/// The modal's tag: a `dismiss` (Escape, or a press outside) arrives
/// under it.
Dialog,
}
#[derive(Default)]
struct Floats {
asking: bool,
deleted: u32,
}
impl App for Floats {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg),
|ui| {
// `tooltip` is a prop: the node tracks its own hover, and
// the core floats the hint below it while hovered. The
// same text is what a screen reader says after the name.
ui.text_in(
NodeSpec::row()
.pad_xy(10.0, 6.0)
.radius(8.0)
.bg(t.raised)
.border(1.0, t.border)
.tooltip("A float: out of the flow, on top, unclipped"),
"hover me",
TextStyle::new(13.0).color(t.fg),
);
ui.with(NodeSpec::row().gap(8.0).cross_align(Align::Center), |ui| {
widgets::button(ui, "Delete…", Msg::Ask);
ui.text(
&format!("deleted {} times", self.deleted),
TextStyle::new(12.0).color(t.muted),
);
});
// Declared last, so it stacks on top. `modal` scopes Tab
// to it, keeps the pointer from what is behind, and turns
// Escape or a press outside into a `dismiss` event.
if self.asking {
ui.with_keyed(
"dialog",
NodeSpec::column()
.float(FloatConfig::viewport().inside(Align::Center, Align::Center))
.modal(Msg::Dialog)
.label("Delete?")
.width(260.0)
.pad(16.0)
.gap(12.0)
.bg(t.raised)
.radius(10.0)
.border(1.0, t.border_strong),
|ui| {
ui.text("Delete the thing?", TextStyle::new(15.0).color(t.fg));
ui.text(
"This cannot be undone.",
TextStyle::new(12.0).color(t.muted),
);
ui.with(NodeSpec::row().gap(8.0).main_align(Align::End), |ui| {
widgets::button(ui, "Cancel", Msg::Cancel);
widgets::button(ui, "Delete", Msg::Confirm);
});
},
);
}
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Ask) => self.asking = true,
Some(Msg::Confirm) => {
self.deleted += 1;
self.asking = false;
}
// The core closes nothing. Both a Cancel click and a dismiss
// end the same way: the view stops declaring the dialog.
Some(Msg::Cancel) | Some(Msg::Dialog) => self.asking = false,
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Floats")
.size(420.0, 280.0)
.run(Floats::default())
}
.tooltip(text) is enough. The node tracks its own hover, and kui
floats the text below it while hovered. Near the bottom of the window
it flips above, so it never hangs off the edge. The same text is what a
screen reader says after the node’s name.
A modal the app declares
A dialog is a float you declare when the model says so and stop declaring when it is answered:
//! Step 7 — floats and a modal. Builds on step 6 by drawing over the
//! page: a tooltip the core floats under a hovered node, and a confirm
//! dialog the app declares as a `modal` float and stops declaring when
//! it is answered or dismissed. Chapter 8 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_07_floats
use kui_native::widgets;
use kui_native::{Align, App, FloatConfig, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Ask,
Confirm,
Cancel,
/// The modal's tag: a `dismiss` (Escape, or a press outside) arrives
/// under it.
Dialog,
}
#[derive(Default)]
struct Floats {
asking: bool,
deleted: u32,
}
impl App for Floats {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg),
|ui| {
// `tooltip` is a prop: the node tracks its own hover, and
// the core floats the hint below it while hovered. The
// same text is what a screen reader says after the name.
ui.text_in(
NodeSpec::row()
.pad_xy(10.0, 6.0)
.radius(8.0)
.bg(t.raised)
.border(1.0, t.border)
.tooltip("A float: out of the flow, on top, unclipped"),
"hover me",
TextStyle::new(13.0).color(t.fg),
);
ui.with(NodeSpec::row().gap(8.0).cross_align(Align::Center), |ui| {
widgets::button(ui, "Delete…", Msg::Ask);
ui.text(
&format!("deleted {} times", self.deleted),
TextStyle::new(12.0).color(t.muted),
);
});
// Declared last, so it stacks on top. `modal` scopes Tab
// to it, keeps the pointer from what is behind, and turns
// Escape or a press outside into a `dismiss` event.
if self.asking {
ui.with_keyed(
"dialog",
NodeSpec::column()
.float(FloatConfig::viewport().inside(Align::Center, Align::Center))
.modal(Msg::Dialog)
.label("Delete?")
.width(260.0)
.pad(16.0)
.gap(12.0)
.bg(t.raised)
.radius(10.0)
.border(1.0, t.border_strong),
|ui| {
ui.text("Delete the thing?", TextStyle::new(15.0).color(t.fg));
ui.text(
"This cannot be undone.",
TextStyle::new(12.0).color(t.muted),
);
ui.with(NodeSpec::row().gap(8.0).main_align(Align::End), |ui| {
widgets::button(ui, "Cancel", Msg::Cancel);
widgets::button(ui, "Delete", Msg::Confirm);
});
},
);
}
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Ask) => self.asking = true,
Some(Msg::Confirm) => {
self.deleted += 1;
self.asking = false;
}
// The core closes nothing. Both a Cancel click and a dismiss
// end the same way: the view stops declaring the dialog.
Some(Msg::Cancel) | Some(Msg::Dialog) => self.asking = false,
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Floats")
.size(420.0, 280.0)
.run(Floats::default())
}
Three props do the work:
.float(FloatConfig::viewport().inside(Center, Center))— anchored to the window, centred.FloatConfig::parent()anchors to the parent box instead, and.offset(x, y)nudges either..modal(tag)— while this float is declared, Tab stays inside it, the pointer cannot reach what is behind it, and Escape or a press outside becomes adismissevent carrying the tag..label("Delete?")— the dialog’s name. A button is named by the text inside it, but a dialog is not: a screen reader announcing “dialog” with nothing after it tells the user nothing, and the platforms do not build a name from a dialog’s contents. So a modal needs alabel, and kui warns withmodal-without-nameif it has none. The label is not drawn; the title text inside the dialog is a separate node, and it is fine for the two to say the same thing.
kui does not close the dialog. It reports dismiss, and the app
decides. Here it stops declaring:
//! Step 7 — floats and a modal. Builds on step 6 by drawing over the
//! page: a tooltip the core floats under a hovered node, and a confirm
//! dialog the app declares as a `modal` float and stops declaring when
//! it is answered or dismissed. Chapter 8 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_07_floats
use kui_native::widgets;
use kui_native::{Align, App, FloatConfig, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Ask,
Confirm,
Cancel,
/// The modal's tag: a `dismiss` (Escape, or a press outside) arrives
/// under it.
Dialog,
}
#[derive(Default)]
struct Floats {
asking: bool,
deleted: u32,
}
impl App for Floats {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg),
|ui| {
// `tooltip` is a prop: the node tracks its own hover, and
// the core floats the hint below it while hovered. The
// same text is what a screen reader says after the name.
ui.text_in(
NodeSpec::row()
.pad_xy(10.0, 6.0)
.radius(8.0)
.bg(t.raised)
.border(1.0, t.border)
.tooltip("A float: out of the flow, on top, unclipped"),
"hover me",
TextStyle::new(13.0).color(t.fg),
);
ui.with(NodeSpec::row().gap(8.0).cross_align(Align::Center), |ui| {
widgets::button(ui, "Delete…", Msg::Ask);
ui.text(
&format!("deleted {} times", self.deleted),
TextStyle::new(12.0).color(t.muted),
);
});
// Declared last, so it stacks on top. `modal` scopes Tab
// to it, keeps the pointer from what is behind, and turns
// Escape or a press outside into a `dismiss` event.
if self.asking {
ui.with_keyed(
"dialog",
NodeSpec::column()
.float(FloatConfig::viewport().inside(Align::Center, Align::Center))
.modal(Msg::Dialog)
.label("Delete?")
.width(260.0)
.pad(16.0)
.gap(12.0)
.bg(t.raised)
.radius(10.0)
.border(1.0, t.border_strong),
|ui| {
ui.text("Delete the thing?", TextStyle::new(15.0).color(t.fg));
ui.text(
"This cannot be undone.",
TextStyle::new(12.0).color(t.muted),
);
ui.with(NodeSpec::row().gap(8.0).main_align(Align::End), |ui| {
widgets::button(ui, "Cancel", Msg::Cancel);
widgets::button(ui, "Delete", Msg::Confirm);
});
},
);
}
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Ask) => self.asking = true,
Some(Msg::Confirm) => {
self.deleted += 1;
self.asking = false;
}
// The core closes nothing. Both a Cancel click and a dismiss
// end the same way: the view stops declaring the dialog.
Some(Msg::Cancel) | Some(Msg::Dialog) => self.asking = false,
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Floats")
.size(420.0, 280.0)
.run(Floats::default())
}
Order matters
Floats stack in the order they are declared. The dialog is declared
last in view, so it is on top. Declare it first and the page would
paint over it — and kui would warn, modal-behind-content.
Try this
- Right-click anywhere and open a menu there.
.on_context_menu(tag)on the root delivers acontextmenuevent withxandy; declare a modal float atFloatConfig::viewport().inside(Start, Start) .offset(x, y).fit(). The reference counter does exactly this. - Give the dialog
.initial_focus()on the Cancel button, so Enter cancels.
Where this is decided
- The reference examples:
widgets/tooltip.rs,features/modal.rs,apps/counter.rsfor the context menu.
Motion
At the end of this chapter, a panel springs between two widths, and toasts slide in and out.
The messages
Nothing new here — a toggle, a notify, and a dismiss that names a toast:
//! Step 8 — motion. Builds on step 7 by letting the core ease what
//! changes: a panel whose width eases between two values, and toasts
//! that slide in when declared and out when they stop being declared.
//! Chapter 9 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_08_motion
use kui_native::widgets;
use kui_native::{Align, App, Easing, Enter, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Toggle,
Notify,
Dismiss { id: u64 },
}
#[derive(Default)]
struct Motion {
wide: bool,
toasts: Vec<(u64, String)>,
next_id: u64,
}
impl App for Motion {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg),
|ui| {
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Toggle width", Msg::Toggle);
widgets::button(ui, "Notify", Msg::Notify);
});
// `transition(ms)`: when a value on this node changes
// between frames, the core eases from the old to the new
// over that long. The view just declares the target.
ui.with_keyed(
"panel",
NodeSpec::column()
.width(if self.wide { 360.0 } else { 160.0 })
.height(80.0)
.center()
.bg(t.accent_soft)
.radius(10.0)
.transition(400.0)
.easing(Easing::Spring),
|ui| {
ui.text(
if self.wide { "wide" } else { "narrow" },
TextStyle::new(14.0).color(t.accent),
);
},
);
// A toast enters from the right and leaves the same way.
// The key is what makes this work: the core matches this
// frame's `toast N` to last frame's and eases between
// them. On the frame it is gone, `exit` replays it out.
ui.with(
NodeSpec::column().gap(6.0).cross_align(Align::Start),
|ui| {
for (id, text) in &self.toasts {
ui.with_indexed(
*id,
NodeSpec::row()
.pad_xy(12.0, 8.0)
.gap(8.0)
.cross_align(Align::Center)
.bg(t.raised)
.radius(8.0)
.border(1.0, t.border)
.transition(250.0)
.enter(Enter::from(240.0, 0.0))
.exit(Enter::from(240.0, 0.0))
.on_click(Msg::Dismiss { id: *id }),
|ui| {
ui.text(text, TextStyle::new(13.0).color(t.fg));
ui.text(
"click to dismiss",
TextStyle::new(11.0).color(t.faint),
);
},
);
}
},
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Toggle) => self.wide = !self.wide,
Some(Msg::Notify) => {
self.next_id += 1;
self.toasts
.push((self.next_id, format!("Saved #{}", self.next_id)));
}
Some(Msg::Dismiss { id }) => self.toasts.retain(|(i, _)| *i != id),
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Motion")
.size(480.0, 360.0)
.run(Motion::default())
}
You declare the target, kui eases
There is no animation API to call. A node says .transition(ms), and
from then on any value on it that changes between frames eases from
the old value to the new one over that long. Width, colour, position,
opacity — whatever changed.
//! Step 8 — motion. Builds on step 7 by letting the core ease what
//! changes: a panel whose width eases between two values, and toasts
//! that slide in when declared and out when they stop being declared.
//! Chapter 9 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_08_motion
use kui_native::widgets;
use kui_native::{Align, App, Easing, Enter, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Toggle,
Notify,
Dismiss { id: u64 },
}
#[derive(Default)]
struct Motion {
wide: bool,
toasts: Vec<(u64, String)>,
next_id: u64,
}
impl App for Motion {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg),
|ui| {
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Toggle width", Msg::Toggle);
widgets::button(ui, "Notify", Msg::Notify);
});
// `transition(ms)`: when a value on this node changes
// between frames, the core eases from the old to the new
// over that long. The view just declares the target.
ui.with_keyed(
"panel",
NodeSpec::column()
.width(if self.wide { 360.0 } else { 160.0 })
.height(80.0)
.center()
.bg(t.accent_soft)
.radius(10.0)
.transition(400.0)
.easing(Easing::Spring),
|ui| {
ui.text(
if self.wide { "wide" } else { "narrow" },
TextStyle::new(14.0).color(t.accent),
);
},
);
// A toast enters from the right and leaves the same way.
// The key is what makes this work: the core matches this
// frame's `toast N` to last frame's and eases between
// them. On the frame it is gone, `exit` replays it out.
ui.with(
NodeSpec::column().gap(6.0).cross_align(Align::Start),
|ui| {
for (id, text) in &self.toasts {
ui.with_indexed(
*id,
NodeSpec::row()
.pad_xy(12.0, 8.0)
.gap(8.0)
.cross_align(Align::Center)
.bg(t.raised)
.radius(8.0)
.border(1.0, t.border)
.transition(250.0)
.enter(Enter::from(240.0, 0.0))
.exit(Enter::from(240.0, 0.0))
.on_click(Msg::Dismiss { id: *id }),
|ui| {
ui.text(text, TextStyle::new(13.0).color(t.fg));
ui.text(
"click to dismiss",
TextStyle::new(11.0).color(t.faint),
);
},
);
}
},
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Toggle) => self.wide = !self.wide,
Some(Msg::Notify) => {
self.next_id += 1;
self.toasts
.push((self.next_id, format!("Saved #{}", self.next_id)));
}
Some(Msg::Dismiss { id }) => self.toasts.retain(|(i, _)| *i != id),
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Motion")
.size(480.0, 360.0)
.run(Motion::default())
}
The view says the width is 160 or 360. Nothing else. .easing(..) picks
the curve: EaseOut is the default. The springs keep their momentum
when the target moves mid-flight, and they differ in one thing, how far
they overshoot: Smooth not at all, Snappy a trace, Spring a
little, Bouncy more. That amount is the spring’s bounce, 0 to 0.9,
and .bounce(..) sets any other — with the duration, it is all a
spring takes. No stiffness or damping to tune.
This only works because the panel has a key. kui matches this frame’s
panel to last frame’s panel and sees the width changed. Without a
stable key, there is nothing to ease from.
Entering and leaving
A row that appears this frame has no last frame to ease from. .enter
says where it comes from. A row that is gone this frame cannot be
drawn — it is not declared — so .exit says where it goes, and kui
replays the last frame’s copy of it on the way out.
//! Step 8 — motion. Builds on step 7 by letting the core ease what
//! changes: a panel whose width eases between two values, and toasts
//! that slide in when declared and out when they stop being declared.
//! Chapter 9 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_08_motion
use kui_native::widgets;
use kui_native::{Align, App, Easing, Enter, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Toggle,
Notify,
Dismiss { id: u64 },
}
#[derive(Default)]
struct Motion {
wide: bool,
toasts: Vec<(u64, String)>,
next_id: u64,
}
impl App for Motion {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg),
|ui| {
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Toggle width", Msg::Toggle);
widgets::button(ui, "Notify", Msg::Notify);
});
// `transition(ms)`: when a value on this node changes
// between frames, the core eases from the old to the new
// over that long. The view just declares the target.
ui.with_keyed(
"panel",
NodeSpec::column()
.width(if self.wide { 360.0 } else { 160.0 })
.height(80.0)
.center()
.bg(t.accent_soft)
.radius(10.0)
.transition(400.0)
.easing(Easing::Spring),
|ui| {
ui.text(
if self.wide { "wide" } else { "narrow" },
TextStyle::new(14.0).color(t.accent),
);
},
);
// A toast enters from the right and leaves the same way.
// The key is what makes this work: the core matches this
// frame's `toast N` to last frame's and eases between
// them. On the frame it is gone, `exit` replays it out.
ui.with(
NodeSpec::column().gap(6.0).cross_align(Align::Start),
|ui| {
for (id, text) in &self.toasts {
ui.with_indexed(
*id,
NodeSpec::row()
.pad_xy(12.0, 8.0)
.gap(8.0)
.cross_align(Align::Center)
.bg(t.raised)
.radius(8.0)
.border(1.0, t.border)
.transition(250.0)
.enter(Enter::from(240.0, 0.0))
.exit(Enter::from(240.0, 0.0))
.on_click(Msg::Dismiss { id: *id }),
|ui| {
ui.text(text, TextStyle::new(13.0).color(t.fg));
ui.text(
"click to dismiss",
TextStyle::new(11.0).color(t.faint),
);
},
);
}
},
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Toggle) => self.wide = !self.wide,
Some(Msg::Notify) => {
self.next_id += 1;
self.toasts
.push((self.next_id, format!("Saved #{}", self.next_id)));
}
Some(Msg::Dismiss { id }) => self.toasts.retain(|(i, _)| *i != id),
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Motion")
.size(480.0, 360.0)
.run(Motion::default())
}
Enter::from(dx, dy) is an offset in pixels. The toast starts 240px to
the right and eases to its place; on the frame it is removed, it eases
back out.
Keys again: each toast is with_indexed(id, ..). Dismiss the first and
the second keeps its key, so it slides up into the gap instead of
being mistaken for the first.
Frames while animating
While something is easing, kui asks the window for frames on its own.
Your view runs each time, declares the same target, and the ease
continues. When everything is still, the loop sleeps until the next
event. You never request frames for motion.
Try this
- Replace
Easing::SpringwithEasing::Linear. ThenBouncy. - Keep
Springand add.bounce(0.6), then.bounce(0.0): the same spring, overshooting more, then not at all. - Add
.opacity(0.0)to theEnter(it is a builder:Enter::from(240.0, 0.0).opacity(0.0)) so toasts fade as they slide. - Remove the
idand useui.with(..). Dismiss the first toast and watch the second one jump.
Where this is decided
- Every animation prop (
transition,easing,bounce,enter,exit,keyframes,slide):docs/props.md. - A frame that removes more than 4096 nodes declaring
exitanimates none of them and warns withexit-budget: putexiton the list, not on every row. - The reference examples:
features/transition.rs,features/spring.rs(a spring’s duration and bounce on two sliders),features/enter_exit.rs.
Effects, the clock and the world
At the end of this chapter, the window’s title counts seconds, a button copies to the clipboard, another pastes, and a third closes the window.
So far the app was pure: a model, a view of it, events that change it. Real apps touch the world — the clipboard, a file, a socket, a timer. kui has three doors for that, and each keeps the loop’s shape.
Door one: the handler gets the core
on_event is &mut self and an event. Its sibling on_event_with
also gets the core of the window the event came from. The core is
where the clipboard, focus and scrolling live:
//! Step 9 — the world outside the frame. Builds on step 8 with the three
//! doors out of the pure loop: the clipboard (a handler that gets the
//! window's core), a thread that wakes the loop (a clock), and the
//! window itself (its title, and closing it). Chapter 10 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_09_effects
use std::sync::{Arc, Mutex};
use std::time::Duration;
use kui_native::widgets;
use kui_native::{
Align, App, Core, Message, NodeSpec, TextStyle, Ui, UiEvent, Waker, WindowCommand,
};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Copy,
Paste,
Quit,
/// The root sink's tag: a paste's text arrives under it.
Sink,
}
struct Effects {
/// Seconds since launch, written by a thread.
ticks: Arc<Mutex<u64>>,
pasted: String,
quit: bool,
}
impl App for Effects {
/// Once, before the window opens. The loop sleeps between events, so
/// a thread with news calls `wake()` and the loop draws a frame.
fn setup(&mut self, waker: Waker) {
let ticks = Arc::clone(&self.ticks);
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_secs(1));
*ticks.lock().unwrap() += 1;
waker.wake();
}
});
}
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
let ticks = *self.ticks.lock().unwrap();
// The window is declared like everything else: say the title
// every frame, and the runner applies it when it changes.
ui.window_title(&format!("Effects · {ticks}s"));
if self.quit {
ui.window_command(WindowCommand::Close(ui.env().window.id));
}
// A key sink with focus, so a paste has somewhere to land.
let sink = ui.with_keyed(
"app",
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg)
.on_key(Msg::Sink),
|ui| {
ui.text(
&format!("up for {ticks}s"),
TextStyle::new(24.0).color(t.fg),
);
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Copy the uptime", Msg::Copy);
widgets::button(ui, "Paste", Msg::Paste);
widgets::button(ui, "Quit", Msg::Quit);
});
ui.text(
&format!(
"pasted: {}",
if self.pasted.is_empty() {
"nothing yet"
} else {
&self.pasted
}
),
TextStyle::new(13.0).color(t.muted),
);
},
);
ui.take_key_focus(sink);
}
/// `on_event` with the core of the window the event came from. What
/// an app does about an event beyond its model — the clipboard, a
/// paste, focus — is a call on it here.
fn on_event_with(&mut self, ev: UiEvent, core: &mut Core) {
match ev.message::<Msg>() {
Some(Msg::Copy) => {
let ticks = *self.ticks.lock().unwrap();
core.set_clipboard(format!("{ticks}s"), None);
}
// The core cannot read the clipboard; the host can. Ask, and
// the text comes back as an event on the focused sink.
Some(Msg::Paste) => core.request_paste(),
Some(Msg::Sink) if ev.kind() == Some("text") => {
if let Some(text) = ev.payload.get_str("text") {
self.pasted = text.to_string();
}
}
Some(Msg::Quit) => self.quit = true,
_ => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Effects").size(420.0, 220.0).run(Effects {
ticks: Arc::new(Mutex::new(0)),
pasted: String::new(),
quit: false,
})
}
core.set_clipboard(text, None) writes. There is no read_clipboard,
because the core never reads the OS clipboard itself — the host does.
core.request_paste() asks, and the text comes back as an event: a
text event on the focused key sink. That is why the root of this app
is a sink with focus, as in the keyboard chapter.
Override on_event_with and the default on_event is not called.
Override only on_event and nothing changes.
Door two: a thread wakes the loop
The loop sleeps between events. A thread that has news — a socket read,
a timer — cannot draw, but it can wake the loop, and then view runs:
//! Step 9 — the world outside the frame. Builds on step 8 with the three
//! doors out of the pure loop: the clipboard (a handler that gets the
//! window's core), a thread that wakes the loop (a clock), and the
//! window itself (its title, and closing it). Chapter 10 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_09_effects
use std::sync::{Arc, Mutex};
use std::time::Duration;
use kui_native::widgets;
use kui_native::{
Align, App, Core, Message, NodeSpec, TextStyle, Ui, UiEvent, Waker, WindowCommand,
};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Copy,
Paste,
Quit,
/// The root sink's tag: a paste's text arrives under it.
Sink,
}
struct Effects {
/// Seconds since launch, written by a thread.
ticks: Arc<Mutex<u64>>,
pasted: String,
quit: bool,
}
impl App for Effects {
/// Once, before the window opens. The loop sleeps between events, so
/// a thread with news calls `wake()` and the loop draws a frame.
fn setup(&mut self, waker: Waker) {
let ticks = Arc::clone(&self.ticks);
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_secs(1));
*ticks.lock().unwrap() += 1;
waker.wake();
}
});
}
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
let ticks = *self.ticks.lock().unwrap();
// The window is declared like everything else: say the title
// every frame, and the runner applies it when it changes.
ui.window_title(&format!("Effects · {ticks}s"));
if self.quit {
ui.window_command(WindowCommand::Close(ui.env().window.id));
}
// A key sink with focus, so a paste has somewhere to land.
let sink = ui.with_keyed(
"app",
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg)
.on_key(Msg::Sink),
|ui| {
ui.text(
&format!("up for {ticks}s"),
TextStyle::new(24.0).color(t.fg),
);
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Copy the uptime", Msg::Copy);
widgets::button(ui, "Paste", Msg::Paste);
widgets::button(ui, "Quit", Msg::Quit);
});
ui.text(
&format!(
"pasted: {}",
if self.pasted.is_empty() {
"nothing yet"
} else {
&self.pasted
}
),
TextStyle::new(13.0).color(t.muted),
);
},
);
ui.take_key_focus(sink);
}
/// `on_event` with the core of the window the event came from. What
/// an app does about an event beyond its model — the clipboard, a
/// paste, focus — is a call on it here.
fn on_event_with(&mut self, ev: UiEvent, core: &mut Core) {
match ev.message::<Msg>() {
Some(Msg::Copy) => {
let ticks = *self.ticks.lock().unwrap();
core.set_clipboard(format!("{ticks}s"), None);
}
// The core cannot read the clipboard; the host can. Ask, and
// the text comes back as an event on the focused sink.
Some(Msg::Paste) => core.request_paste(),
Some(Msg::Sink) if ev.kind() == Some("text") => {
if let Some(text) = ev.payload.get_str("text") {
self.pasted = text.to_string();
}
}
Some(Msg::Quit) => self.quit = true,
_ => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Effects").size(420.0, 220.0).run(Effects {
ticks: Arc::new(Mutex::new(0)),
pasted: String::new(),
quit: false,
})
}
setup is called once before the window opens, with a Waker. Clone
it into as many threads as you like. waker.wake() is cheap and safe
at any rate; wakes coalesce into the next turn of the loop.
The thread writes into shared state; the view reads it. There is no
channel into on_event — the model is the channel.
Door three: the window is declared
//! Step 9 — the world outside the frame. Builds on step 8 with the three
//! doors out of the pure loop: the clipboard (a handler that gets the
//! window's core), a thread that wakes the loop (a clock), and the
//! window itself (its title, and closing it). Chapter 10 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_09_effects
use std::sync::{Arc, Mutex};
use std::time::Duration;
use kui_native::widgets;
use kui_native::{
Align, App, Core, Message, NodeSpec, TextStyle, Ui, UiEvent, Waker, WindowCommand,
};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Copy,
Paste,
Quit,
/// The root sink's tag: a paste's text arrives under it.
Sink,
}
struct Effects {
/// Seconds since launch, written by a thread.
ticks: Arc<Mutex<u64>>,
pasted: String,
quit: bool,
}
impl App for Effects {
/// Once, before the window opens. The loop sleeps between events, so
/// a thread with news calls `wake()` and the loop draws a frame.
fn setup(&mut self, waker: Waker) {
let ticks = Arc::clone(&self.ticks);
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_secs(1));
*ticks.lock().unwrap() += 1;
waker.wake();
}
});
}
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
let ticks = *self.ticks.lock().unwrap();
// The window is declared like everything else: say the title
// every frame, and the runner applies it when it changes.
ui.window_title(&format!("Effects · {ticks}s"));
if self.quit {
ui.window_command(WindowCommand::Close(ui.env().window.id));
}
// A key sink with focus, so a paste has somewhere to land.
let sink = ui.with_keyed(
"app",
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg)
.on_key(Msg::Sink),
|ui| {
ui.text(
&format!("up for {ticks}s"),
TextStyle::new(24.0).color(t.fg),
);
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Copy the uptime", Msg::Copy);
widgets::button(ui, "Paste", Msg::Paste);
widgets::button(ui, "Quit", Msg::Quit);
});
ui.text(
&format!(
"pasted: {}",
if self.pasted.is_empty() {
"nothing yet"
} else {
&self.pasted
}
),
TextStyle::new(13.0).color(t.muted),
);
},
);
ui.take_key_focus(sink);
}
/// `on_event` with the core of the window the event came from. What
/// an app does about an event beyond its model — the clipboard, a
/// paste, focus — is a call on it here.
fn on_event_with(&mut self, ev: UiEvent, core: &mut Core) {
match ev.message::<Msg>() {
Some(Msg::Copy) => {
let ticks = *self.ticks.lock().unwrap();
core.set_clipboard(format!("{ticks}s"), None);
}
// The core cannot read the clipboard; the host can. Ask, and
// the text comes back as an event on the focused sink.
Some(Msg::Paste) => core.request_paste(),
Some(Msg::Sink) if ev.kind() == Some("text") => {
if let Some(text) = ev.payload.get_str("text") {
self.pasted = text.to_string();
}
}
Some(Msg::Quit) => self.quit = true,
_ => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Effects").size(420.0, 220.0).run(Effects {
ticks: Arc::new(Mutex::new(0)),
pasted: String::new(),
quit: false,
})
}
ui.window_title(..) is said every frame, and applied when it changes.
ui.window_command(WindowCommand::Close(id)) asks the runner to close
a window; ui.env().window.id is this one’s id. A second window is
declared the same way, with ui.window(name, ..);
docs/design.md has the multi-window rules under
Window chrome is data.
The messages
//! Step 9 — the world outside the frame. Builds on step 8 with the three
//! doors out of the pure loop: the clipboard (a handler that gets the
//! window's core), a thread that wakes the loop (a clock), and the
//! window itself (its title, and closing it). Chapter 10 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_09_effects
use std::sync::{Arc, Mutex};
use std::time::Duration;
use kui_native::widgets;
use kui_native::{
Align, App, Core, Message, NodeSpec, TextStyle, Ui, UiEvent, Waker, WindowCommand,
};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Copy,
Paste,
Quit,
/// The root sink's tag: a paste's text arrives under it.
Sink,
}
struct Effects {
/// Seconds since launch, written by a thread.
ticks: Arc<Mutex<u64>>,
pasted: String,
quit: bool,
}
impl App for Effects {
/// Once, before the window opens. The loop sleeps between events, so
/// a thread with news calls `wake()` and the loop draws a frame.
fn setup(&mut self, waker: Waker) {
let ticks = Arc::clone(&self.ticks);
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_secs(1));
*ticks.lock().unwrap() += 1;
waker.wake();
}
});
}
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
let ticks = *self.ticks.lock().unwrap();
// The window is declared like everything else: say the title
// every frame, and the runner applies it when it changes.
ui.window_title(&format!("Effects · {ticks}s"));
if self.quit {
ui.window_command(WindowCommand::Close(ui.env().window.id));
}
// A key sink with focus, so a paste has somewhere to land.
let sink = ui.with_keyed(
"app",
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg)
.on_key(Msg::Sink),
|ui| {
ui.text(
&format!("up for {ticks}s"),
TextStyle::new(24.0).color(t.fg),
);
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Copy the uptime", Msg::Copy);
widgets::button(ui, "Paste", Msg::Paste);
widgets::button(ui, "Quit", Msg::Quit);
});
ui.text(
&format!(
"pasted: {}",
if self.pasted.is_empty() {
"nothing yet"
} else {
&self.pasted
}
),
TextStyle::new(13.0).color(t.muted),
);
},
);
ui.take_key_focus(sink);
}
/// `on_event` with the core of the window the event came from. What
/// an app does about an event beyond its model — the clipboard, a
/// paste, focus — is a call on it here.
fn on_event_with(&mut self, ev: UiEvent, core: &mut Core) {
match ev.message::<Msg>() {
Some(Msg::Copy) => {
let ticks = *self.ticks.lock().unwrap();
core.set_clipboard(format!("{ticks}s"), None);
}
// The core cannot read the clipboard; the host can. Ask, and
// the text comes back as an event on the focused sink.
Some(Msg::Paste) => core.request_paste(),
Some(Msg::Sink) if ev.kind() == Some("text") => {
if let Some(text) = ev.payload.get_str("text") {
self.pasted = text.to_string();
}
}
Some(Msg::Quit) => self.quit = true,
_ => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Effects").size(420.0, 220.0).run(Effects {
ticks: Arc::new(Mutex::new(0)),
pasted: String::new(),
quit: false,
})
}
Try this
- Print something from
teardown. It runs once as the window goes. - Make the tick thread stop after 10 seconds. The loop keeps running; only the wakes stop.
- Copy, then paste into another app. Then paste from another app into this one.
Where this is decided
on_event_with,setupandteardown:kui_native::App.- Effects as data, the Node driver’s version of the same idea:
withEffectsin the Node package. - The reference examples:
features/waker.rs,features/clipboard.rs.
Testing without a window
At the end of this chapter, cargo test clicks buttons, presses a key
and reads the screen — with no window.
Why it works
The core owns no window. A frame is your view run against a viewport
and a clock the caller provides. Input is a value handed in. So a test
can run the real view and the real on_event, and assert on what the
frame produced, the way a user would see it.
kui_native::testing::Drive is the driver.
The app under test
A smaller cousin of chapter 6’s list: an Add button, a Clear button,
a row per item, and a summary line. Two boxes are given keys — list
and summary — so the test can find them by name; the buttons and the
checkboxes are found by their text, since the stock widgets key
themselves that way.
//! Step 10 — a test without a window. Builds on step 5's list by giving
//! it a `mod tests` that drives the real `view` and `on_event` headless:
//! clicks by label, a key, and assertions on what the frame drew.
//! Chapter 11 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_10_testing
//! Test: cargo test -p kui-native --example tutorial_10_testing
use kui_native::widgets;
use kui_native::{Align, App, KeyCode, KeyPhase, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Add,
Toggle {
id: u64,
},
Clear,
/// The root sink: `a` adds, so a test can press a key too.
Key,
}
struct Item {
id: u64,
text: String,
done: bool,
}
#[derive(Default)]
struct Todo {
items: Vec<Item>,
next_id: u64,
}
impl Todo {
fn add(&mut self) {
self.next_id += 1;
self.items.push(Item {
id: self.next_id,
text: format!("Task {}", self.next_id),
done: false,
});
}
}
impl App for Todo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
let sink = ui.with_keyed(
"app",
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg)
.on_key(Msg::Key),
|ui| {
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Add", Msg::Add);
widgets::button(ui, "Clear", Msg::Clear);
});
// A keyed box has a name the frame remembers. A test reads
// the frame by those names, as it clicks a button by its
// text.
ui.with_keyed("list", NodeSpec::column().gap(4.0), |ui| {
for item in &self.items {
ui.with_indexed(item.id, NodeSpec::row(), |ui| {
widgets::checkbox(
ui,
&item.text,
item.done,
Msg::Toggle { id: item.id },
);
});
}
});
let done = self.items.iter().filter(|i| i.done).count();
ui.text_in_keyed(
"summary",
NodeSpec::row(),
&format!("{done} of {} done", self.items.len()),
TextStyle::new(12.0).color(t.muted),
);
},
);
ui.take_key_focus(sink);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Add) => self.add(),
Some(Msg::Toggle { id }) => {
if let Some(item) = self.items.iter_mut().find(|i| i.id == id) {
item.done = !item.done;
}
}
Some(Msg::Clear) => self.items.clear(),
Some(Msg::Key) => {
if let Some((KeyPhase::Down, key)) = ev.key_press()
&& key.code == KeyCode::Char('a')
{
self.add();
}
}
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Testing")
.size(360.0, 300.0)
.run(Todo::default())
}
#[cfg(test)]
mod tests {
use super::*;
use kui_native::testing::Drive;
use kui_native::{Core, KeyMods};
/// A drive over a core and a viewport: the same `view` and `on_event`
/// the window runs, with no window. `framing` builds a frame after
/// every gesture, so the view has caught up before the next assert.
fn drive() -> Drive {
Drive::new(Core::new(), 360.0, 300.0).framing()
}
#[test]
fn a_click_adds_and_a_click_ticks() {
let mut app = Todo::default();
let mut d = drive();
d.frame(&mut app);
assert_eq!(d.texts_under("summary"), ["0 of 0 done"]);
// Click by label: how a screen reader presses, so no geometry.
let add = d.key_of("Add").expect("an Add button");
d.click_key(&mut app, add);
d.click_key(&mut app, add);
assert_eq!(app.items.len(), 2);
assert_eq!(d.texts_under("list"), ["Task 1", "Task 2"]);
let first = d.key_of("Task 1").expect("the first row's checkbox");
d.click_key(&mut app, first);
assert!(app.items[0].done);
assert_eq!(d.texts_under("summary"), ["1 of 2 done"]);
}
#[test]
fn a_key_adds_too() {
let mut app = Todo::default();
let mut d = drive();
d.frame(&mut app);
d.key(&mut app, "a", KeyMods::NONE);
assert_eq!(app.items.len(), 1);
// Nothing the core saw was misdeclared.
assert!(d.warnings().is_empty());
}
}
The tests
//! Step 10 — a test without a window. Builds on step 5's list by giving
//! it a `mod tests` that drives the real `view` and `on_event` headless:
//! clicks by label, a key, and assertions on what the frame drew.
//! Chapter 11 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_10_testing
//! Test: cargo test -p kui-native --example tutorial_10_testing
use kui_native::widgets;
use kui_native::{Align, App, KeyCode, KeyPhase, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Add,
Toggle {
id: u64,
},
Clear,
/// The root sink: `a` adds, so a test can press a key too.
Key,
}
struct Item {
id: u64,
text: String,
done: bool,
}
#[derive(Default)]
struct Todo {
items: Vec<Item>,
next_id: u64,
}
impl Todo {
fn add(&mut self) {
self.next_id += 1;
self.items.push(Item {
id: self.next_id,
text: format!("Task {}", self.next_id),
done: false,
});
}
}
impl App for Todo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
let sink = ui.with_keyed(
"app",
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg)
.on_key(Msg::Key),
|ui| {
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Add", Msg::Add);
widgets::button(ui, "Clear", Msg::Clear);
});
// A keyed box has a name the frame remembers. A test reads
// the frame by those names, as it clicks a button by its
// text.
ui.with_keyed("list", NodeSpec::column().gap(4.0), |ui| {
for item in &self.items {
ui.with_indexed(item.id, NodeSpec::row(), |ui| {
widgets::checkbox(
ui,
&item.text,
item.done,
Msg::Toggle { id: item.id },
);
});
}
});
let done = self.items.iter().filter(|i| i.done).count();
ui.text_in_keyed(
"summary",
NodeSpec::row(),
&format!("{done} of {} done", self.items.len()),
TextStyle::new(12.0).color(t.muted),
);
},
);
ui.take_key_focus(sink);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Add) => self.add(),
Some(Msg::Toggle { id }) => {
if let Some(item) = self.items.iter_mut().find(|i| i.id == id) {
item.done = !item.done;
}
}
Some(Msg::Clear) => self.items.clear(),
Some(Msg::Key) => {
if let Some((KeyPhase::Down, key)) = ev.key_press()
&& key.code == KeyCode::Char('a')
{
self.add();
}
}
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Testing")
.size(360.0, 300.0)
.run(Todo::default())
}
#[cfg(test)]
mod tests {
use super::*;
use kui_native::testing::Drive;
use kui_native::{Core, KeyMods};
/// A drive over a core and a viewport: the same `view` and `on_event`
/// the window runs, with no window. `framing` builds a frame after
/// every gesture, so the view has caught up before the next assert.
fn drive() -> Drive {
Drive::new(Core::new(), 360.0, 300.0).framing()
}
#[test]
fn a_click_adds_and_a_click_ticks() {
let mut app = Todo::default();
let mut d = drive();
d.frame(&mut app);
assert_eq!(d.texts_under("summary"), ["0 of 0 done"]);
// Click by label: how a screen reader presses, so no geometry.
let add = d.key_of("Add").expect("an Add button");
d.click_key(&mut app, add);
d.click_key(&mut app, add);
assert_eq!(app.items.len(), 2);
assert_eq!(d.texts_under("list"), ["Task 1", "Task 2"]);
let first = d.key_of("Task 1").expect("the first row's checkbox");
d.click_key(&mut app, first);
assert!(app.items[0].done);
assert_eq!(d.texts_under("summary"), ["1 of 2 done"]);
}
#[test]
fn a_key_adds_too() {
let mut app = Todo::default();
let mut d = drive();
d.frame(&mut app);
d.key(&mut app, "a", KeyMods::NONE);
assert_eq!(app.items.len(), 1);
// Nothing the core saw was misdeclared.
assert!(d.warnings().is_empty());
}
}
Run them:
cargo test
What the drive does
Drive::new(core, w, h)— a core and a viewport..framing()makes it build a frame after every gesture, so the view has caught up before the next line.d.frame(&mut app)— one frame, as the runner would build it.d.key_of("Add")— the key of the node declared under that name. Buttons are keyed by their text; your own boxes by what you gavewith_keyedortext_in_keyed.d.click_key(&mut app, key)— a click by key, the way a screen reader presses. No coordinates.d.click(app, x, y)is the pointer version, andd.rect_of(key)gives you the rect to aim at.d.key(&mut app, "a", KeyMods::NONE)— a key press and release, by name.d.keys(app, "jj ww")types a sequence.d.texts_under("list")— every text inside the node with that key, in order. What the user would read.d.warnings()— every warning the core raised. A test that asserts it empty catches an unnamed button or a duplicate key before a user does.
There is more — hover, drag, wheel, advance(secs) to move the
clock for a transition — in the module’s docs.
What to assert on
Two things, and both are cheap here:
- the model —
app.items.len()— because that is what the app believes; - the frame —
texts_under,rect_of— because that is what the user sees, and a view that ignores its model would pass the first assert and fail this one.
Try this
- Assert that the second row’s checkbox is not ticked after ticking
the first. (
app.items[1].done, and the frame: the checkbox’scheckedis in the node’s access tree —d.core.access_tree().) - Add
d.advance(1.0)and a frame, then assert something about a transition. Chapter 9’s toasts are a good subject.
Where this is decided
- The headless driver:
kui_native::testing. - The Node version of the same test:
docs/guide.md, Testing without a window. - A larger example with its own
mod tests:apps/splitmux.rs.
Warnings and the devtools
At the end of this chapter, you can see what your app is doing.
Turn it on
kui_native::app("Todo").devtools(true).run(Todo::default())
Or set KUI_DEVTOOLS=1 and run any app. A panel opens beside the app,
docked to the right. Ctrl+Shift+D moves it (left, right, bottom, its
own window, off); Ctrl+Shift+I moves the keyboard into it and back.
Three tabs
facts — what the runtime believes right now: the theme and where it came from, the window and its size, the focused node, the modifier keys, the node count, and a latency graph.
events — every event handed to your on_event, as it arrives: the
frame number, the node it came from, and the payload as data. Click a
row to unfold the payload. This is the tab to keep open while you
learn. When a button does nothing, look here first: either the click
arrived and your handler ignored it, or it never arrived and the button
is not where you think it is.
tree — the last frame’s nodes, foldable, with a filter. Click a
node to see its box, its layout, its paint, its handlers and its state.
The picker (Ctrl+Shift+P, or the crosshair button) selects a node by
pointing at it.
Warnings
A mistake that fails silently in most UI libraries comes back as data
in kui: a warning with a stable code, the node it is about, and a
sentence that says what to do. They show in the events tab, and a test
reads them with d.warnings().
The ones you will meet first:
| code | what happened | what to do |
|---|---|---|
control-without-name | a button, editor or checkbox with no text inside it and no label | give it .label("..") |
duplicate-key | two nodes in one frame share a key | key rows by id, not by text that repeats |
transition-auto-key | a node with a transition sits among siblings that change count, under a position key | give it with_keyed or with_indexed |
modal-behind-content | a modal float declared before the content that paints over it | declare the modal last |
grow-weight-ignored | Sizing::Grow(2.0) on the only grow child | it has nothing to split against; use grow_width() |
item-outside-container | a radio with no radio_group above it | wrap the items |
exit-budget | one frame removed more than 4096 nodes declaring exit | that removal did not animate; remove a parent instead of its children |
The full list is the Warnings table in
docs/props.md. Each
(code, node) pair is reported once, so a warning in a view that runs
every frame does not flood the log.
Where this is decided
- The panel is drawn by the core, not by the window runner, so a Node,
Lua or C app gets the same one, and
KUI_DEVTOOLS=1works for all of them. - Your app can add a tab of its own:
features/devtools_tab.rs.
Where next
You have the whole model now: a view of the model, messages back, keys, floats, motion, the three doors out, and a test. What follows is where to look for the rest.
The reference gallery
The Examples part of this book lists every example by kind, with its whole source. Each shows one thing, in every state it has, inside the devtools:
widgets/— one element or stock widget each:button,edit,select,table,virtual_list,cells(a terminal grid),image,fragment(a box a shader paints),path(any outline, as SVG path data),menu_bar,titlebar.features/— one behaviour each:hover,drag,drop,focus,selection,clipboard,audio,accessibility,theme,metrics,transition,enter_exit,waker.apps/— how it composes:splitmux(a tiling pane multiplexer),modal_editor(a Helix-style editor),syntax_view,loaders.
Reading order after this book: features/focus.rs, then
widgets/edit.rs, then apps/splitmux.rs.
The reference documents
- docs.rs/kui-native — the API reference: every type and method, with kui-core underneath it.
docs/howto.md— “How do I…” questions, each with a two-sentence answer and a link. Animate a removal, draw a connector, make a popup taller than the window, give the app a menu bar, test the real window.docs/props.md— every prop, element, event, warning and theme colour, with its Node, Lua and C spelling in the same row. Generated from the schema, so it cannot drift.
The design records
docs/adr/ in the
repository holds the design records: why focus is one node, why a modal
is a scope, why effects are data, why the devtools belong to the core.
Read one when a rule in this book seems arbitrary.
The other bindings
The same tree, the same events, the same core. What changes is the spelling:
| Rust | Node (JSX) | Lua | C |
|---|---|---|---|
NodeSpec::row().pad(8.0) | <box dir="row" pad={8}> | row { pad = 8 } | kui_open(ctx, &spec, NULL) |
widgets::button(ui, "OK", Msg::Ok) | <button onClick={{ kind: 'ok' }}>OK</button> | button { label = "OK", on_click = { kind = "ok" } } | kui_button(ctx, "OK", payload) |
on_event(ev) | update(model, msg) | on_event(ev) | kui_poll_event |
testing::Drive | createApp(..), app.press(..) | the host’s drive | --headless |
The Node package (packages/kui) has the
richest second driver: an Elm-style update that returns the model and
effects, a headless createApp that runs tick too, and the same
devtools. Its README is the place to start.
What this book left out
Multiple windows, the menu bar, drag and drop from the OS, images and
shaders, sound, selection and the clipboard’s finer points, tokens and
themes, accessibility beyond names, and extensions (a Lua or C guest
filling a slot in your frame). Each has a reference example and a
howto entry.
Examples
Every Rust example in the kui repository, one page each, with its whole
source. An example has one subject and shows it in every state it has.
Each runs inside the repository’s devtools harness (kui-devtools,
which is not published): in your own project, take the view and the
on_event and launch them with kui_native::app as the book does.
The comment at the top of each file says what it shows and how the
repository runs it.
apps/: How it composes: an app owning its state, keymap or pane tree, touching whatever it needs.widgets/: One element or one stock widget, in every state it has.features/: One cross-cutting behaviour, with exactly the widgets it touches.tools/: Registered as an example for want of a better slot, and not one: a corpus dump.
apps/
How it composes: an app owning its state, keymap or pane tree, touching whatever it needs.
apps/counter.rs
//! The counter: the smallest app that is the whole pattern, and the one
//! example every binding has in the same shape (`docs/adr/0021`, decision
//! 3). `view` rebuilds the tree from the model; a click arrives in
//! `on_event` as data and moves the model; nothing else happens.
//!
//! What every counter holds, so the four stay one shape: the count with
//! `+1`, `-1` and `reset`; a right-click that declares a `modal` menu on
//! the next frame — the core opens nothing, it reports the press and the
//! view puts a float there — with `+10` and `reset` in it; and a headless
//! drive that clicks all of it and exits non-zero on a wrong answer. The
//! messages are a `#[derive(Message)]` enum, the spelling the book's
//! step 3 teaches (`docs/adr/0039`, decision 6); the other bindings
//! build the same `{kind}` maps by hand.
//!
//! Run: cargo run -p kui-native --example counter [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{Align, App, Core, FloatConfig, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Inc,
Dec,
Reset,
Add10,
/// The tag on the root's `on_context_menu` and on the menu's `modal`:
/// the press that asks for the menu, and the `dismiss` that ends it,
/// both arrive under it.
Menu,
}
#[derive(Default)]
struct Counter {
count: i64,
/// Where the menu is, in viewport px, while it is open.
menu: Option<(f32, f32)>,
}
impl App for Counter {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
// The whole example answers a secondary press. Any node can: the
// topmost one that declared `on_context_menu` is the one asked, so
// a row inside could offer its own menu and win over this one.
ui.with(
NodeSpec::column()
.fill()
.center()
.gap(24.0)
.on_context_menu(Msg::Menu),
|ui| {
ui.with(
NodeSpec::column()
.pad(32.0)
.gap(20.0)
.bg(t.surface)
.radius(12.0)
.border(1.0, t.border)
.cross_align(Align::Center)
.width(320.0),
|ui| {
ui.text("kui counter", TextStyle::new(14.0).color(t.muted));
ui.text(&self.count.to_string(), TextStyle::new(56.0));
ui.with(NodeSpec::row().gap(12.0), |ui| {
widgets::button(ui, "-1", Msg::Dec);
widgets::button(ui, "+1", Msg::Inc);
widgets::button(ui, "reset", Msg::Reset);
});
},
);
ui.text(
"right-click for a menu · clicks are data: view() never sees a callback",
TextStyle::new(12.0).color(t.faint),
);
},
);
// Declared last: floats stack in declaration order. One `modal`
// row is the whole difference between this and a plain float —
// Tab is scoped to it, the pointer cannot reach what is behind
// it, and Escape or a press outside comes back as `dismiss`.
if let Some((x, y)) = self.menu {
ui.with_keyed(
"menu",
NodeSpec::column()
.float(
FloatConfig::viewport()
.inside(Align::Start, Align::Start)
.offset(x, y)
// Opened near an edge, the menu would hang off
// the window; `fit` mirrors it back instead.
.fit(),
)
.modal(Msg::Menu)
.label("Actions")
.width(120.0)
.pad(4.0)
.gap(4.0)
.bg(t.raised)
.border(1.0, t.border_strong)
.radius(6.0),
|ui| {
widgets::button(ui, "+10", Msg::Add10);
widgets::button(ui, "reset", Msg::Reset);
},
);
}
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Inc) => self.count += 1,
Some(Msg::Dec) => self.count -= 1,
Some(Msg::Reset) => {
self.count = 0;
self.menu = None;
}
Some(Msg::Add10) => {
self.count += 10;
self.menu = None;
}
// The one tag, two events: a right-click — the core reports
// where it landed and opens nothing; the next frame's view is
// what puts a menu there — and a `dismiss`, Escape or a press
// outside the menu, where the core asks and the app decides.
// This one just closes.
Some(Msg::Menu) => {
if ev.kind() == Some("contextmenu") {
let at = |k| ev.payload.get_float(k).unwrap_or(0.0) as f32;
self.menu = Some((at("x"), at("y")));
} else {
self.menu = None;
}
}
None => {}
}
}
}
impl Example for Counter {
const KEYS: &'static [(&'static str, &'static str)] =
&[("right-click", "the modal menu"), ("Esc", "dismiss it")];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(560.0, 400.0)
}
/// The Rosetta drive: every counter clicks its buttons by label, opens
/// and dismisses its menu, and checks the model after each.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 560.0, 400.0);
d.frame(self);
let inc = d.key_of("+1").ok_or("no +1")?;
let dec = d.key_of("-1").ok_or("no -1")?;
d.click_key(self, inc);
d.click_key(self, inc);
d.click_key(self, dec);
d.frame(self);
d.check(self.count == 1, "two +1 and a -1 count to 1")?;
// The menu: a secondary press anywhere in the example, then the
// frame that declares the modal, then its +10.
d.input(
self,
kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(40.0, 40.0)),
);
d.input(
self,
kui_native::InputEvent::MouseDown {
button: kui_native::MouseButton::Secondary,
clicks: 1,
},
);
d.input(
self,
kui_native::InputEvent::MouseUp {
button: kui_native::MouseButton::Secondary,
},
);
d.check(self.menu.is_some(), "a right-click asks for the menu")?;
d.frame(self);
let add10 = d.key_of("+10").ok_or("the menu did not open")?;
d.click_key(self, add10);
d.frame(self);
d.check(
self.count == 11 && self.menu.is_none(),
"+10 counts and closes the menu",
)?;
// Escape dismisses a reopened one without choosing.
self.menu = Some((40.0, 40.0));
d.frame(self);
d.key(self, "escape", Default::default());
d.check(self.menu.is_none(), "escape dismisses the menu")?;
d.frame(self);
let gone = d.key_of("+10").is_none();
d.check(gone, "and the frame after has no menu")
}
}
kui_devtools::main!(Counter::default());
apps/loaders.rs
//! Loaders: eleven ways to say "wait", over one job the app owns. The job
//! is a number from 0 to 1 that a clock moves while it runs; three loaders
//! are *determinate* and draw that number — a pie, a ring, a bar — and eight
//! are *indeterminate* and draw only the time — an arc, a chasing pie,
//! spokes, dots, a sweeping bar, a bar going back and forth, a skeleton,
//! a rainbow.
//!
//! What it shows of composing an app:
//!
//! - **The clock is the model's.** `view` is a function of `phase` and
//! `progress`; `step` is the only thing that moves them. A window steps
//! by the wall clock and a headless drive by hand, so the drive is exact.
//! - **A frame is asked for only while something moves** (`request_frame`).
//! Idle, paused or done, the window draws nothing and costs nothing.
//! - **The round loaders are `path`s** (`docs/adr/0040-a-path-is-a-mask-in-the-atlas.md`):
//! a pie is `Path::sector` with its sweep the progress, a ring the same
//! with an inner radius, the arc an open stroked `arc_to`. The fills'
//! shape changes every frame, which is the case the ADR's decision 8 is
//! for — each leaves the atlas for a texture of its own and is
//! rasterized per frame, where the track under it, which never changes,
//! is one mask in the atlas for good. The arc only *turns*, and that is
//! `rotate` (`docs/adr/0041-a-mask-turns-about-its-centre.md`): one
//! mask, in the atlas, turned by the quad that draws it. The spokes are `line`s and the rest are boxes:
//! a loader that is only rectangles needs no path.
//! - **The rainbow is a `gradient`** (`docs/adr/0042-a-gradient-is-an-image-the-core-paints.md`):
//! two turns of the spectrum on a box twice its track's length, slid
//! under the track's clip. A gradient does not tween, and this one does
//! not have to: what moves is the box, so the colours are rasterized
//! once, into one strip in the atlas, and every frame after is the same
//! image quad somewhere else.
//! - **Reduced motion is honoured** (`env.system.motion`): the
//! indeterminate loaders stand still and the determinate ones still
//! report, since progress is information and spinning is not.
//!
//! Run: cargo run -p kui-native --example loaders [-- --headless]
use std::f32::consts::TAU;
use std::time::Instant;
use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{
Align, App, Color, Core, FloatConfig, Gradient, Message, NodeSpec, Path, QuadKind, Role, Side,
Stroke, TextStyle, Ui, UiEvent, Vec2,
};
/// How long the job takes at speed 1, in seconds.
const JOB_SECS: f32 = 4.0;
/// A loader's stage: the square its shapes are drawn in, and its centre
/// and radius.
const STAGE: f32 = 96.0;
const C: f32 = STAGE / 2.0;
const R: f32 = 34.0;
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
/// Start, pause, resume or run again: whichever the state offers.
Toggle,
Reset,
/// The speed slider's tag; the value rides on the `change` event.
Speed,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum State {
Idle,
Running,
Paused,
Done,
}
struct Loaders {
state: State,
/// The job, 0 to 1.
progress: f32,
/// Seconds the loaders have been moving: what the indeterminate ones
/// draw. It stops when the job does, so a paused spinner is paused.
phase: f32,
speed: f32,
/// The wall clock's last reading, for a window; `None` under a drive,
/// which calls `step` itself.
last: Option<Instant>,
manual: bool,
/// Whether the first frame has given the job's button the keyboard.
focused: bool,
}
impl Loaders {
fn new() -> Self {
Self {
state: State::Idle,
progress: 0.0,
phase: 0.0,
speed: 1.0,
last: None,
manual: false,
focused: false,
}
}
/// Moves the job and the loaders `dt` seconds on. The one place the
/// model's time changes.
fn step(&mut self, dt: f32) {
if self.state != State::Running {
return;
}
self.phase += dt;
self.progress = (self.progress + dt * self.speed / JOB_SECS).min(1.0);
if self.progress >= 1.0 {
self.state = State::Done;
}
}
/// What the one button does next, which is also its label.
fn action(&self) -> &'static str {
match self.state {
State::Idle => "start",
State::Running => "pause",
State::Paused => "resume",
State::Done => "again",
}
}
}
impl App for Loaders {
fn view(&mut self, ui: &mut Ui<'_>) {
// A window's clock: the time since the last frame, while running.
if !self.manual {
let now = Instant::now();
let dt = self
.last
.map_or(0.0, |l| now.duration_since(l).as_secs_f32());
self.last = (self.state == State::Running).then_some(now);
// A frame after a long sleep is not a long step.
self.step(dt.min(0.1));
}
// Nothing else wakes the window for the next step.
if self.state == State::Running {
ui.request_frame();
}
let t = ui.theme();
let still = ui.env().system.motion.is_reduced();
// The time the indeterminate loaders draw: none, when the user
// asked for less motion.
let phase = if still { 0.0 } else { self.phase };
let p = self.progress;
let done = self.state == State::Done;
let tint = if done { t.success } else { t.accent };
let muted = TextStyle::new(12.0).color(t.muted);
// What a loader runs in: a tint of the text colour, so it shows
// on the card in either appearance.
let track = track_color(&t);
ui.with(
NodeSpec::column().fill().bg(t.bg).pad(24.0).gap(16.0),
|ui| {
// The job: its one button, a reset, the speed, what it says.
ui.with(NodeSpec::row().gap(12.0).cross_align(Align::Center), |ui| {
// The job's button is one node whatever it says — a label
// that changes would re-key it and drop the keyboard — and
// it has the focus from the first frame, so Space starts
// the job and pauses it.
let (theme, m) = (ui.theme(), ui.metrics());
widgets::button_with(
ui,
"job",
self.action(),
widgets::button_spec(&theme, &m).on_click(Msg::Toggle),
None,
);
if !self.focused {
self.focused = true;
let job = ui.child_key("job");
ui.focus(job);
}
widgets::button(ui, "reset", Msg::Reset);
ui.leaf(NodeSpec::row().width(12.0));
ui.text("speed", muted);
widgets::slider(ui, "speed", self.speed, 0.25, 3.0, 0.25, Msg::Speed);
ui.text(
&format!("{:.2}×", self.speed),
TextStyle::new(13.0).color(t.fg),
);
ui.leaf(NodeSpec::row().width(12.0));
let status = match self.state {
State::Idle => "idle".to_string(),
State::Running => format!("running · {:.0}%", p * 100.0),
State::Paused => format!("paused at {:.0}%", p * 100.0),
State::Done => "done".to_string(),
};
ui.with_keyed("status", NodeSpec::row(), |ui| {
ui.text(&status, TextStyle::new(13.0).color(t.fg));
});
});
ui.text("determinate: the job's progress", muted);
ui.with(NodeSpec::row().gap(12.0), |ui| {
// The pie: a disc for the track, and over it a sector from
// twelve o'clock whose sweep is the progress. A full turn
// is `sector`'s two half arcs, so 100% is a whole disc.
card(ui, "pie", &format!("pie, {:.0}%", p * 100.0), |ui| {
ui.path(
&Path::sector(C, C, R, 0.0, 0.0, 1.0),
NodeSpec::column().bg(track),
);
if p > 0.0 {
ui.path_keyed(
"fill",
&Path::sector(C, C, R, 0.0, -0.25, p),
NodeSpec::column().bg(tint).transition(200.0),
);
}
});
// The ring: the same sector with an inner radius, and the
// number in its hole.
card(ui, "ring", &format!("ring, {:.0}%", p * 100.0), |ui| {
ui.path(
&Path::sector(C, C, R, R - 9.0, 0.0, 1.0),
NodeSpec::column().bg(track),
);
if p > 0.0 {
ui.path_keyed(
"fill",
&Path::sector(C, C, R, R - 9.0, -0.25, p),
NodeSpec::column().bg(tint).transition(200.0),
);
}
ui.with(
NodeSpec::column()
.float(FloatConfig::parent().inside(Align::Center, Align::Center)),
|ui| {
ui.text(
&format!("{:.0}", p * 100.0),
TextStyle::new(16.0).color(t.fg),
);
},
);
});
// The bar: two boxes. No path in it.
card(ui, "bar", &format!("bar, {:.0}%", p * 100.0), |ui| {
ui.with(NodeSpec::column().fill().center(), |ui| {
ui.with(
NodeSpec::row()
.size(STAGE - 12.0, 8.0)
.bg(track)
.radius(4.0)
.clip(),
|ui| {
ui.leaf(
NodeSpec::row()
.size((STAGE - 12.0) * p, 8.0)
.bg(tint)
.radius(4.0),
);
},
);
});
});
});
ui.text(
if still {
"indeterminate: only the time — standing still, as the system asked"
} else {
"indeterminate: only the time"
},
muted,
);
ui.with(NodeSpec::row().gap(12.0), |ui| {
// The arc: an open path, stroked and not filled, turning
// once a second — by `rotate`, about the circle's centre,
// so it is one shape and one mask at every angle and the
// quad that draws it carries the turn (ADR 0041).
card(ui, "arc", "arc spinner", |ui| {
let end = 0.3 * TAU;
let arc = Path::new()
.move_to(C + R, C)
.arc_to(R, R, 0.0, false, true, C + R * end.cos(), C + R * end.sin())
.stroked(Stroke::new(5.0, t.accent))
.pivot(C, C)
.rotated(phase);
ui.path_keyed("arc", &arc, NodeSpec::column());
});
// The chase: a pie whose head runs ahead of its tail and
// is caught, the sweep breathing between a sliver and
// three quarters while the whole turns.
card(ui, "chase", "chasing pie", |ui| {
let sweep = 0.08 + 0.67 * (0.5 - 0.5 * (phase * 0.5 * TAU).cos());
let from = phase * 0.75 - sweep * 0.5;
ui.path_keyed(
"chase",
&Path::sector(C, C, R, R - 12.0, from, sweep),
NodeSpec::column().bg(t.accent),
);
});
// The spokes: twelve lines, the brightest stepping round
// twelve times a second and the rest fading behind it.
card(ui, "spokes", "spokes spinner", |ui| {
let head = (phase * 12.0).floor() as usize % 12;
for i in 0..12 {
let a = i as f32 / 12.0 * TAU;
let behind = (head + 12 - i) % 12;
let alpha = 1.0 - behind as f32 / 12.0 * 0.85;
ui.line_indexed(
i as u64,
Vec2::new(C + 16.0 * a.cos(), C + 16.0 * a.sin()),
Vec2::new(C + 30.0 * a.cos(), C + 30.0 * a.sin()),
Stroke::new(4.0, t.fg.with_alpha(alpha)),
NodeSpec::column(),
);
}
});
// The dots: three round boxes, each a third of a beat
// behind the last, lifted by a spacer above it.
card(ui, "dots", "bouncing dots", |ui| {
ui.with(NodeSpec::column().fill().center(), |ui| {
ui.with(NodeSpec::row().gap(8.0).height(36.0), |ui| {
for i in 0..3 {
let beat = (phase * 1.4 - i as f32 * 0.15).rem_euclid(1.0);
let lift = (beat * TAU * 0.5).sin().max(0.0) * 20.0;
ui.with(NodeSpec::column().size(12.0, 36.0), |ui| {
ui.leaf(NodeSpec::row().size(12.0, 20.0 - lift));
ui.leaf(
NodeSpec::row()
.size(12.0, 12.0)
.radius(6.0)
.bg(t.accent.with_alpha(0.55 + 0.45 * lift / 20.0)),
);
});
}
});
});
});
// The sweep: a bar that crosses its track and comes back
// in from the left, cut by the track's clip at both ends.
card(ui, "sweep", "sweeping bar", |ui| {
let len = STAGE - 12.0;
let run = len * 0.4;
let x = (phase * 0.8).rem_euclid(1.0) * (len + run) - run;
ui.with(NodeSpec::column().fill().center(), |ui| {
ui.with(
NodeSpec::row().size(len, 8.0).bg(track).radius(4.0).clip(),
|ui| {
ui.leaf(NodeSpec::row().size(x.max(0.0), 8.0));
ui.leaf(
NodeSpec::row()
.size(run + x.min(0.0), 8.0)
.bg(t.accent)
.radius(4.0),
);
},
);
});
});
// The loop: the same bar going back and forth, slowing
// into each end and out of it, and never leaving its track.
card(ui, "loop", "bar going back and forth", |ui| {
let len = STAGE - 12.0;
let run = len * 0.4;
ui.with(NodeSpec::column().fill().center(), |ui| {
ui.with(
NodeSpec::row().size(len, 8.0).bg(track).radius(4.0).clip(),
|ui| {
ui.leaf(
NodeSpec::row().size(loop_offset(phase, len, run), 8.0),
);
ui.leaf(
NodeSpec::row().size(run, 8.0).bg(t.accent).radius(4.0),
);
},
);
});
});
// The skeleton: the shape of what is coming, each line
// dimming and brightening a little after the one above.
card(ui, "skeleton", "skeleton", |ui| {
ui.with(NodeSpec::column().fill().center().gap(8.0), |ui| {
for (i, w) in [72.0, 84.0, 56.0].into_iter().enumerate() {
let wave =
0.5 + 0.5 * ((phase * 0.9 - i as f32 * 0.12) * TAU).sin();
ui.leaf(
NodeSpec::row()
.size(w, 10.0)
.radius(5.0)
.bg(t.fg.with_alpha(0.10 + 0.14 * wave)),
);
}
});
});
// The rainbow: the spectrum flowing along a track. One
// gradient, on a box two tracks long, slid left under
// the track's clip by up to a track — where the second
// turn of the colours sits exactly where the first
// began, so the loop has no seam. Only the box moves:
// the gradient is the same every frame, and so is its
// one strip in the atlas.
card(ui, "rainbow", "rainbow bar", |ui| {
let len = STAGE - 12.0;
let x = (phase * 0.6).rem_euclid(1.0) * len;
ui.with(NodeSpec::column().fill().center(), |ui| {
ui.with(NodeSpec::row().size(len, 10.0).radius(5.0).clip(), |ui| {
ui.leaf_keyed(
"spectrum",
NodeSpec::row()
.float(FloatConfig::parent().offset(-x, 0.0).clipped())
.size(len * 2.0, 10.0)
.gradient(rainbow()),
);
});
});
});
});
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Toggle) => {
self.state = match self.state {
State::Idle | State::Paused => State::Running,
State::Running => State::Paused,
State::Done => {
self.progress = 0.0;
State::Running
}
}
}
Some(Msg::Reset) => {
self.state = State::Idle;
self.progress = 0.0;
self.phase = 0.0;
}
// A slider proposes; the app stores, and the next frame draws
// the slider there.
Some(Msg::Speed) => {
if let Some(v) = ev.payload.get_float("value") {
self.speed = v as f32;
}
}
None => {}
}
}
}
/// Where the looping bar's left edge is at `phase`, on a track `len`
/// long: from 0 to `len - run` and back every two and a half seconds, on
/// a cosine, so it eases at both ends.
fn loop_offset(phase: f32, len: f32, run: f32) -> f32 {
(len - run) * (0.5 - 0.5 * (phase * 0.4 * TAU).cos())
}
/// The spectrum twice over, left to right: red round to red, and round
/// again, so a box painted with it and slid by half its length looks as
/// it did before it moved.
fn rainbow() -> Gradient {
const TURN: [u32; 6] = [
0xff5f6dff, 0xffa64dff, 0xffe066ff, 0x5fd38dff, 0x4da3ffff, 0xb580ffff,
];
let stops = TURN
.iter()
.chain(&TURN)
.chain(&TURN[..1])
.map(|&c| Color::hex(c));
Gradient::to(Side::Right, stops)
}
/// The colour of a loader's track.
fn track_color(t: &kui_native::Theme) -> Color {
t.fg.with_alpha(0.10)
}
/// One loader's card: the stage its shapes are drawn in — a path or a
/// line is a float in its parent's box space, so the stage is the origin
/// of every coordinate above — and its name under it. The stage is one
/// image to assistive technology, named for what it shows.
fn card(ui: &mut Ui<'_>, name: &str, says: &str, stage: impl FnOnce(&mut Ui<'_>)) {
let t = ui.theme();
ui.with_keyed(
name,
NodeSpec::column()
.pad(12.0)
.gap(8.0)
.bg(t.surface)
.border(1.0, t.border)
.radius(12.0)
.cross_align(Align::Center),
|ui| {
ui.with_keyed(
"stage",
NodeSpec::column()
.size(STAGE, STAGE)
.role(Role::Image)
.label(says),
stage,
);
ui.text(name, TextStyle::new(12.0).color(t.muted));
},
);
}
impl Example for Loaders {
const KEYS: &'static [(&'static str, &'static str)] = &[
(
"Space",
"start the job; the same button pauses and resumes it",
),
(
"speed",
"how fast the job goes, not how fast the loaders turn",
),
];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(1160.0, 470.0)
}
/// The job by hand: idle asks for no frame; running, the progress is
/// the time stepped and the moving paths leave the atlas; paused, it
/// holds and the window sleeps; done, the pie is a whole disc in the
/// success colour.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
self.manual = true;
let mut d = Drive::new(core, 1160.0, 470.0);
let step = |app: &mut Self, d: &mut Drive<'_>, secs: f32, frames: u32| {
for _ in 0..frames {
app.step(secs / frames as f32);
d.advance(f64::from(secs / frames as f32));
d.frame(app);
}
};
let count = |core: &mut Core, kind: QuadKind, color: Option<Color>| {
core.output()
.0
.quads
.iter()
.filter(|q| q.kind == kind && color.is_none_or(|c| q.color == c))
.count()
};
d.frame(self);
d.check(!d.core.animating(), "idle, no frame is asked for")?;
// The two tracks, and the still arc and chase: four masks, all in
// the atlas (told from the text's glyphs by their colours), and
// the twelve spokes.
let (track, accent) = (track_color(d.core.theme()), d.core.theme().accent);
let masks = count(d.core, QuadKind::GlyphMask, Some(track))
+ count(d.core, QuadKind::GlyphMask, Some(accent));
d.check(masks == 4, "idle, four paths are four atlas masks")?;
let spokes = count(d.core, QuadKind::Segment, None);
d.check(spokes == 12, "and the spokes are twelve segments")?;
d.check(d.core.path_texture_count() == 0, "with no texture")?;
// The rainbow is one image quad from the atlas, twice its track.
let spectrum = |core: &mut Core| -> Vec<(f32, [u32; 4])> {
let quads = &core.output().0.quads;
let images = quads.iter().filter(|q| q.kind == QuadKind::Image);
images.map(|q| (q.rect.x, q.uv)).collect()
};
let idle = spectrum(d.core);
d.check(idle.len() == 1, "the rainbow is one image quad")?;
d.check(idle[0].1[2..] == [256, 1], "from a strip in the atlas")?;
// A second of a four-second job, in ten frames.
let job = d.key_of("job").ok_or("no job button")?;
d.check(
d.core.focus() == Some(job),
"the job's button has the keyboard",
)?;
d.key(self, "space", Default::default());
d.check(self.state == State::Running, "and Space starts the job")?;
step(self, &mut d, 1.0, 10);
d.check(d.core.animating(), "running, the next frame is asked for")?;
d.check((self.progress - 0.25).abs() < 1e-3, "a second is a quarter")?;
let status = d.texts_under("status");
d.check(
status.iter().any(|s| s.contains("25%")),
"and the status says so",
)?;
// The pie's fill, the ring's and the chase change shape every
// frame: each draws from a texture of its own, and the two tracks
// stay the atlas's. The arc only turns: it is still its one atlas
// mask, and its quad carries the angle.
let textures = count(d.core, QuadKind::Texture, None);
d.check(textures == 3, "the three changing paths are textures")?;
let turned: Vec<f32> = d
.core
.output()
.0
.quads
.iter()
.filter(|q| q.kind == QuadKind::GlyphMask && q.color == accent)
.map(|q| q.blur)
.collect();
d.check(
turned.len() == 1 && (turned[0] - self.phase * TAU).abs() < 1e-3,
"the arc is one atlas mask, turned by its quad",
)?;
let masks = count(d.core, QuadKind::GlyphMask, Some(track));
d.check(masks == 2, "and the two tracks are still masks")?;
// The rainbow moved and was not drawn again: the same texels,
// further left.
let flowing = spectrum(d.core);
d.check(
flowing.len() == 1 && flowing[0].1 == idle[0].1 && flowing[0].0 < idle[0].0,
"the rainbow slid, and is the strip it was",
)?;
let warned = d.warnings();
d.check(warned.is_empty(), "nothing warned")?;
// The looping bar goes out and comes back: at rest on the left,
// at the far end half a period on, home again a period on.
let (len, run) = (84.0, 33.6);
let at = |phase: f32| loop_offset(phase, len, run);
d.check(
at(0.0).abs() < 1e-3 && (at(1.25) - (len - run)).abs() < 1e-3 && at(2.5).abs() < 1e-3,
"the looping bar goes to the far end and back",
)?;
// Paused: time passes and nothing moves.
d.check(d.core.focus() == Some(job), "the button kept the keyboard")?;
d.key(self, "space", Default::default());
step(self, &mut d, 1.0, 2);
d.check(
self.state == State::Paused && (self.progress - 0.25).abs() < 1e-3,
"paused, the job holds",
)?;
d.check(!d.core.animating(), "and no frame is asked for")?;
// Twice as fast, the rest takes a second and a half.
self.speed = 2.0;
d.click_key(self, job);
step(self, &mut d, 1.6, 16);
d.check(
self.state == State::Done && self.progress == 1.0,
"the job ends at 1",
)?;
// The fill's colour eases to the success role's.
step(self, &mut d, 0.5, 2);
d.check(!d.core.animating(), "done, the window sleeps")?;
let success = d.core.theme().success;
let green = count(d.core, QuadKind::Texture, Some(success))
+ count(d.core, QuadKind::GlyphMask, Some(success));
d.check(green == 2, "the pie and the ring are whole and green")?;
// Reset: back to nothing drawn over the tracks.
let reset = d.key_of("reset").ok_or("no reset button")?;
d.click_key(self, reset);
d.frame(self);
d.check(
self.state == State::Idle && self.progress == 0.0 && self.phase == 0.0,
"reset is idle at zero",
)?;
let says = d.texts_under("job");
d.check(
says.iter().any(|s| s == "start"),
"and the button offers start again",
)
}
}
kui_devtools::main!(Loaders::new());
apps/modal_editor.rs
//! A helix-flavored modal editor with the app — not kui — owning the
//! document, the keymap, and the modes. The whole keyboard arrives through
//! one `on_key` sink as `{kind="key"}` events, so the same modal dispatch
//! would work verbatim from Lua or C. kui's part is turning the state into
//! rows of text runs: selection as background segments, the caret as an
//! inline node, no text measurement anywhere.
//!
//! The document model is a deliberately dumb Vec<String> — the point is the
//! boundary, not the rope. An IO/LSP/undo layer replaces the model; the view
//! code stays.
//!
//! The mouse and the clipboard are the same shape as the keyboard: a press
//! or drag inside the sink arrives as a `drag` event carrying `line`,
//! `byte` and `clicks` (backlog C34), so click-to-caret, drag-select and
//! double-click-word are arithmetic in `on_event`; `y` and `p` go through
//! `ui.set_clipboard` / `ui.request_paste` (backlog C33), and the paste
//! comes back as a `text` event the way an IME's commit does.
//!
//! Run: cargo run -p kui-native --example modal_editor
//!
//! Keys: see the buffer text (`:help` puts a summary in the minibuffer).
use kui_devtools::Example;
use kui_native::widgets;
use kui_native::{
Align, App, Color, Core, NodeSpec, Role, TextStyle, Theme, Ui, UiEvent, Value, WindowCommand,
};
const FONT: f32 = 13.5;
const LH: f32 = 20.0;
const GUTTER_W: f32 = 52.0;
const STATUS_H: f32 = 24.0;
const MINIBUF_H: f32 = 26.0;
// ---------------------------------------------------------------- palette
#[derive(Clone, Copy)]
struct Pal {
bg: Color,
bg2: Color,
panel: Color,
status: Color,
fg: Color,
dim: Color,
faint: Color,
accent: Color,
select: Color,
insert: Color,
command: Color,
}
impl From<Theme> for Pal {
/// Every field is a theme role, including the three that look like
/// app colours: a mode indicator is a status, and the theme already
/// names the three statuses an editor has anything to say with.
fn from(t: Theme) -> Self {
Self {
bg: t.bg,
bg2: t.sunken,
panel: t.surface,
status: t.sunken,
fg: t.fg,
dim: t.muted,
faint: t.faint,
accent: t.focus_ring,
select: t.selection,
insert: t.success,
command: t.warning,
}
}
}
// ---------------------------------------------------------------- model
#[derive(Clone, Copy, PartialEq)]
enum Mode {
Normal,
Insert,
Command,
}
struct Doc {
name: String,
lines: Vec<String>,
modified: bool,
}
impl Doc {
fn new(name: &str, text: &str) -> Self {
let lines = text.split('\n').map(|l| l.replace('\t', " ")).collect();
Self {
name: name.into(),
lines,
modified: false,
}
}
}
#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
struct Pos {
line: usize,
col: usize,
}
/// The view of the document: cursor, selection anchor, scroll. `rows` is
/// written during render (the view fn knows the editor's height) and read by
/// the keymap for paging; scroll-into-view happens in render too, so the
/// caret can never leave the screen.
struct View {
cur: Pos,
anchor: Option<Pos>,
top: usize,
rows: usize,
}
impl Default for View {
fn default() -> Self {
Self {
cur: Pos::default(),
anchor: None,
top: 0,
rows: 24,
}
}
}
// ---------------------------------------------------------------- app
struct ModalEditor {
pal: Pal,
doc: Doc,
view: View,
mode: Mode,
cmd: String,
message: String,
pending: Option<char>,
/// Text to put on the clipboard at the next view — `on_event` has no
/// `Ui`, and the runner draws right after an event, so this is one
/// frame away. Linewise text ends with a newline.
clip_out: Option<String>,
/// `p` asked for the clipboard: the next `text` event is the paste.
awaiting_paste: bool,
/// Where the button went down, for a drag-select.
drag_from: Option<Pos>,
quit: bool,
}
/// The key event, back out of `Value` form.
struct KeyEv {
code: String,
text: Option<String>,
}
impl KeyEv {
/// A press, from a `{kind="key"}` payload. Modal editing is a keymap,
/// not a held-key interaction: the releases the same sink delivers
/// (`phase="up"`) are not commands, so they stop here.
/// Every key event is a press: the sink never asked for releases
/// (`key_up`), so a keymap needs no phase check.
fn from_payload(p: &Value) -> Option<Self> {
Some(Self {
code: p.get("code")?.as_str()?.to_string(),
text: p.get_str("text").map(str::to_string),
})
}
}
impl ModalEditor {
fn new() -> Self {
Self {
pal: Theme::default().into(),
doc: Doc::new("*scratch*", SAMPLE),
view: View::default(),
mode: Mode::Normal,
cmd: String::new(),
message: "modal editing demo — the buffer text is the keymap".into(),
pending: None,
clip_out: None,
awaiting_paste: false,
drag_from: None,
quit: false,
}
}
// ------------------------------------------------------------ keymap
fn on_key(&mut self, k: KeyEv) {
match self.mode {
Mode::Command => self.key_command(k),
Mode::Insert => self.key_insert(k),
Mode::Normal => self.key_normal(k),
}
}
fn key_command(&mut self, k: KeyEv) {
match k.code.as_str() {
"escape" => {
self.mode = Mode::Normal;
self.cmd.clear();
}
"enter" => {
self.mode = Mode::Normal;
let cmd = std::mem::take(&mut self.cmd);
self.exec_command(&cmd);
}
"backspace" => {
if self.cmd.pop().is_none() {
self.mode = Mode::Normal;
}
}
_ => {
if let Some(t) = &k.text {
self.cmd.push_str(t);
}
}
}
}
fn key_normal(&mut self, k: KeyEv) {
let pending = self.pending.take();
let (doc, view) = (&mut self.doc, &mut self.view);
if let Some(p) = pending {
match (p, k.code.as_str()) {
('g', "g") => {
view.cur = Pos::default();
view.anchor = None;
}
('g', "e") => {
view.cur.line = doc.lines.len().saturating_sub(1);
clamp_col(doc, view);
}
('d', "d") => {
self.clip_out = Some(doc.lines[view.cur.line].clone() + "\n");
delete_line(doc, view);
self.message = "deleted line (p puts it back)".into();
}
_ => self.message = format!("{p}{} is not a thing here", k.code),
}
return;
}
match k.code.as_str() {
"h" | "left" => move_h(doc, view, -1),
"l" | "right" => move_h(doc, view, 1),
"j" | "down" => move_v(doc, view, 1),
"k" | "up" => move_v(doc, view, -1),
"w" => word_fwd(doc, view),
"b" => word_back(doc, view),
"0" | "home" => view.cur.col = 0,
"$" | "end" => view.cur.col = line_len(doc, view.cur.line),
"pageup" => move_v(doc, view, -(view.rows as i64 - 1)),
"pagedown" => move_v(doc, view, view.rows as i64 - 1),
"G" => {
view.cur.line = doc.lines.len().saturating_sub(1);
clamp_col(doc, view);
}
"g" => self.pending = Some('g'),
"d" => {
if view.anchor.is_some() {
self.clip_out = Some(sel_lines(doc, view).join("\n"));
delete_sel(doc, view);
} else {
self.pending = Some('d');
}
}
"i" => self.mode = Mode::Insert,
"a" => {
view.cur.col = (view.cur.col + 1).min(line_len(doc, view.cur.line));
self.mode = Mode::Insert;
}
"I" => {
view.cur.col = 0;
self.mode = Mode::Insert;
}
"A" => {
view.cur.col = line_len(doc, view.cur.line);
self.mode = Mode::Insert;
}
"o" => {
open_line(doc, view, 1);
self.mode = Mode::Insert;
}
"O" => {
open_line(doc, view, 0);
self.mode = Mode::Insert;
}
"v" => {
view.anchor = if view.anchor.is_some() {
None
} else {
Some(view.cur)
}
}
"x" => {
if !delete_sel(doc, view) {
delete_char(doc, view);
}
}
"y" => {
// A whole line when nothing is selected, and linewise:
// the newline says so to the paste.
let lines = sel_lines(doc, view);
let n = lines.len();
let mut text = lines.join("\n");
if view.anchor.is_none() {
text.push('\n');
}
self.clip_out = Some(text);
view.anchor = None;
self.message = format!("yanked {n} line(s) to the clipboard");
}
"p" => {
self.awaiting_paste = true;
}
"u" => self.message = "undo is where the demo ends and your app begins".into(),
":" => {
self.mode = Mode::Command;
self.cmd.clear();
}
"escape" => view.anchor = None,
_ => {}
}
}
fn key_insert(&mut self, k: KeyEv) {
let (doc, view) = (&mut self.doc, &mut self.view);
match k.code.as_str() {
"escape" => {
self.mode = Mode::Normal;
clamp_col(doc, view);
}
"enter" => insert_text(doc, view, "\n"),
"backspace" => backspace(doc, view),
"delete" => delete_char(doc, view),
"tab" => insert_text(doc, view, " "),
"left" => move_h(doc, view, -1),
"right" => move_h(doc, view, 1),
"up" => move_v(doc, view, -1),
"down" => move_v(doc, view, 1),
"home" => view.cur.col = 0,
"end" => view.cur.col = line_len(doc, view.cur.line),
_ => {
if let Some(t) = k.text.clone() {
insert_text(doc, view, &t);
}
}
}
}
fn exec_command(&mut self, cmd: &str) {
match cmd.trim() {
"" => {}
"q" | "q!" | "qa" | "qa!" => self.quit = true,
"w" | "write" => self.write_doc(),
"wq" => {
self.write_doc();
self.quit = true;
}
"help" => {
self.message =
"h j k l · w b · gg G · i a o · v x d dd y p · :w :q — see the buffer".into()
}
other => self.message = format!("not a command: {other} (:help for the keymap)"),
}
}
fn write_doc(&mut self) {
self.doc.modified = false;
self.message = format!(
"wrote {} — well, pretended to; IO belongs to your app",
self.doc.name
);
}
// ------------------------------------------------------------ view
fn minibuffer(&self, ui: &mut Ui<'_>) {
let pal = self.pal;
ui.with(
NodeSpec::row()
.grow_width()
.height(MINIBUF_H)
.bg(pal.bg2)
.pad_xy(10.0, 0.0)
.gap(2.0)
.cross_align(Align::Center),
|ui| {
if self.mode == Mode::Command {
ui.text(":", TextStyle::new(FONT).mono().color(pal.command));
ui.text(&self.cmd, TextStyle::new(FONT).mono().color(pal.fg));
caret_bar(ui, pal.command);
} else {
ui.text(&self.message, TextStyle::new(12.0).color(pal.dim));
}
ui.leaf(NodeSpec::row().grow_width());
let hint = match self.mode {
Mode::Insert => "esc → normal",
Mode::Command => "enter run · esc cancel",
Mode::Normal => "i insert · v select · : command · :help",
};
ui.text(hint, TextStyle::new(11.0).color(pal.faint));
},
);
}
}
impl App for ModalEditor {
fn view(&mut self, ui: &mut Ui<'_>) {
if self.quit {
ui.window_command(WindowCommand::Close(ui.env().window.id));
}
// Rebuilt from the theme each frame, so the window follows the OS.
self.pal = ui.theme().into();
let pal = self.pal;
// What the keymap queued for the clipboard, and the paste it asked
// for: both are the host's, so both go out through `Ui`.
if let Some(text) = self.clip_out.take() {
ui.set_clipboard(text, None);
}
// Asked on every frame the answer is outstanding: the core queues
// one ask at a time, so this is one paste and not one per frame
// (backlog AR34).
if self.awaiting_paste {
ui.request_paste();
}
// Normal mode is a keymap, not text: the platform's input method is
// off while it holds (backlog F125), so a held `j` repeats where a
// Mac's press-and-hold would have swallowed it, and an IME left on
// does not eat the keys. Insert and command modes type text, and
// get accents, dead keys and the IME back by not declaring it.
ui.ime_off(self.mode == Mode::Normal);
ui.with(NodeSpec::column().fill().bg(pal.bg), |ui| {
widgets::titlebar(ui, "kui — modal editor (the app owns the keymap)");
let vp = ui.viewport();
let editor_h = (vp.h - widgets::TITLEBAR_H - MINIBUF_H).max(LH);
let mode = self.mode;
let (doc, view) = (&self.doc, &mut self.view);
let sink = ui.with_keyed(
"editor",
NodeSpec::column()
.fill()
.bg(pal.panel)
.clip()
.key_sink()
// The mouse: a press or drag anywhere in the sink says
// which line and byte it landed on (`on_drag`).
.on_drag(Value::Null)
// The app owns the text, so it says what the sink is; the
// lines it draws (`Role::Line` rows) are the editor's text
// to a screen reader, and selection requests come back as
// `{kind="access"}` events (see `on_access`).
.role(Role::MultilineTextInput)
.label(doc.name.as_str()),
|ui| render_editor(ui, &pal, doc, view, mode, editor_h),
);
ui.take_key_focus(sink);
self.minibuffer(ui);
});
}
fn on_event(&mut self, ev: UiEvent) {
match ev.kind() {
Some("key") => {
if let Some(k) = KeyEv::from_payload(&ev.payload) {
self.on_key(k);
}
}
// The clipboard's answer, or an IME's commit: the paste `p`
// asked for, else typed text in insert mode.
Some("text") => {
let text = ev.payload.get_str("text").unwrap_or("");
if std::mem::take(&mut self.awaiting_paste) {
paste(&mut self.doc, &mut self.view, text);
} else if self.mode == Mode::Insert {
insert_text(&mut self.doc, &mut self.view, text);
}
}
Some("drag") => self.on_drag(&ev.payload),
Some("access") => self.on_access(&ev.payload),
_ => {}
}
}
}
impl ModalEditor {
/// The mouse, as the sink's `drag` events carry it: `line` is the
/// ordinal among the drawn lines (so `view.top` maps it back into the
/// document), `byte` is into the line's drawn text, `clicks` the
/// press's count. One click places the caret, two take the word,
/// three the line; the pointer moving extends from where it pressed.
fn on_drag(&mut self, p: &Value) {
let Some(pos) = self.drag_pos(p) else {
return;
};
let clicks = p.get_int("clicks").unwrap_or(1);
let (doc, view) = (&self.doc, &mut self.view);
match p.get_str("phase") {
Some("start") => {
self.drag_from = Some(pos);
match clicks {
1 => {
view.cur = pos;
view.anchor = None;
}
2 => {
let (a, b) = word_at(doc, pos);
view.anchor = Some(a);
view.cur = b;
}
_ => {
view.anchor = Some(Pos {
line: pos.line,
col: 0,
});
view.cur = Pos {
line: pos.line,
col: line_len(doc, pos.line),
};
}
}
}
Some("move") => {
if let Some(from) = self.drag_from
&& pos != from
{
view.anchor.get_or_insert(from);
view.cur = pos;
}
}
_ => self.drag_from = None,
}
if self.mode == Mode::Command {
self.mode = Mode::Normal;
}
}
/// The document position a pointer payload names. The drawn text is
/// the line as it is — a run's spaces measure at the face's advance
/// (backlog K3) — so the byte offset into it counts back to a column
/// directly.
fn drag_pos(&self, p: &Value) -> Option<Pos> {
let line = p.get("line")?.as_int()? as usize + self.view.top;
let line = line.min(self.doc.lines.len().saturating_sub(1));
let byte = p.get("byte")?.as_int()? as usize;
Some(Pos {
line,
col: col_at(&self.doc.lines[line], byte),
})
}
}
impl ModalEditor {
/// A screen reader's text requests, in the app's own terms: lines are
/// ordinals among the drawn lines (so `view.top` maps them back into
/// the document), offsets are bytes.
fn on_access(&mut self, p: &Value) {
let text = p.get_str("text").unwrap_or("");
match p.get_str("action") {
Some("setTextSelection") => {
let (Some(anchor), Some(focus)) = (
p.get("anchor").and_then(|v| self.access_pos(v)),
p.get("focus").and_then(|v| self.access_pos(v)),
) else {
return;
};
self.view.cur = focus;
self.view.anchor = (anchor != focus).then_some(anchor);
}
Some("replaceSelectedText") => {
delete_sel(&mut self.doc, &mut self.view);
insert_text(&mut self.doc, &mut self.view, text);
}
Some("setValue") => {
self.doc.lines = text.split('\n').map(String::from).collect();
self.doc.modified = true;
self.view.cur = Pos::default();
self.view.anchor = None;
}
_ => {}
}
}
fn access_pos(&self, v: &Value) -> Option<Pos> {
let line = v.get("line")?.as_int()? as usize + self.view.top;
let line = line.min(self.doc.lines.len().saturating_sub(1));
let offset = v.get("offset")?.as_int()? as usize;
Some(Pos {
line,
col: col_at(&self.doc.lines[line], offset),
})
}
}
/// Byte offset of character column `col` in `text` (its length past the end).
fn byte_at(text: &str, col: usize) -> u32 {
text.char_indices().nth(col).map_or(text.len(), |(b, _)| b) as u32
}
/// Character column of byte offset `offset` in `text`.
fn col_at(text: &str, offset: usize) -> usize {
text[..offset.min(text.len())].chars().count()
}
// ---------------------------------------------------------------- rendering
fn mono(pal: &Pal) -> TextStyle {
TextStyle::new(FONT).mono().line_height(LH).color(pal.fg)
}
fn caret_bar(ui: &mut Ui<'_>, color: Color) {
ui.leaf(NodeSpec::column().size(2.0, LH - 4.0).bg(color));
}
#[derive(Clone, Copy, PartialEq)]
enum Caret {
Bar,
Block,
/// The block without the keyboard: an outline over the cell.
Hollow,
}
fn render_editor(ui: &mut Ui<'_>, pal: &Pal, doc: &Doc, view: &mut View, mode: Mode, h: f32) {
// The blink: the runner's clock, armed on the `caret` row the line
// below declares (backlog C35). On the off phase the caret node is
// not drawn — the row stays, which is what keeps the clock armed and
// the IME anchored — and in a window without the keyboard the runner
// parks the phase off, so the bar is not drawn at all. Only insert
// mode's bar blinks: the block of normal and command mode is solid,
// and its row says so (`caret_solid`, backlog F68), so the clock is
// not armed and an editor idling in normal mode asks for no frame.
// A solid caret's phase stays on without the keyboard too — the
// clock touches only what it blinks — so the block's unfocused look
// is this view's: hollow, the way a GUI editor's block goes when the
// window loses the keyboard (`env.focused`; backlog RG14).
let blink_on = ui.caret_visible();
let solid = mode != Mode::Insert;
let hollow = solid && !ui.env().focused;
let rows = (((h - STATUS_H - 8.0) / LH).max(1.0)) as usize;
view.rows = rows;
// Scroll the caret into view — the app's job, and two lines of it.
if view.cur.line < view.top {
view.top = view.cur.line;
}
if view.cur.line >= view.top + rows {
view.top = view.cur.line + 1 - rows;
}
let last = (view.top + rows).min(doc.lines.len());
ui.with(NodeSpec::row().fill().pad_xy(0.0, 4.0), |ui| {
// Gutter.
ui.with(
NodeSpec::column()
.width(GUTTER_W)
.grow_height()
.pad_xy(12.0, 0.0)
// Decoration: not part of the editor's text.
.role(Role::None),
|ui| {
for ln in view.top..last {
let color = if ln == view.cur.line {
pal.dim
} else {
pal.faint
};
ui.text_in(
NodeSpec::row()
.grow_width()
.height(LH)
.main_align(Align::End)
.cross_align(Align::Center),
&format!("{}", ln + 1),
TextStyle::new(11.0).mono().color(color),
);
}
},
);
// Text.
ui.with(NodeSpec::column().fill().clip(), |ui| {
for ln in view.top..last {
let kind = match (solid, hollow) {
(false, _) => Caret::Bar,
(true, false) => Caret::Block,
(true, true) => Caret::Hollow,
};
let caret =
(ln == view.cur.line && (solid || blink_on)).then_some((view.cur.col, kind));
// Where the caret and the selection's other end
// sit on this line, as byte offsets, for the
// access tree.
let access = (
(ln == view.cur.line).then(|| byte_at(&doc.lines[ln], view.cur.col)),
view.anchor
.filter(|a| a.line == ln)
.map(|a| byte_at(&doc.lines[ln], a.col)),
);
emit_line(
ui,
pal,
&doc.lines[ln],
sel_on_line(view, doc, ln),
caret,
access,
solid,
);
}
});
});
status_line(ui, pal, mode, doc, view);
}
/// One line as a row of runs: selection as background segments, the caret as
/// an inline node. No text measurement needed anywhere — the row *is* the
/// layout. (The syntax_view example adds per-char colors to this same shape.)
fn emit_line(
ui: &mut Ui<'_>,
pal: &Pal,
text: &str,
sel: Option<(usize, usize)>,
caret: Option<(usize, Caret)>,
access: (Option<u32>, Option<u32>),
solid: bool,
) {
let chars: Vec<char> = text.chars().collect();
let (caret_col, caret_kind) = match caret {
Some((c, k)) => (Some(c), Some(k)),
None => (None, None),
};
let at_sel = |i: usize| sel.is_some_and(|(a, b)| i >= a && i < b);
// One line of the editor's text to assistive technology: the text
// nodes inside this row, whatever they are split into for drawing.
let mut row = NodeSpec::row()
.grow_width()
.height(LH)
.cross_align(Align::Center)
.role(Role::Line);
if let Some(c) = access.0 {
row = row.caret(c);
if solid {
row = row.caret_solid();
}
}
if let Some(a) = access.1 {
row = row.selection_anchor(a);
}
ui.with(row, |ui| {
let mut i = 0;
let mut bar_done = false;
while i < chars.len() {
if !bar_done && caret_col == Some(i) && caret_kind == Some(Caret::Bar) {
caret_bar(ui, pal.accent);
bar_done = true;
}
if caret_col == Some(i) && caret_kind == Some(Caret::Block) {
// Block caret: one inverted cell.
ui.text_in(
NodeSpec::row()
.height(LH)
.cross_align(Align::Center)
.bg(pal.accent),
&chars[i].to_string(),
mono(pal).color(pal.bg),
);
i += 1;
continue;
}
if caret_col == Some(i) && caret_kind == Some(Caret::Hollow) {
// The block without the keyboard: the cell outlined, its
// glyph as it is.
ui.text_in(
NodeSpec::row()
.height(LH)
.cross_align(Align::Center)
.border(1.0, pal.accent),
&chars[i].to_string(),
mono(pal),
);
i += 1;
continue;
}
// Extend a run of chars sharing selection state, breaking at
// the caret cell so it can be emitted inline.
let selected = at_sel(i);
let start = i;
i += 1;
while i < chars.len() && at_sel(i) == selected && caret_col != Some(i) {
i += 1;
}
let run: String = chars[start..i].iter().collect();
if selected {
ui.text_in(
NodeSpec::row()
.height(LH)
.cross_align(Align::Center)
.bg(pal.select),
&run,
mono(pal),
);
} else {
ui.text(&run, mono(pal));
}
}
// Caret at end of line.
if caret_col == Some(chars.len()) {
match caret_kind {
Some(Caret::Bar) => caret_bar(ui, pal.accent),
Some(Caret::Block) => {
ui.leaf(NodeSpec::column().size(8.0, LH - 4.0).bg(pal.accent));
}
Some(Caret::Hollow) => {
ui.leaf(
NodeSpec::column()
.size(8.0, LH - 4.0)
.border(1.0, pal.accent),
);
}
None => {}
}
}
// Selection running past the newline.
if sel.is_some_and(|(_, b)| b > chars.len()) && caret_col != Some(chars.len()) {
ui.leaf(NodeSpec::column().size(8.0, LH).bg(pal.select));
}
});
}
fn status_line(ui: &mut Ui<'_>, pal: &Pal, mode: Mode, doc: &Doc, view: &View) {
ui.with(
NodeSpec::row()
.grow_width()
.height(STATUS_H)
.bg(pal.status)
.pad_xy(8.0, 0.0)
.gap(8.0)
.cross_align(Align::Center),
|ui| {
let (label, color) = match mode {
Mode::Normal => ("NOR", pal.accent),
Mode::Insert => ("INS", pal.insert),
Mode::Command => ("CMD", pal.command),
};
ui.text_in(
NodeSpec::row().pad_xy(8.0, 2.0).radius(4.0).bg(color),
label,
TextStyle::new(10.0).mono().color(pal.bg),
);
ui.text(&doc.name, TextStyle::new(12.0).color(pal.fg));
if doc.modified {
ui.text("●", TextStyle::new(10.0).color(pal.command));
}
ui.leaf(NodeSpec::row().grow_width());
let total = doc.lines.len();
let pct = if total <= 1 {
100
} else {
(view.cur.line * 100) / (total - 1)
};
ui.text(
&format!("{}:{} {pct}%", view.cur.line + 1, view.cur.col + 1),
TextStyle::new(11.0).mono().color(pal.dim),
);
},
);
}
// ---------------------------------------------------------------- text ops (the toy model)
fn line_len(doc: &Doc, line: usize) -> usize {
doc.lines[line].chars().count()
}
fn clamp_col(doc: &Doc, view: &mut View) {
view.cur.col = view.cur.col.min(line_len(doc, view.cur.line));
}
fn move_h(doc: &Doc, view: &mut View, dx: i64) {
if dx < 0 {
if view.cur.col > 0 {
view.cur.col -= 1;
} else if view.cur.line > 0 {
view.cur.line -= 1;
view.cur.col = line_len(doc, view.cur.line);
}
} else if view.cur.col < line_len(doc, view.cur.line) {
view.cur.col += 1;
} else if view.cur.line + 1 < doc.lines.len() {
view.cur.line += 1;
view.cur.col = 0;
}
}
fn move_v(doc: &Doc, view: &mut View, dy: i64) {
let line = view.cur.line as i64 + dy;
view.cur.line = line.clamp(0, doc.lines.len() as i64 - 1) as usize;
clamp_col(doc, view);
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn word_fwd(doc: &Doc, view: &mut View) {
let chars: Vec<char> = doc.lines[view.cur.line].chars().collect();
let mut c = view.cur.col;
while c < chars.len() && is_word(chars[c]) {
c += 1;
}
while c < chars.len() && !is_word(chars[c]) {
c += 1;
}
if c == view.cur.col && view.cur.line + 1 < doc.lines.len() {
view.cur.line += 1;
view.cur.col = 0;
} else {
view.cur.col = c;
}
}
fn word_back(doc: &Doc, view: &mut View) {
if view.cur.col == 0 {
if view.cur.line > 0 {
view.cur.line -= 1;
view.cur.col = line_len(doc, view.cur.line);
}
return;
}
let chars: Vec<char> = doc.lines[view.cur.line].chars().collect();
let mut c = view.cur.col;
while c > 0 && !is_word(chars[c - 1]) {
c -= 1;
}
while c > 0 && is_word(chars[c - 1]) {
c -= 1;
}
view.cur.col = c;
}
fn char_to_byte(s: &str, col: usize) -> usize {
s.char_indices().nth(col).map(|(b, _)| b).unwrap_or(s.len())
}
fn insert_text(doc: &mut Doc, view: &mut View, s: &str) {
doc.modified = true;
for part in s.split_inclusive('\n') {
let (text, newline) = match part.strip_suffix('\n') {
Some(t) => (t, true),
None => (part, false),
};
if !text.is_empty() {
let line = &mut doc.lines[view.cur.line];
let byte = char_to_byte(line, view.cur.col);
line.insert_str(byte, text);
view.cur.col += text.chars().count();
}
if newline {
let line = doc.lines[view.cur.line].clone();
let byte = char_to_byte(&line, view.cur.col);
let (a, b) = line.split_at(byte);
doc.lines[view.cur.line] = a.to_string();
doc.lines.insert(view.cur.line + 1, b.to_string());
view.cur.line += 1;
view.cur.col = 0;
}
}
}
fn backspace(doc: &mut Doc, view: &mut View) {
doc.modified = true;
if view.cur.col > 0 {
let byte = char_to_byte(&doc.lines[view.cur.line], view.cur.col - 1);
doc.lines[view.cur.line].remove(byte);
view.cur.col -= 1;
} else if view.cur.line > 0 {
let tail = doc.lines.remove(view.cur.line);
view.cur.line -= 1;
view.cur.col = line_len(doc, view.cur.line);
doc.lines[view.cur.line].push_str(&tail);
}
}
fn delete_char(doc: &mut Doc, view: &mut View) {
doc.modified = true;
if view.cur.col < line_len(doc, view.cur.line) {
let byte = char_to_byte(&doc.lines[view.cur.line], view.cur.col);
doc.lines[view.cur.line].remove(byte);
} else if view.cur.line + 1 < doc.lines.len() {
let tail = doc.lines.remove(view.cur.line + 1);
doc.lines[view.cur.line].push_str(&tail);
}
}
fn delete_line(doc: &mut Doc, view: &mut View) {
doc.modified = true;
if doc.lines.len() > 1 {
doc.lines.remove(view.cur.line);
view.cur.line = view.cur.line.min(doc.lines.len() - 1);
} else {
doc.lines[0].clear();
}
clamp_col(doc, view);
view.anchor = None;
}
fn sel_range(view: &View) -> Option<(Pos, Pos)> {
let a = view.anchor?;
Some(if a <= view.cur {
(a, view.cur)
} else {
(view.cur, a)
})
}
/// The selection's intersection with one display line, as a half-open char
/// range; the end can run one past the line for the picked-up newline.
fn sel_on_line(view: &View, doc: &Doc, line: usize) -> Option<(usize, usize)> {
let (s, e) = sel_range(view)?;
if line < s.line || line > e.line {
return None;
}
let a = if line == s.line { s.col } else { 0 };
let b = if line == e.line {
e.col + 1
} else {
line_len(doc, line) + 1
};
Some((a, b.min(line_len(doc, line) + 1)))
}
fn sel_lines(doc: &Doc, view: &View) -> Vec<String> {
match sel_range(view) {
None => vec![doc.lines[view.cur.line].clone()],
Some((s, e)) if s.line == e.line => {
let chars: Vec<char> = doc.lines[s.line].chars().collect();
let hi = (e.col + 1).min(chars.len());
vec![chars[s.col.min(hi)..hi].iter().collect()]
}
Some((s, e)) => {
let mut out = Vec::new();
let first: Vec<char> = doc.lines[s.line].chars().collect();
out.push(first[s.col.min(first.len())..].iter().collect());
for l in s.line + 1..e.line {
out.push(doc.lines[l].clone());
}
let last: Vec<char> = doc.lines[e.line].chars().collect();
out.push(last[..(e.col + 1).min(last.len())].iter().collect());
out
}
}
}
fn delete_sel(doc: &mut Doc, view: &mut View) -> bool {
let Some((s, e)) = sel_range(view) else {
return false;
};
doc.modified = true;
let end_tail: String = {
let chars: Vec<char> = doc.lines[e.line].chars().collect();
chars[(e.col + 1).min(chars.len())..].iter().collect()
};
let start_keep: String = {
let chars: Vec<char> = doc.lines[s.line].chars().collect();
chars[..s.col.min(chars.len())].iter().collect()
};
doc.lines.splice(s.line..=e.line, [start_keep + &end_tail]);
view.cur = s;
view.anchor = None;
clamp_col(doc, view);
true
}
fn open_line(doc: &mut Doc, view: &mut View, offset: usize) {
doc.modified = true;
doc.lines.insert(view.cur.line + offset, String::new());
view.cur.line += offset;
view.cur.col = 0;
view.anchor = None;
}
/// Linewise text (ending in a newline, as `dd` and a bare `y` leave it)
/// goes in below the current line; anything else at the caret.
fn paste(doc: &mut Doc, view: &mut View, text: &str) {
match text.strip_suffix('\n') {
Some(lines) => {
doc.modified = true;
for (i, line) in lines.split('\n').enumerate() {
doc.lines.insert(view.cur.line + 1 + i, line.to_string());
}
view.cur.line += 1;
view.cur.col = 0;
}
_ => insert_text(doc, view, text),
}
}
/// The word around `pos` as an inclusive selection — a double click's.
/// Not on a word: the run of non-word characters there.
fn word_at(doc: &Doc, pos: Pos) -> (Pos, Pos) {
let chars: Vec<char> = doc.lines[pos.line].chars().collect();
if chars.is_empty() {
return (pos, pos);
}
let c = pos.col.min(chars.len() - 1);
let class = is_word(chars[c]);
let mut a = c;
while a > 0 && is_word(chars[a - 1]) == class {
a -= 1;
}
let mut b = c;
while b + 1 < chars.len() && is_word(chars[b + 1]) == class {
b += 1;
}
(
Pos {
line: pos.line,
col: a,
},
Pos {
line: pos.line,
col: b,
},
)
}
// ---------------------------------------------------------------- content
const SAMPLE: &str = "\
modal editor
An editor keymap that isn't kui's business. This app
owns the document, the modes, and (in real life)
IO, LSP, and undo. kui turns that state into pixels.
# normal mode
h j k l move i a I A insert
w b words o O open line
0 $ line ends v select
gg ge G document x d dd delete
y p yank/paste
# commands (:)
:w write (pretend) :q :qa quit
:help esc back out
The caret block is normal mode; the bar is insert.
Try dd on this line, then p a few times.
— events are data: every key you press arrives as
{kind=key, phase=down, code=..} on one on_key sink;
a sink that also says key_up hears the release.
The same dispatch would run from Lua or C.";
impl Example for ModalEditor {
const KEYS: &'static [(&'static str, &'static str)] = &[
("i / Esc", "insert / normal"),
("h j k l", "move"),
("w b", "words"),
(":help", "the rest, in the minibuffer"),
];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default()
.size(900.0, 700.0)
.custom_titlebar()
}
fn dock(&self) -> kui_devtools::Dock {
kui_devtools::Dock::Bottom
}
/// The keymap, the mouse and the clipboard, driven (backlog C36) —
/// what the smoke round once checked by hand with real keystrokes.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
use kui_devtools::Drive;
use kui_native::{InputEvent, KeyCode, KeyMods, KeyPress, MenuAction, Vec2};
let mut d = Drive::new(core, 900.0, 700.0);
d.frame(self);
// Typed characters, as a driver reports them: the code and the
// text the press would insert, which the minibuffer reads.
let keys = |d: &mut Drive<'_>, app: &mut Self, seq: &str| {
for c in seq.chars().filter(|c| *c != ' ') {
let kp =
KeyPress::new(KeyCode::Char(c), KeyMods::default()).with_text(c.to_string());
d.input(app, InputEvent::KeyDown(kp.clone()));
d.input(app, InputEvent::KeyUp(kp.released()));
d.frame(app);
}
};
// `jjj ww v lll`: down three, two words on, select, right three —
// "docu" of "document" on line 4.
keys(&mut d, self, "jjj ww v lll");
d.check(
self.view.cur.line == 3 && self.view.anchor.is_some(),
"jjj ww v lll leaves a selection on line 4",
)?;
d.check(
sel_lines(&self.doc, &self.view) == ["docu"],
"which is the first four letters of \"document\"",
)?;
// `dd` takes the line to the clipboard, and `p` twice asks for it
// back: the clipboard is the host's, so the drive plays the host.
let lines = self.doc.lines.len();
d.key(self, "escape", KeyMods::default());
d.frame(self);
d.check(self.view.anchor.is_none(), "escape drops the selection")?;
keys(&mut d, self, "dd");
d.check(self.doc.lines.len() == lines - 1, "dd deletes the line")?;
let queued = d.core.take_menu_actions();
let linewise = matches!(&queued[..], [MenuAction::SetClipboard { text, .. }] if text.starts_with("owns the document") && text.ends_with('\n'));
d.check(linewise, "and hands it to the clipboard, linewise")?;
for _ in 0..2 {
keys(&mut d, self, "p");
let asked = d.core.take_menu_actions() == vec![MenuAction::Paste];
d.check(asked, "p asks the host for the clipboard")?;
d.input(
self,
InputEvent::Commit("owns the document, the modes, and (in real life)\n".into()),
);
d.frame(self);
}
d.check(
self.doc.lines.len() == lines + 1,
"and each paste puts the line back below the caret",
)?;
// `:help` fills the minibuffer.
keys(&mut d, self, ":help");
d.check(
self.mode == Mode::Command && self.cmd == "help",
": enters command mode and the letters go to the minibuffer",
)?;
d.key(self, "enter", KeyMods::default());
d.frame(self);
d.check(
self.mode == Mode::Normal && self.message.contains("h j k l"),
"enter runs it: the keymap summary is the message",
)?;
// The mouse (backlog C34): a press inside the sink carries the
// line, the byte and the click count; a double click takes a word.
let editor = d.key_of("editor").ok_or("no editor")?;
let r = d.rect_of(editor).ok_or("the editor has no rect")?;
let cell = d.core.measure_text("M", &mono(&self.pal), None).width;
let x = r.x + GUTTER_W + 12.5 * cell; // the thirteenth column: inside "document"
let y = r.y + 4.0 + 4.0 * LH + LH / 2.0; // the fifth drawn line
d.input(self, InputEvent::CursorMoved(Vec2::new(x, y)));
d.input(self, InputEvent::mouse_down(1));
d.input(self, InputEvent::mouse_up());
d.frame(self);
d.check(
self.view.cur.line == 4 && self.view.anchor.is_none() && self.view.cur.col > 0,
"a click places the caret on the line and column it landed on",
)?;
d.input(self, InputEvent::mouse_down(2));
d.input(self, InputEvent::mouse_up());
d.frame(self);
d.check(
self.view.anchor.is_some() && sel_lines(&self.doc, &self.view) == ["document"],
"a double click selects the word under it",
)?;
// Normal mode's block is solid (backlog F68): its row is the
// caret — the IME's anchor — but not one to blink, so the clock
// is not armed and an idle editor asks for no frame.
d.check(
self.mode == Mode::Normal && !d.core.has_caret() && d.core.ime_rect().is_some(),
"a solid block caret anchors the IME and arms no clock",
)?;
d.check(d.core.ime_off(), "normal mode turns the input method off")?;
// Without the keyboard the solid caret is still declared and its
// phase still on — the runner hides only what it blinks — and
// the block is this view's to draw hollow (backlog RG14, in F68).
let outlined = |d: &Drive<'_>| d.core.nodes().iter().filter(|n| n.border_w == 1.0).count();
d.core.set_inspect(true);
d.frame(self);
let focused_outlines = outlined(&d);
d.core.env.focused = false;
d.frame(self);
d.check(
!d.core.has_caret() && d.core.ime_rect().is_some() && d.core.caret_visible(),
"unfocused: the solid caret is still the IME's anchor, its phase on",
)?;
d.check(
outlined(&d) == focused_outlines + 1,
"and the block is drawn hollow — one outlined cell",
)?;
d.core.env.focused = true;
d.frame(self);
d.check(
outlined(&d) == focused_outlines,
"focused again: the block is filled",
)?;
d.core.set_inspect(false);
// Insert mode's bar blinks (backlog C35): on the off phase the
// caret node is gone and the `caret` row stays.
keys(&mut d, self, "i");
d.core.set_caret_visible(false);
d.frame(self);
d.check(
self.mode == Mode::Insert && d.core.has_caret(),
"the caret row is declared through the off phase",
)?;
d.check(!d.core.ime_off(), "insert mode gives the input method back")?;
d.core.set_caret_visible(true);
Ok(())
}
}
kui_devtools::main!(ModalEditor::new());
apps/splitmux.rs
//! A pane multiplexer: nested splits, tabs, and focus hopping, with the app
//! — not kui — owning the pane tree and the chord keymap. Splits are nested
//! Grow containers rebuilt from a plain enum tree each frame; a pane is just
//! its number here (the modal_editor example shows what real pane content
//! looks like). The whole keyboard arrives through one `on_key` sink as
//! `{kind="key"}` events, so the same chord dispatch would work verbatim
//! from Lua or C.
//!
//! Run: cargo run -p kui-native --example splitmux
//!
//! Keys — Alt is ⌥ Option on macOS: Alt-v/s split · Alt-o hop panes ·
//! Alt-w close · Alt-t new tab · Alt-1..9 jump to tab. Click a pane to
//! focus it. The chords read `code`, which follows the layout while the
//! layout speaks ASCII and falls back to the key's US-QWERTY position when
//! it does not — so ⌥v is on the key printed V for a Dvorak or AZERTY user,
//! and still works at all on a Cyrillic or Greek one. A keymap that wanted
//! the *shape* rather than the label (WASD) would read `physical` instead;
//! the payload carries both. Drag the strip between panes to resize a split; drag a tab
//! along the bar to reorder it (both are plain `on_drag` data — the drag
//! payload's parent rect gives the divider its ratio, and hover during the
//! drag gives tabs their live reorder). Splits ease into place: the two
//! halves carry a `transition`, so a new split slides open and a keyboard
//! resize glides — except while a divider drags, when the ratio must track
//! the cursor exactly.
//!
//! Hold ⌘ (Ctrl elsewhere) and drag a pane to move it: the modifier state
//! arrives as a `{kind="modifiers"}` event, the model keeps it, and while it
//! is held the view floats five drop-zone overlays (edges + center) over
//! every pane. They are the drag sources *and* the drop targets — no core
//! knowledge of "pane moving" at all, and without ⌘ the overlays don't
//! exist, so plain clicks still reach the pane. Hover over a zone during
//! the drag names the destination; dropping on an edge splits the target
//! on that side, on the center swaps the two panes. A ghost label follows
//! the cursor as a viewport-anchored float.
use kui_devtools::Example;
use kui_native::widgets;
use kui_native::{
Align, App, Color, Core, Drag, DragPhase, Easing, FloatConfig, KeyCode, KeyMods, Message,
NodeSpec, Sizing, TextStyle, Theme, Ui, UiEvent, WindowCommand,
};
const TABBAR_H: f32 = 30.0;
/// How long a split takes to ease into a new ratio.
const SPLIT_MS: f32 = 180.0;
#[cfg(target_os = "macos")]
const ALT: &str = "⌥";
#[cfg(not(target_os = "macos"))]
const ALT: &str = "Alt-";
#[cfg(target_os = "macos")]
const PRIMARY: &str = "⌘";
#[cfg(not(target_os = "macos"))]
const PRIMARY: &str = "Ctrl-";
// ---------------------------------------------------------------- palette
/// This app's names for the theme's roles. Every one of them *is* a role
/// — a mux is chrome all the way down — so the struct is a rename rather
/// than a palette, kept because `pal.bg2` reads better at the twenty call
/// sites below than `theme.sunken` does, and rebuilt from `ui.theme()`
/// each frame so the window follows the OS.
#[derive(Clone, Copy)]
struct Pal {
bg: Color,
bg2: Color,
panel: Color,
border: Color,
border_focus: Color,
fg: Color,
dim: Color,
faint: Color,
accent: Color,
}
impl From<Theme> for Pal {
fn from(t: Theme) -> Self {
Self {
bg: t.bg,
bg2: t.sunken,
panel: t.surface,
border: t.border,
border_focus: t.accent,
fg: t.fg,
dim: t.muted,
faint: t.faint,
accent: t.focus_ring,
}
}
}
// ---------------------------------------------------------------- model
/// What the view hangs on its nodes and `on_event` reads back (backlog
/// C50): a click hands one over as its payload, a drag inside the core's
/// `drag` event as its `tag`, and `ev.message::<Msg>()` reads either. The
/// payloads are the same plain data as before — `{kind: "focus", pane}` —
/// written and read by `#[derive(Message)]` instead of by string.
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
/// A pane clicked into focus.
Focus { pane: u64 },
/// A tab chosen.
Tab { tab: usize },
/// The `+` after the tabs.
TabNew,
/// A tab dragged along the strip to reorder it.
TabDrag { tab: usize },
/// The divider of the split at `path` dragged.
Split { path: String, dir: SplitDir },
/// A pane ⌘-dragged from one of its zones.
PaneDrag { pane: u64, zone: Zone },
}
#[derive(Message, Clone, Copy, PartialEq, Debug)]
#[message(string)]
enum SplitDir {
/// Side by side.
H,
/// Stacked.
V,
}
#[derive(Clone)]
enum Node {
Pane(u64),
Split {
dir: SplitDir,
ratio: f32,
a: Box<Node>,
b: Box<Node>,
/// Just created: rendered once with the new half collapsed so the
/// transition has somewhere to slide it open from (a node's first
/// frame snaps).
fresh: Fresh,
},
}
impl Node {
fn panes(&self, out: &mut Vec<u64>) {
match self {
Node::Pane(id) => out.push(*id),
Node::Split { a, b, .. } => {
a.panes(out);
b.panes(out);
}
}
}
/// Replaces the leaf `target` with a split of it and `new_id`.
fn split(&mut self, target: u64, dir: SplitDir, new_id: u64) -> bool {
match self {
Node::Pane(id) if *id == target => {
let old = Node::Pane(*id);
*self = Node::Split {
dir,
ratio: 0.5,
a: Box::new(old),
b: Box::new(Node::Pane(new_id)),
fresh: Fresh::B,
};
true
}
Node::Pane(_) => false,
Node::Split { a, b, .. } => {
a.split(target, dir, new_id) || b.split(target, dir, new_id)
}
}
}
/// Clears every `fresh` flag (after the collapsed first frame drew).
fn settle(&mut self) {
if let Node::Split { a, b, fresh, .. } = self {
*fresh = Fresh::No;
a.settle();
b.settle();
}
}
/// Replaces the leaf `target` with a split of it and `node`, `node`
/// going first when `before`.
fn split_with(&mut self, target: u64, dir: SplitDir, node: Node, before: bool) -> bool {
match self {
Node::Pane(id) if *id == target => {
let old = Node::Pane(*id);
let (a, b, fresh) = if before {
(node, old, Fresh::A)
} else {
(old, node, Fresh::B)
};
*self = Node::Split {
dir,
ratio: 0.5,
a: Box::new(a),
b: Box::new(b),
fresh,
};
true
}
Node::Pane(_) => false,
Node::Split { a, b, .. } => {
// Descend into the side that holds the target so `node`
// is moved, not cloned.
if a.contains(target) {
a.split_with(target, dir, node, before)
} else {
b.split_with(target, dir, node, before)
}
}
}
}
fn contains(&self, target: u64) -> bool {
match self {
Node::Pane(id) => *id == target,
Node::Split { a, b, .. } => a.contains(target) || b.contains(target),
}
}
/// Exchanges two leaves in place.
fn swap(&mut self, x: u64, y: u64) {
match self {
Node::Pane(id) if *id == x => *id = y,
Node::Pane(id) if *id == y => *id = x,
Node::Pane(_) => {}
Node::Split { a, b, .. } => {
a.swap(x, y);
b.swap(x, y);
}
}
}
/// The split at `path` ("a"/"b" steps from the root), for divider drags.
fn ratio_mut(&mut self, path: &str) -> Option<&mut f32> {
match self {
Node::Pane(_) => None,
Node::Split { ratio, a, b, .. } => match path.split_at_checked(1) {
None => Some(ratio),
Some(("a", rest)) => a.ratio_mut(rest),
Some((_, rest)) => b.ratio_mut(rest),
},
}
}
}
/// Removes a leaf, collapsing its split; None if the tree became empty.
fn without(node: Node, target: u64) -> Option<Node> {
match node {
Node::Pane(id) if id == target => None,
Node::Pane(id) => Some(Node::Pane(id)),
Node::Split {
dir,
ratio,
a,
b,
fresh,
} => match (without(*a, target), without(*b, target)) {
(Some(a), Some(b)) => Some(Node::Split {
dir,
ratio,
a: Box::new(a),
b: Box::new(b),
fresh,
}),
(Some(x), None) | (None, Some(x)) => Some(x),
(None, None) => None,
},
}
}
/// Which half of a split is new (see `Node::Split::fresh`).
#[derive(Clone, Copy, PartialEq, Eq)]
enum Fresh {
No,
A,
B,
}
/// Where a dragged pane lands on its target: an edge splits the target on
/// that side, the center swaps the two.
#[derive(Message, Clone, Copy, PartialEq, Eq, Debug)]
#[message(string)]
enum Zone {
Left,
Right,
Top,
Bottom,
Center,
}
impl Zone {
const ALL: [Zone; 5] = [
Zone::Left,
Zone::Right,
Zone::Top,
Zone::Bottom,
Zone::Center,
];
fn label(self) -> &'static str {
match self {
Zone::Left => "zl",
Zone::Right => "zr",
Zone::Top => "zt",
Zone::Bottom => "zb",
Zone::Center => "zc",
}
}
/// The overlay: a percent-sized float pinned to the pane's edge (or its
/// middle). Later zones paint and hit-test on top, so the center wins
/// over the edges and top/bottom win in the corners.
fn spec(self) -> NodeSpec {
let (ax, ay, w, h) = match self {
Zone::Left => (Align::Start, Align::Start, 0.25, 1.0),
Zone::Right => (Align::End, Align::Start, 0.25, 1.0),
Zone::Top => (Align::Start, Align::Start, 1.0, 0.25),
Zone::Bottom => (Align::Start, Align::End, 1.0, 0.25),
Zone::Center => (Align::Center, Align::Center, 0.5, 0.5),
};
NodeSpec::column()
.float(FloatConfig::parent().inside(ax, ay))
.width(Sizing::Percent(w))
.height(Sizing::Percent(h))
}
}
/// One tab: its split tree plus a stable id, so the tab's node key survives
/// reordering and its position transition has something to slide.
struct Tab {
id: u64,
root: Node,
}
// ---------------------------------------------------------------- app
struct Splitmux {
pal: Pal,
next_pane: u64,
/// One split tree per tab — the whole layout is this data.
tabs: Vec<Tab>,
next_tab: u64,
tab: usize,
focused: u64,
/// Path of the divider being dragged, for active styling while the
/// cursor is off the strip.
dragging: Option<String>,
/// Tab drag in flight: (current slot, sign of the last horizontal
/// motion, the last `dx` seen). The sign gates reorder direction so
/// unequal-width tabs can't oscillate around the cursor; it is the
/// difference between two `dx`es, since each is the displacement from
/// the press point and not a step.
tab_drag: Option<(usize, f32, f32)>,
/// Physical modifiers, straight from `{kind="modifiers"}` events.
mods: KeyMods,
/// ⌘-drag of a pane in flight: (pane id, cursor x, cursor y).
pane_drag: Option<(u64, f32, f32)>,
/// Where the pane drag would land, recomputed by the view from which
/// zone overlay is hovered (hover is last frame's layout, as always).
drop_target: Option<(u64, Zone)>,
quit: bool,
}
impl Splitmux {
fn new() -> Self {
// Launch looking like the app it wants to be: pane | (pane / pane).
let root = Node::Split {
dir: SplitDir::H,
ratio: 0.5,
a: Box::new(Node::Pane(1)),
b: Box::new(Node::Split {
dir: SplitDir::V,
ratio: 0.5,
a: Box::new(Node::Pane(2)),
b: Box::new(Node::Pane(3)),
fresh: Fresh::No,
}),
fresh: Fresh::No,
};
Self {
pal: Theme::default().into(),
next_pane: 4,
tabs: vec![Tab { id: 1, root }],
next_tab: 2,
tab: 0,
focused: 1,
dragging: None,
tab_drag: None,
mods: KeyMods::default(),
pane_drag: None,
drop_target: None,
quit: false,
}
}
/// Whether the panes wear their drop-zone overlays: while the primary
/// modifier is held, and for the whole of a pane drag (the modifier may
/// be released mid-drag).
fn overlays_on(&self) -> bool {
self.mods.primary() || self.pane_drag.is_some()
}
/// Lands `src` on `dst`: an edge zone splits `dst` on that side, the
/// center swaps them.
fn move_pane(&mut self, src: u64, dst: u64, zone: Zone) {
if src == dst {
return;
}
if zone == Zone::Center {
self.tabs[self.tab].root.swap(src, dst);
return;
}
let root = std::mem::replace(&mut self.tabs[self.tab].root, Node::Pane(u64::MAX));
let Some(mut root) = without(root, src) else {
return;
};
let (dir, before) = match zone {
Zone::Left => (SplitDir::H, true),
Zone::Right => (SplitDir::H, false),
Zone::Top => (SplitDir::V, true),
Zone::Bottom => (SplitDir::V, false),
Zone::Center => unreachable!(),
};
root.split_with(dst, dir, Node::Pane(src), before);
self.tabs[self.tab].root = root;
self.focused = src;
}
fn pane_ids(&self) -> Vec<u64> {
let mut ids = Vec::new();
self.tabs[self.tab].root.panes(&mut ids);
ids
}
fn refocus(&mut self) {
self.focused = self.pane_ids().first().copied().unwrap_or(0);
}
fn cycle_pane(&mut self) {
let ids = self.pane_ids();
if let Some(i) = ids.iter().position(|id| *id == self.focused) {
self.focused = ids[(i + 1) % ids.len()];
} else if let Some(id) = ids.first() {
self.focused = *id;
}
}
fn split(&mut self, dir: SplitDir) {
let id = self.next_pane;
self.next_pane += 1;
self.tabs[self.tab].root.split(self.focused, dir, id);
self.focused = id;
}
fn close_pane(&mut self) {
let root = std::mem::replace(&mut self.tabs[self.tab].root, Node::Pane(u64::MAX));
match without(root, self.focused) {
Some(root) => {
self.tabs[self.tab].root = root;
self.refocus();
}
None => {
self.tabs.remove(self.tab);
if self.tabs.is_empty() {
self.quit = true;
return;
}
self.tab = self.tab.min(self.tabs.len() - 1);
self.refocus();
}
}
}
fn new_tab(&mut self) {
let id = self.next_pane;
self.next_pane += 1;
self.tabs.push(Tab {
id: self.next_tab,
root: Node::Pane(id),
});
self.next_tab += 1;
self.tab = self.tabs.len() - 1;
self.focused = id;
}
// ------------------------------------------------------------ keymap
/// Alt chords, straight off the `code` string — the layout's own key
/// where the layout is Latin, the key's US-QWERTY position where it is
/// not, so these arms match on every layout without the app knowing one
/// exists. A keymap binding a shape rather than a label (a game's WASD)
/// would read `physical` here instead. Anything else would go to the
/// focused pane's content — here panes have none, so it's dropped.
fn chord(&mut self, code: &str) {
match code {
"o" => self.cycle_pane(),
"v" => self.split(SplitDir::H),
"s" => self.split(SplitDir::V),
"w" => self.close_pane(),
"t" => self.new_tab(),
_ => {
if let Ok(n @ 1..=9) = code.parse::<usize>()
&& n <= self.tabs.len()
{
self.tab = n - 1;
self.refocus();
}
}
}
}
// ------------------------------------------------------------ view
fn tab_bar(&mut self, ui: &mut Ui<'_>) {
let pal = self.pal;
ui.with(
NodeSpec::row()
.grow_width()
.height(TABBAR_H)
.bg(pal.bg2)
.pad_xy(8.0, 0.0)
.gap(4.0)
.cross_align(Align::Center)
// A tab or the `+` acts and leaves the keyboard with the
// sink: this app owns the whole keyboard (DX10).
.keep_focus(),
|ui| {
// Live reorder: while a tab drags, hovering another tab in
// the direction of motion moves it there. Hover comes from
// the previous frame's layout; the direction gate keeps
// unequal widths from swap-oscillating under a still cursor.
// Each tab wears a full-height hover column during the
// drag (a transparent float below it), so the cursor's x
// keeps reordering after it has left the bar vertically.
let dragging_tab = self.tab_drag.is_some();
let column_h = ui.viewport().h;
if let Some((from, sign, last_dx)) = self.tab_drag
&& sign != 0.0
&& from < self.tabs.len()
{
let to = (0..self.tabs.len()).find(|&j| {
let tab = ui.child_key(&format!("tab{}", self.tabs[j].id));
j != from
&& (ui.is_hovered(tab) || ui.is_hovered(tab.str("col")))
&& ((j > from && sign > 0.0) || (j < from && sign < 0.0))
});
if let Some(j) = to {
let node = self.tabs.remove(from);
self.tabs.insert(j, node);
self.tab = if self.tab == from {
j
} else if from < self.tab && j >= self.tab {
self.tab - 1
} else if from > self.tab && j <= self.tab {
self.tab + 1
} else {
self.tab
};
self.tab_drag = Some((j, sign, last_dx));
}
}
for i in 0..self.tabs.len() {
let active = i == self.tab;
let lifted = self.tab_drag.is_some_and(|(s, ..)| s == i);
let (bg, fg) = if active {
(pal.panel, pal.fg)
} else {
(Color::TRANSPARENT, pal.dim)
};
let mut ids = Vec::new();
self.tabs[i].root.panes(&mut ids);
// Keyed by tab id, not slot: a reordered tab keeps its
// identity, so `slide` eases it (and the tabs it
// displaced) into the new order instead of snapping —
// on a snappy spring, a trace of overshoot, so a row of
// tabs lands at once rather than wobbling.
let mut spec = NodeSpec::row()
.pad_xy(10.0, 4.0)
.radius(6.0)
.bg(bg)
.transition(220.0)
.easing(Easing::Snappy)
.slide()
.on_click(Msg::Tab { tab: i })
.on_drag(Msg::TabDrag { tab: i });
if lifted {
spec = spec.border(1.0, pal.border_focus);
}
ui.with_keyed(&format!("tab{}", self.tabs[i].id), spec, |ui| {
ui.text(
&format!("{} {} pane(s)", i + 1, ids.len()),
TextStyle::new(12.0).color(fg),
);
if dragging_tab {
// Hangs from the tab's bottom edge: the tab
// keeps its own hover, so a press-release on
// it is still a click.
ui.leaf_keyed(
"col",
NodeSpec::column()
.float(FloatConfig::parent().at(Align::Start, Align::End))
.width(Sizing::Percent(1.0))
.height(column_h)
.hoverable());
}
});
}
ui.text_in_keyed("tab+", NodeSpec::row()
.pad_xy(8.0, 4.0)
.radius(6.0)
.on_click(Msg::TabNew), "+", TextStyle::new(12.0).color(pal.faint));
ui.leaf(NodeSpec::row().grow_width());
ui.text(
&format!(
"{ALT}v/{ALT}s split · {ALT}o hop · {ALT}w close · {ALT}t tab · {PRIMARY}drag moves a pane"
),
TextStyle::new(11.0).color(pal.faint),
);
},
);
}
fn render_node(&mut self, ui: &mut Ui<'_>, node: &Node, path: &str) {
match node {
Node::Pane(id) => self.render_pane(ui, *id),
Node::Split {
dir,
ratio,
a,
b,
fresh,
} => {
let pal = self.pal;
let spec = match dir {
SplitDir::H => NodeSpec::row(),
SplitDir::V => NodeSpec::column(),
};
ui.with(spec.fill(), |ui| {
// A fresh split draws once with the new half collapsed;
// the transition then slides it open to the real ratio.
// That first frame snaps, so nothing is mid-flight yet
// to keep frames coming: ask for the next one.
if *fresh != Fresh::No {
ui.request_frame();
}
let (wa, wb) = match fresh {
Fresh::A => (0.0, 1.0),
Fresh::B => (1.0, 0.0),
Fresh::No => (ratio.clamp(0.05, 0.95), 1.0 - ratio.clamp(0.05, 0.95)),
};
// The halves ease between ratios, except under a divider
// drag, where the ratio has to follow the cursor exactly
// (the core snaps a transition that skipped a frame, so
// nothing replays when the drag ends).
let dragging = self.dragging.as_deref() == Some(path);
let grow = |f: f32| {
let spec = match dir {
SplitDir::H => NodeSpec::column().width(Sizing::Grow(f)).grow_height(),
SplitDir::V => NodeSpec::column().grow_width().height(Sizing::Grow(f)),
};
if dragging {
spec
} else {
spec.transition(SPLIT_MS)
}
};
ui.with_keyed("a", grow(wa), |ui| {
self.render_node(ui, a, &format!("{path}a"))
});
// The divider: a grabbable strip that drags the ratio.
// Its drag events carry the parent (this split) rect, so
// the handler turns absolute x/y into a ratio directly.
let divider = ui.child_key("divider");
let active = ui.is_hovered(divider)
|| ui.is_pressed(divider)
|| self.dragging.as_deref() == Some(path);
let bar = match dir {
SplitDir::H => NodeSpec::column().width(5.0).grow_height(),
SplitDir::V => NodeSpec::column().grow_width().height(5.0),
};
ui.leaf_keyed(
"divider",
bar.bg(if active { pal.border_focus } else { pal.bg2 })
.on_drag(Msg::Split {
path: path.to_string(),
dir: *dir,
}),
);
ui.with_keyed("b", grow(wb), |ui| {
self.render_node(ui, b, &format!("{path}b"))
});
});
}
}
}
/// A pane is just its number — swap this fn for an editor, a terminal,
/// whatever; the tree around it doesn't change.
fn render_pane(&mut self, ui: &mut Ui<'_>, id: u64) {
let pal = self.pal;
let focused = self.focused == id;
let overlays = self.overlays_on();
let drag = self.pane_drag;
let border = if focused {
pal.border_focus
} else {
pal.border
};
ui.with_keyed(
&format!("pane{id}"),
NodeSpec::column()
.fill()
.center()
.gap(8.0)
.bg(pal.panel)
.border(1.0, border)
.clip()
.on_click(Msg::Focus { pane: id }),
|ui| {
let color = if focused { pal.accent } else { pal.faint };
ui.text(&format!("{id}"), TextStyle::new(48.0).color(color));
ui.text(
if focused { "focused" } else { "click to focus" },
TextStyle::new(12.0).color(if focused { pal.fg } else { pal.dim }),
);
ui.text(
&format!("{ALT}v splits me · {ALT}w closes me"),
TextStyle::new(11.0).color(pal.faint),
);
if !overlays {
return;
}
// Drop-zone overlays: drag sources (any zone starts a drag
// of this pane) and drop targets (the hovered zone during
// a drag is the destination). Floats hit-test above the
// pane's own on_click, so ⌘-clicks never focus by accident.
for zone in Zone::ALL {
let key = ui.child_key(zone.label());
let hovered = ui.is_hovered(key);
let mut lit = false;
// Dropping a pane on itself is a no-op; don't advertise it.
if let Some((src, ..)) = drag
&& hovered
&& src != id
{
self.drop_target = Some((id, zone));
lit = true;
}
let bg = if lit {
Color {
a: 0.35,
..pal.accent
}
} else {
Color::TRANSPARENT
};
ui.leaf_keyed(
zone.label(),
zone.spec()
.bg(bg)
.transition(80.0)
.hoverable()
.on_drag(Msg::PaneDrag { pane: id, zone }),
);
}
},
);
}
/// The ghost that follows the cursor during a pane drag: a viewport
/// float placed from the drag payload's cursor position.
fn render_ghost(&self, ui: &mut Ui<'_>) {
let Some((id, x, y)) = self.pane_drag else {
return;
};
let pal = self.pal;
// Hang off the cursor's bottom-right, or its bottom-left once the
// right edge is near (the float's `fit` only clamps for viewport
// anchors; flipping sides is the host's call).
let vw = ui.viewport().w;
let float = if x + 300.0 < vw {
FloatConfig::viewport().offset(x + 14.0, y + 14.0)
} else {
FloatConfig::viewport()
.inside(Align::End, Align::Start)
.offset(x - vw - 14.0, y + 14.0)
}
.fit();
let target = self
.drop_target
.map(|(dst, zone)| match zone {
Zone::Center => format!("swap with {dst}"),
Zone::Left => format!("left of {dst}"),
Zone::Right => format!("right of {dst}"),
Zone::Top => format!("above {dst}"),
Zone::Bottom => format!("below {dst}"),
})
.unwrap_or_else(|| "drop on a pane".to_string());
ui.with_keyed(
"ghost",
NodeSpec::row()
.float(float)
.pad_xy(10.0, 6.0)
.gap(6.0)
.radius(6.0)
.bg(Color {
a: 0.92,
..pal.panel
})
.border(1.0, pal.border_focus)
.cross_align(Align::Center),
|ui| {
ui.text(&format!("pane {id}"), TextStyle::new(13.0).color(pal.fg));
ui.text(&format!("→ {target}"), TextStyle::new(12.0).color(pal.dim));
},
);
}
}
impl App for Splitmux {
fn view(&mut self, ui: &mut Ui<'_>) {
// Closing the last pane empties `tabs`, so don't build a frame from
// them — just ask the runner to close and emit nothing.
if self.quit {
ui.window_command(WindowCommand::Close(ui.env().window.id));
return;
}
// Rebuilt every frame from the theme, so an OS appearance change
// repaints the mux without a message reaching the model.
self.pal = ui.theme().into();
let pal = self.pal;
ui.with(NodeSpec::column().fill().bg(pal.bg), |ui| {
widgets::titlebar(
ui,
"splitmux — the app owns the pane tree, kui owns the pixels",
);
self.tab_bar(ui);
let root = self.tabs[self.tab].root.clone();
// The view names the drop target from hover; start each frame blank
// so a cursor that left every zone means "nowhere".
self.drop_target = None;
let sink = ui.with_keyed("main", NodeSpec::column().fill().key_sink(), |ui| {
self.render_node(ui, &root, "")
});
ui.take_key_focus(sink);
self.tabs[self.tab].root.settle();
self.render_ghost(ui);
});
}
fn on_event(&mut self, ev: UiEvent) {
// Presses only — the sink never asked for releases (`key_up`), so
// a chord fires once.
if let Some((_, k)) = ev.key_press() {
// The chord map: Alt (⌥ Option on macOS) + a letter or digit.
if k.mods.alt && !k.mods.ctrl {
self.chord(&k.code.name());
} else if k.code == KeyCode::Escape {
// Abandon a pane drag; the pointer capture runs on until
// release, but its end lands on nothing.
self.pane_drag = None;
}
return;
}
if let Some(mods) = ev.modifiers() {
self.mods = mods;
return;
}
match ev.kind() {
Some("drag") => {
let Some(d) = ev.drag() else { return };
match ev.message::<Msg>() {
Some(Msg::TabDrag { tab }) => match d.phase {
DragPhase::Start => self.tab_drag = Some((tab, 0.0, 0.0)),
DragPhase::Move => {
// `delta` is measured from the press point, so
// the direction of this move is the change
// since the last one.
if let Some((_, sign, last)) = self.tab_drag.as_mut() {
let step = d.delta.x - *last;
if step != 0.0 {
*sign = step;
}
*last = d.delta.x;
}
}
DragPhase::End => self.tab_drag = None,
},
Some(Msg::Split { path, dir }) => self.split_drag(d, path, dir),
Some(Msg::PaneDrag { pane, .. }) => self.pane_drag_event(d, pane),
_ => {}
}
}
// Everything else the app hung on a node arrives as its payload.
_ => match ev.message::<Msg>() {
Some(Msg::Focus { pane }) => self.focused = pane,
Some(Msg::Tab { tab }) => {
self.tab = tab;
self.refocus();
}
Some(Msg::TabNew) => self.new_tab(),
_ => {}
},
}
}
}
impl Splitmux {
/// ⌘-drag of a pane: the payload's cursor drives the ghost, the view's
/// hover bookkeeping names the target, and release performs the move.
fn pane_drag_event(&mut self, d: Drag, pane: u64) {
match d.phase {
DragPhase::Start => self.pane_drag = Some((pane, d.pos.x, d.pos.y)),
DragPhase::Move => {
if let Some((_, x, y)) = self.pane_drag.as_mut() {
*x = d.pos.x;
*y = d.pos.y;
}
}
DragPhase::End => {
if let (Some((src, ..)), Some((dst, zone))) = (self.pane_drag, self.drop_target) {
self.move_pane(src, dst, zone);
}
self.pane_drag = None;
self.drop_target = None;
}
}
}
/// Divider drags: absolute cursor position over the split's own rect
/// (carried in the payload) is the new ratio directly.
fn split_drag(&mut self, d: Drag, path: String, dir: SplitDir) {
if d.phase == DragPhase::End {
self.dragging = None;
return;
}
let ratio = match dir {
SplitDir::H => d.ratio().x,
SplitDir::V => d.ratio().y,
};
self.dragging = Some(path.clone());
if let Some(r) = self.tabs[self.tab].root.ratio_mut(&path) {
*r = ratio.clamp(0.05, 0.95);
}
}
}
impl Example for Splitmux {
const KEYS: &'static [(&'static str, &'static str)] = &[
("Alt-v / Alt-s", "split"),
("Alt-o", "hop panes"),
("Alt-w", "close"),
("Alt-t", "new tab"),
("Alt-1..9", "jump to tab"),
("⌘-drag", "move a pane"),
];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default()
.size(1100.0, 720.0)
.custom_titlebar()
}
fn dock(&self) -> kui_devtools::Dock {
kui_devtools::Dock::Bottom
}
/// The chord keymap, driven (backlog C36): two splits, a tab, and a
/// jump back — the keys the smoke round once checked by hand.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
use kui_devtools::Drive;
use kui_native::{InputEvent, Vec2};
let alt = KeyMods::NONE.with_alt();
let mut d = Drive::new(core, 1100.0, 720.0);
d.frame(self);
let panes = |app: &Splitmux| {
fn count(n: &Node) -> usize {
match n {
Node::Pane(_) => 1,
Node::Split { a, b, .. } => count(a) + count(b),
}
}
count(&app.tabs[app.tab].root)
};
d.check(panes(self) == 3, "three panes to start")?;
d.key(self, "v", alt);
d.frame(self);
d.check(panes(self) == 4, "Alt-v splits the focused pane")?;
d.key(self, "s", alt);
d.frame(self);
d.check(panes(self) == 5, "Alt-s splits the focused half again")?;
let focused = self.focused;
d.key(self, "o", alt);
d.frame(self);
d.check(self.focused != focused, "Alt-o hops to another pane")?;
d.key(self, "t", alt);
d.frame(self);
d.check(
self.tabs.len() == 2 && self.tab == 1 && panes(self) == 1,
"Alt-t opens a second tab with one pane and moves to it",
)?;
d.key(self, "1", alt);
d.frame(self);
d.check(
self.tab == 0 && panes(self) == 5,
"Alt-1 jumps back to the first tab, its five panes intact",
)?;
d.key(self, "w", alt);
d.frame(self);
d.check(panes(self) == 4, "Alt-w closes the focused pane")?;
// The typed messages (backlog C50), each through real input: a
// click's payload, a drag's tag.
d.click(self, 60.0, 400.0);
d.frame(self);
let left = self.focused;
d.click(self, 1040.0, 680.0);
d.frame(self);
d.check(
self.focused != left,
"a click on a pane focuses it (Msg::Focus)",
)?;
let plus = d.key_of("tab+").ok_or("no + tab")?;
let r = d.rect_of(plus).ok_or("the + tab was not laid out")?;
d.click(self, r.x + r.w / 2.0, r.y + r.h / 2.0);
d.frame(self);
d.check(
self.tabs.len() == 3 && self.tab == 2,
"the + tab opens a third tab (Msg::TabNew)",
)?;
let first = d
.key_of(&format!("tab{}", self.tabs[0].id))
.ok_or("no first tab")?;
let r = d.rect_of(first).ok_or("the first tab was not laid out")?;
d.click(self, r.x + r.w / 2.0, r.y + r.h / 2.0);
d.frame(self);
d.check(self.tab == 0, "a click on a tab selects it (Msg::Tab)")?;
let divider = d.key_of("divider").ok_or("no divider")?;
let r = d.rect_of(divider).ok_or("the divider was not laid out")?;
let (x, y) = (r.x + r.w / 2.0, r.y + r.h / 2.0);
let ratios = |app: &Splitmux| {
fn walk(n: &Node, out: &mut Vec<f32>) {
if let Node::Split { a, b, ratio, .. } = n {
out.push(*ratio);
walk(a, out);
walk(b, out);
}
}
let mut out = Vec::new();
walk(&app.tabs[app.tab].root, &mut out);
out
};
let before = ratios(self);
let (dx, dy) = if r.w < r.h { (80.0, 0.0) } else { (0.0, 60.0) };
d.input(self, InputEvent::CursorMoved(Vec2::new(x, y)));
d.input(self, InputEvent::mouse_down(1));
d.input(self, InputEvent::CursorMoved(Vec2::new(x + dx, y + dy)));
d.input(self, InputEvent::mouse_up());
d.frame(self);
d.check(
ratios(self) != before,
"dragging a divider moves its split (Msg::Split, read from the drag's tag)",
)?;
// A click on a pane focuses it, and the chords still work after:
// the trap the unit tests below pin.
let before = self.focused;
d.key(self, "o", alt);
d.frame(self);
d.check(self.focused != before, "and the keymap is still the sink's")
}
}
kui_devtools::main!(Splitmux::new());
#[cfg(test)]
mod tests {
//! The app driven headlessly through the `App` trait the runner calls:
//! build a frame, feed pointer and key input to a `Core`, hand what comes
//! back to `on_event`. Splitmux owns its whole keyboard through one sink
//! *and* draws clickable surfaces inside it, which is the shape where a
//! press quietly taking the keyboard shows up — a click on a pane once
//! killed every chord for the life of the process, because
//! `take_key_focus` is edge-triggered and does not ask twice.
use super::*;
use kui_core::widgets::TITLEBAR_H;
use kui_core::{Core, InputEvent, KeyCode, KeyPress, Size, Vec2};
fn viewport() -> Size {
Size::new(900.0, 640.0)
}
/// One frame, the way the runner pumps it.
fn frame(core: &mut Core, app: &mut Splitmux) {
let mut ui = core.frame(viewport(), 1.0);
app.view(&mut ui);
ui.finish();
}
fn feed(core: &mut Core, app: &mut Splitmux, ev: InputEvent) {
for e in core.handle_input(ev) {
app.on_event(e);
}
}
fn click(core: &mut Core, app: &mut Splitmux, x: f32, y: f32) {
feed(core, app, InputEvent::CursorMoved(Vec2::new(x, y)));
feed(core, app, InputEvent::mouse_down(1));
feed(core, app, InputEvent::mouse_up());
}
/// ⌥ plus a key on a US layout: what the layout produced and which key
/// it was are the same thing.
fn chord(core: &mut Core, app: &mut Splitmux, c: char) {
chord_on(core, app, c, c);
}
/// ⌥ plus a key on any layout: `layout` is what the active layout put on
/// the key, `physical` which key it was, exactly as a driver reports the
/// pair. `KeyPress::from_layout` resolves the `code` the keymap sees.
fn chord_on(core: &mut Core, app: &mut Splitmux, layout: char, physical: char) {
let mods = KeyMods::NONE.with_alt();
let kp = KeyPress::from_layout(KeyCode::Char(layout), KeyCode::Char(physical), mods);
feed(core, app, InputEvent::KeyDown(kp));
}
fn panes(app: &Splitmux) -> usize {
fn count(n: &Node) -> usize {
match n {
Node::Pane(_) => 1,
Node::Split { a, b, .. } => count(a) + count(b),
}
}
count(&app.tabs[app.tab].root)
}
/// Asserts that ⌥v still reaches the keymap.
fn splits(core: &mut Core, app: &mut Splitmux) -> bool {
let before = panes(app);
chord(core, app, 'v');
frame(core, app);
panes(app) == before + 1
}
fn started() -> (Core, Splitmux) {
let (mut core, mut app) = (Core::new(), Splitmux::new());
frame(&mut core, &mut app);
(core, app)
}
#[test]
fn chords_work_before_anything_is_clicked() {
let (mut core, mut app) = started();
assert!(splits(&mut core, &mut app));
}
/// The pane says "click to focus" on its own face, so this is the first
/// thing anyone does.
#[test]
fn chords_survive_clicking_a_pane() {
let (mut core, mut app) = started();
click(&mut core, &mut app, 450.0, 540.0);
frame(&mut core, &mut app);
assert_eq!(app.focused, 1, "the click still focused the pane");
assert!(splits(&mut core, &mut app));
}
/// A tab is a real control outside the sink, and pressing one leaves
/// the keyboard with the sink (`keep_focus` on the bar, DX10).
#[test]
fn chords_survive_clicking_a_tab() {
let (mut core, mut app) = started();
chord(&mut core, &mut app, 't');
frame(&mut core, &mut app);
assert_eq!(app.tabs.len(), 2, "⌥t opened a tab");
click(&mut core, &mut app, 40.0, TITLEBAR_H + TABBAR_H / 2.0);
frame(&mut core, &mut app);
assert_eq!(app.tab, 0, "the click selected the first tab");
assert!(splits(&mut core, &mut app));
}
/// The keymap is written in Latin and the app never asks what layout is
/// active, so these are the layouts it has to survive.
#[test]
fn chords_work_on_a_layout_that_is_not_latin() {
let (mut core, mut app) = started();
// Russian ЙЦУКЕН: the key US-QWERTY prints V on produces "м". Without
// the fallback every arm of `chord` would miss and the app would be
// silently keyboard-dead.
let before = panes(&app);
chord_on(&mut core, &mut app, 'м', 'v');
frame(&mut core, &mut app);
assert_eq!(panes(&app), before + 1, "⌥v splits on a Russian layout");
// Greek: ⌥t opens a tab from the key printed Τ.
chord_on(&mut core, &mut app, 'τ', 't');
frame(&mut core, &mut app);
assert_eq!(app.tabs.len(), 2, "⌥t opens a tab on a Greek layout");
}
/// A Latin layout keeps its own labels, so the chord is where the user
/// reads it rather than where QWERTY would have put it.
#[test]
fn chords_follow_the_label_on_a_latin_layout() {
let (mut core, mut app) = started();
// Dvorak: the key printed V sits where QWERTY prints ".".
let before = panes(&app);
chord_on(&mut core, &mut app, 'v', '.');
frame(&mut core, &mut app);
assert_eq!(panes(&app), before + 1, "⌥v is on the key printed V");
// And the QWERTY V position, which Dvorak prints K on, is not it.
let before = panes(&app);
chord_on(&mut core, &mut app, 'k', 'v');
frame(&mut core, &mut app);
assert_eq!(panes(&app), before, "the position alone does not split");
}
/// Grabbing the window to move it is the platform's business, not a
/// request that the app give up its keyboard.
#[test]
fn chords_survive_grabbing_the_titlebar() {
let (mut core, mut app) = started();
click(&mut core, &mut app, 450.0, TITLEBAR_H / 2.0);
frame(&mut core, &mut app);
assert!(splits(&mut core, &mut app));
}
}
apps/syntax_view.rs
//! A read-only syntax-highlighted code view: the app's highlighter assigns
//! each char a color, and every visible line becomes a row of coalesced
//! style runs — one text node per token run, no text measurement anywhere;
//! the row *is* the layout. Tree-sitter goes where `highlight()` sits in the
//! real app; `emit_line` wouldn't notice.
//!
//! This is the frame shape the kui-core `highlight` bench measures.
//!
//! One line carries a diagnostic: the runs under it are underlined by a
//! red wave in their own colour (`underline_color` + `underline_style`,
//! backlog K4), the way an editor marks an unused field — no `line` float
//! under the run, no rect arithmetic.
//!
//! Run: cargo run -p kui-native --example syntax_view
//!
//! Keys: j/k or arrows move · pageup/pagedown · g/G ends · tab next buffer.
use kui_devtools::Example;
use kui_native::widgets;
use kui_native::{
Align, App, Color, Core, NodeSpec, TextStyle, Theme, Ui, UiEvent, UnderlineStyle,
};
const FONT: f32 = 13.5;
const LH: f32 = 20.0;
const GUTTER_W: f32 = 52.0;
const STATUS_H: f32 = 24.0;
// ---------------------------------------------------------------- palette
#[derive(Clone, Copy)]
struct Pal {
bg: Color,
panel: Color,
line: Color,
status: Color,
fg: Color,
dim: Color,
faint: Color,
accent: Color,
kw: Color,
string: Color,
number: Color,
comment: Color,
ty: Color,
mac: Color,
/// The diagnostic wave: the theme's danger role.
error: Color,
}
impl From<Theme> for Pal {
/// The chrome comes off the theme; the six syntax hues are the app's
/// own, which is the honest split — a keyword's purple is authored,
/// the way ADR 0017 says a span's colour is, and no UI role names it.
/// They still come in two sets, because a hue picked to read on
/// `#0f1117` does not read on white: same families, darkened for the
/// light base, each checked past 4.5:1 on the surface it lands on.
fn from(t: Theme) -> Self {
let dark = t.is_dark();
let hue = |d: u32, l: u32| Color::hex(if dark { d } else { l });
Self {
bg: t.bg,
panel: t.surface,
line: t.sunken,
status: t.sunken,
fg: t.fg,
dim: t.muted,
faint: t.faint,
accent: t.focus_ring,
kw: hue(0xc78fe8ff, 0x7c3aabff),
string: hue(0x9cc87aff, 0x35701cff),
number: hue(0xd9a14dff, 0x8a5c08ff),
// A comment is meant to recede, so it is the theme's own
// "barely there" tier rather than a seventh hue.
comment: t.faint,
ty: hue(0x6fc3d6ff, 0x17697dff),
mac: hue(0xe09a6aff, 0xa1541cff),
error: t.danger,
}
}
}
// ---------------------------------------------------------------- model
#[derive(Clone, Copy, PartialEq)]
enum Lang {
Rust,
Text,
}
struct Doc {
name: String,
lines: Vec<String>,
lang: Lang,
}
impl Doc {
fn new(name: &str, text: &str, lang: Lang) -> Self {
let lines = text.split('\n').map(|l| l.replace('\t', " ")).collect();
Self {
name: name.into(),
lines,
lang,
}
}
/// The columns a diagnostic marks on `line`, if any: the Rust sample
/// has one, on the field its counter never reads — the columns of
/// `count` on the line that declares it.
fn diagnostic_on(&self, line: usize) -> Option<std::ops::Range<usize>> {
if self.lang != Lang::Rust {
return None;
}
let text = self.lines.get(line)?;
let at = text.find("count: i64")?;
Some(at..at + "count".len())
}
}
// ---------------------------------------------------------------- app
struct SyntaxView {
pal: Pal,
docs: Vec<Doc>,
cur: usize,
line: usize,
top: usize,
rows: usize,
}
impl SyntaxView {
fn new() -> Self {
Self {
pal: Theme::default().into(),
docs: vec![
Doc::new("main.rs", SAMPLE_RS, Lang::Rust),
Doc::new("NOTES", NOTES, Lang::Text),
],
cur: 0,
line: 0,
top: 0,
rows: 24,
}
}
fn move_line(&mut self, dy: i64) {
let total = self.docs[self.cur].lines.len() as i64;
self.line = (self.line as i64 + dy).clamp(0, total - 1) as usize;
}
fn on_key(&mut self, code: &str) {
match code {
"j" | "down" => self.move_line(1),
"k" | "up" => self.move_line(-1),
"pagedown" => self.move_line(self.rows as i64 - 1),
"pageup" => self.move_line(-(self.rows as i64 - 1)),
"g" | "home" => self.line = 0,
"G" | "end" => self.line = self.docs[self.cur].lines.len() - 1,
"tab" => {
self.cur = (self.cur + 1) % self.docs.len();
self.line = 0;
self.top = 0;
}
_ => {}
}
}
}
impl App for SyntaxView {
fn view(&mut self, ui: &mut Ui<'_>) {
// Rebuilt from the theme each frame, so the window follows the OS.
self.pal = ui.theme().into();
let pal = self.pal;
ui.with(NodeSpec::column().fill().bg(pal.bg), |ui| {
widgets::titlebar(
ui,
"kui — syntax view (highlighting is coalesced style runs)",
);
let vp = ui.viewport();
let h = (vp.h - widgets::TITLEBAR_H).max(LH);
self.rows = (((h - STATUS_H - 8.0) / LH).max(1.0)) as usize;
// Scroll the cursor line into view.
if self.line < self.top {
self.top = self.line;
}
if self.line >= self.top + self.rows {
self.top = self.line + 1 - self.rows;
}
let doc = &self.docs[self.cur];
let last = (self.top + self.rows).min(doc.lines.len());
let (top, cur_line) = (self.top, self.line);
let sink = ui.with_keyed(
"view",
NodeSpec::column().fill().bg(pal.panel).clip().key_sink(),
|ui| {
ui.with(NodeSpec::row().fill().pad_xy(0.0, 4.0), |ui| {
// Gutter.
ui.with(
NodeSpec::column()
.width(GUTTER_W)
.grow_height()
.pad_xy(12.0, 0.0),
|ui| {
for ln in top..last {
let color = if ln == cur_line { pal.dim } else { pal.faint };
ui.text_in(
NodeSpec::row()
.grow_width()
.height(LH)
.main_align(Align::End)
.cross_align(Align::Center),
&format!("{}", ln + 1),
TextStyle::new(11.0).mono().color(color),
);
}
},
);
// Text.
ui.with(NodeSpec::column().fill().clip(), |ui| {
for ln in top..last {
emit_line(
ui,
&pal,
&doc.lines[ln],
doc.lang,
ln == cur_line,
doc.diagnostic_on(ln),
);
}
});
});
status_line(ui, &pal, doc, cur_line);
},
);
ui.take_key_focus(sink);
});
}
fn on_event(&mut self, ev: UiEvent) {
// Presses only, because the sink never asked for releases (no
// `key_up`): this pane scrolls on a chord, and nothing arrives on
// the way up to be filtered out.
if ev.kind() == Some("key")
&& let Some(code) = ev.payload.get_str("code")
{
let code = code.to_string();
self.on_key(&code);
}
}
}
// ---------------------------------------------------------------- rendering
fn mono(pal: &Pal) -> TextStyle {
TextStyle::new(FONT).mono().line_height(LH).color(pal.fg)
}
/// One line as a row of coalesced color runs: adjacent chars sharing a color
/// become one text node. The cache in the core is keyed by (content, style,
/// scale) — color excluded — so token runs dedupe across lines and colors.
fn emit_line(
ui: &mut Ui<'_>,
pal: &Pal,
text: &str,
lang: Lang,
current: bool,
diagnostic: Option<std::ops::Range<usize>>,
) {
let chars: Vec<char> = text.chars().collect();
let colors = highlight(pal, &chars, lang);
// A run breaks where the diagnostic starts and ends, so the wave
// covers the marked columns and nothing beside them.
let marked = |i: usize| diagnostic.as_ref().is_some_and(|d| d.contains(&i));
let mut row = NodeSpec::row()
.grow_width()
.height(LH)
.cross_align(Align::Center);
if current {
row = row.bg(pal.line);
}
ui.with(row, |ui| {
let mut i = 0;
while i < chars.len() {
let start = i;
let color = colors[i];
let mark = marked(i);
i += 1;
while i < chars.len() && colors[i] == color && marked(i) == mark {
i += 1;
}
let run: String = chars[start..i].iter().collect();
let mut style = mono(pal).color(color);
if mark {
style = style
.underline_color(pal.error)
.underline_style(UnderlineStyle::Wavy);
}
ui.text(&run, style);
}
});
}
fn status_line(ui: &mut Ui<'_>, pal: &Pal, doc: &Doc, line: usize) {
ui.with(
NodeSpec::row()
.grow_width()
.height(STATUS_H)
.bg(pal.status)
.pad_xy(8.0, 0.0)
.gap(8.0)
.cross_align(Align::Center),
|ui| {
ui.with(
NodeSpec::row().pad_xy(8.0, 2.0).radius(4.0).bg(pal.accent),
|ui| {
let label = match doc.lang {
Lang::Rust => "RUST",
Lang::Text => "TEXT",
};
ui.text(label, TextStyle::new(10.0).mono().color(pal.bg));
},
);
ui.text(&doc.name, TextStyle::new(12.0).color(pal.fg));
ui.leaf(NodeSpec::row().grow_width());
ui.text("tab switches buffer", TextStyle::new(11.0).color(pal.faint));
let total = doc.lines.len();
let pct = if total <= 1 {
100
} else {
(line * 100) / (total - 1)
};
ui.text(
&format!("{}/{total} {pct}%", line + 1),
TextStyle::new(11.0).mono().color(pal.dim),
);
},
);
}
// ---------------------------------------------------------------- highlighter (a stand-in)
const RUST_KW: &[&str] = &[
"as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern",
"false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
"ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", "type",
"unsafe", "use", "where", "while",
];
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
/// Per-char colors for one line. Tree-sitter goes here in the real app; the
/// view code (`emit_line`) doesn't care who colored the chars.
fn highlight(pal: &Pal, chars: &[char], lang: Lang) -> Vec<Color> {
let mut out = vec![pal.fg; chars.len()];
match lang {
Lang::Text => {
if chars.first() == Some(&'#') {
out.fill(pal.accent);
} else if chars.first() == Some(&'—') {
out.fill(pal.faint);
}
}
Lang::Rust => {
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '/' && chars.get(i + 1) == Some(&'/') {
out[i..].fill(pal.comment);
break;
}
if c == '"' {
let start = i;
i += 1;
while i < chars.len() && chars[i] != '"' {
i += if chars[i] == '\\' { 2 } else { 1 };
}
i = (i + 1).min(chars.len());
out[start..i].fill(pal.string);
continue;
}
if c.is_ascii_digit() && (i == 0 || !is_word(chars[i - 1])) {
let start = i;
while i < chars.len()
&& (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '.')
{
i += 1;
}
out[start..i].fill(pal.number);
continue;
}
if is_word(c) && (i == 0 || !is_word(chars[i - 1])) {
let start = i;
while i < chars.len() && is_word(chars[i]) {
i += 1;
}
let word: String = chars[start..i].iter().collect();
let color = if chars.get(i) == Some(&'!') {
Some(pal.mac)
} else if RUST_KW.contains(&word.as_str()) {
Some(pal.kw)
} else if word.chars().next().is_some_and(char::is_uppercase) {
Some(pal.ty)
} else {
None
};
if let Some(color) = color {
out[start..i].fill(color);
}
continue;
}
i += 1;
}
}
}
out
}
// ---------------------------------------------------------------- content
const NOTES: &str = "\
syntax_view
Per-char colors come from the app's highlighter;
kui just draws coalesced runs of styled text.
Every visible line is one row node; every token
run is one text node. No measurement, no spans
API — the row is the layout.
— swap highlight() for tree-sitter and emit_line
never knows the difference.
— the kui-core `highlight` bench builds frames of
exactly this shape to keep the cost honest.";
const SAMPLE_RS: &str = "\
//! Minimal Elm-ish counter: view() rebuilds the tree
//! from state, clicks arrive as data in on_event.
use kui_native::widgets;
use kui_native::{App, NodeSpec, TextStyle, Ui, UiEvent, Value};
#[derive(Default)]
struct Counter {
count: i64,
}
impl App for Counter {
fn view(&mut self, ui: &mut Ui<'_>) {
ui.configure_root(NodeSpec::column().fill().center().gap(24.0));
ui.with(
NodeSpec::column().pad(32.0).gap(20.0).radius(12.0),
|ui| {
ui.text(\"kui counter\", TextStyle::new(14.0));
ui.text(&self.count.to_string(), TextStyle::new(56.0));
widgets::button(ui, \"+1\", Value::map([(\"kind\", \"inc\".into())]));
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.payload.get(\"kind\").and_then(Value::as_str) {
Some(\"inc\") => self.count += 1,
Some(\"dec\") => self.count -= 1,
_ => {}
}
}
}
fn main() {
kui_native::run(\"kui — counter\", Counter::default(), vec![]).unwrap();
}";
impl Example for SyntaxView {
const KEYS: &'static [(&'static str, &'static str)] = &[
("j / k, arrows", "move"),
("PgUp / PgDn", "page"),
("g / G", "ends"),
("Tab", "next buffer"),
];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default()
.size(900.0, 700.0)
.custom_titlebar()
}
fn dock(&self) -> kui_devtools::Dock {
kui_devtools::Dock::Bottom
}
/// The keys, driven (backlog C36): `j`, `G` and `tab` move the line,
/// the view and the buffer — and the view keeps the line on screen.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
use kui_devtools::Drive;
use kui_native::KeyMods;
let mut d = Drive::new(core, 900.0, 700.0);
d.frame(self);
d.check(
self.cur == 0 && self.line == 0 && self.top == 0,
"the first buffer, at its top",
)?;
d.key(self, "j", KeyMods::default());
d.frame(self);
d.check(self.line == 1 && self.top == 0, "j moves down a line")?;
d.key(self, "G", KeyMods::default());
d.frame(self);
let last = self.docs[0].lines.len() - 1;
d.check(self.line == last, "G goes to the last line")?;
d.check(
self.top > 0 && self.top + self.rows > last,
"and the view scrolled so the line is on screen",
)?;
d.key(self, "k", KeyMods::default());
d.frame(self);
d.check(self.line == last - 1, "k moves up")?;
// Tab is the sink's, not the ring's: a sink that holds focus keeps
// every key (ADR 0002, decision 3).
d.key(self, "tab", KeyMods::default());
d.frame(self);
d.check(
self.cur == 1 && self.line == 0 && self.top == 0,
"tab switches to the next buffer, at its top",
)?;
d.key(self, "tab", KeyMods::default());
d.frame(self);
d.check(self.cur == 0, "and wraps around")
}
}
kui_devtools::main!(SyntaxView::new());
widgets/
One element or one stock widget, in every state it has.
buttoncellscontext_menucontrolseditfragmentimagelinemenu_barpathpolygonselecttabletexttitlebartooltipvirtual_list
widgets/button.rs
//! The stock button, in every state it has. `widgets::button(ui, text,
//! payload)` is the whole of it for most uses: a keyed row with the
//! theme's three backgrounds (rest, hover, pressed), a label readable on
//! whichever it is, a click payload, and a `button` role named by its
//! text. `button_with` hands the spec back to the caller for the rest —
//! `accent` (a no-op on the stock spec since backlog AR41, which paints
//! every plain button from the theme's accent trio; it still recolours a
//! spec of the app's own), `disabled` (dimmed, inert, out of the Tab
//! ring), a `label` when the text is not the name, a `description`, a
//! `tooltip` hint — and
//! `button_spec` / `button_palette` / `readable_on` are the pieces for a
//! button of the app's own colour that still reads as the same control.
//!
//! Every binding's `button` lowers to this one function, so none of them
//! can end up with a button of its own; the access rows it admits are
//! `schema::BUTTON_ROWS_JSX`.
//!
//! Run: cargo run -p kui-native --example button [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{Align, App, Color, Core, NodeSpec, TextStyle, Ui, UiEvent, Value};
#[derive(Default)]
struct Buttons {
last: Option<String>,
presses: u32,
}
impl App for Buttons {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(24.0)
.gap(18.0)
.cross_align(Align::Start),
|ui| {
ui.text("`widgets::button` · the theme's accent trio (rest, hover, pressed; `accent` adds nothing to a stock spec), the ring on Tab, disabled", TextStyle::new(12.0).color(t.muted));
ui.with(NodeSpec::row().gap(10.0), |ui| {
widgets::button(ui, "plain", Value::str("plain"));
widgets::button_with(
ui,
"accent",
"accent",
widgets::button_spec(&ui.theme(), &ui.metrics()).accent().on_click("accent"),
None,
);
widgets::button_with(
ui,
"disabled",
"disabled",
widgets::button_spec(&ui.theme(), &ui.metrics()).disabled(true).on_click("disabled"),
None,
);
});
ui.text("`button_with` · the access rows: a label, a description, a hint", TextStyle::new(12.0).color(t.muted));
ui.with(NodeSpec::row().gap(10.0), |ui| {
// The text is a glyph; the name is what a reader says.
widgets::button_with(
ui,
"add",
"+",
widgets::button_spec(&ui.theme(), &ui.metrics()).on_click("add").label("add a row"),
None,
);
// A `description` is what a reader says after the
// name; a tooltip hint *is* one, so a button says it
// once (`tooltip`) and both the float and the reader
// have it.
widgets::button_with(
ui,
"delete",
"delete",
widgets::button_spec(&ui.theme(), &ui.metrics())
.on_click("delete")
.tooltip("removes the row for good — no undo"),
None,
);
});
ui.text("`button_spec` + `button_palette` · a button in the app's own colour, still the same control", TextStyle::new(12.0).color(t.muted));
ui.with(NodeSpec::row().gap(10.0), |ui| {
for (name, base) in [
("forest", Color::hex(0x2f7d4fff)),
("plum", Color::hex(0x7d2f6bff)),
("sand", Color::hex(0xe0c070ff)),
] {
let (rest, hover, pressed) = widgets::button_palette(base);
let spec = widgets::button_spec(&ui.theme(), &ui.metrics())
.bg(rest)
.hover_bg(hover)
.pressed_bg(pressed)
.on_click(Value::str(name));
// Readable on the base, whichever it is — the rule
// the stock button applies to its own.
let fg = widgets::readable_on(rest);
ui.text_in_keyed(name, spec, name, TextStyle::new(widgets::BUTTON_TEXT).color(fg));
}
});
ui.text(
&format!(
"{} presses · last: {}",
self.presses,
self.last.as_deref().unwrap_or("nothing")
),
TextStyle::new(12.0).color(t.faint),
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
if let Some(s) = ev.payload.as_str() {
self.presses += 1;
self.last = Some(s.to_string());
}
}
}
impl Example for Buttons {
const KEYS: &'static [(&'static str, &'static str)] =
&[("Tab", "the ring"), ("Enter / Space", "press")];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(560.0, 320.0)
}
/// A click by label presses; a disabled button does not, and is not
/// in the ring; the access rows come back as declared.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 560.0, 320.0);
d.frame(self);
let plain = d.key_of("plain").ok_or("no plain button")?;
d.click_key(self, plain);
d.check(
self.last.as_deref() == Some("plain") && self.presses == 1,
"a click presses",
)?;
let disabled = d.key_of("disabled").ok_or("no disabled button")?;
d.click_key(self, disabled);
d.check(self.presses == 1, "a disabled button is inert")?;
let mut ring = Vec::new();
for _ in 0..8 {
d.input(
self,
kui_native::InputEvent::Key(kui_native::EditKey::Tab, Default::default()),
);
if let Some(k) = d.core.focus() {
ring.push(d.core.label_of(k).unwrap_or("?").to_string());
}
}
d.check(!ring.iter().any(|n| n == "disabled"), "and not in the ring")?;
d.check(
ring.iter().any(|n| n == "forest"),
"the app-coloured button is a button like the rest",
)?;
let node = |d: &mut Drive<'_>, name: &str| {
d.core
.access_tree()
.nodes
.iter()
.find(|n| n.name.as_deref() == Some(name))
.map(|n| (n.role, n.description.clone()))
};
let add = node(&mut d, "add a row");
d.check(
add.is_some(),
"a `label` is the name a reader says, not the glyph",
)?;
let delete = node(&mut d, "delete");
d.check(
delete.and_then(|(_, desc)| desc).as_deref()
== Some("removes the row for good — no undo"),
"and a tooltip hint is the `description` a reader says after the name",
)
}
}
kui_devtools::main!(Buttons::default());
widgets/cells.rs
//! The `cells` element: one screenful of a terminal as a grid — a
//! character, a colour and a background per cell, laid out row-major in
//! a monospace style, with a block cursor and an `origin_line` that says
//! where this screenful sits in the session's own history. The grid is
//! data the app rebuilds every frame from its own screen model; nothing
//! is retained in the core but what a `cells` node keeps for itself.
//!
//! Its selection is in *cells*, not in bytes (ADR 0017): drag out a block
//! of the screen, hold Alt for a rectangle, double-click a word,
//! triple-click a row. What a copy takes is what a terminal copies — the
//! lines, each one's trailing blanks trimmed — and its ends are absolute
//! lines, so the readout says which lines of the session they are and
//! not which rows of the screen. The screen scrolls through the app: the
//! grid declares `on_scroll`, so the wheel over it — and a drag-select
//! held past its top or bottom edge — arrives as a `scroll` event whose
//! `lines` the app adds to its own `top` (ADR 0029). A selection's ends
//! stay where they were through it, which the readout shows.
//!
//! The bench table's frame is box drawing and its bars are block
//! elements, and neither comes from the font: a `cells` node draws
//! U+2500–U+259F from the cell box, so every `│` is the row's full
//! height and the frame has no seams (backlog F66) — through the font
//! they were 1.25 em tall in a 20 px row, a dash with a gap under it.
//!
//! Run: cargo run -p kui-native --example cells [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::cells::flags;
use kui_native::{
Align, App, Cell, CellCursor, CellGrid, Core, FontFamily, NodeSpec, TextStyle, Theme, Ui,
UiEvent,
};
/// What a line of the fake session is *for*. A terminal's palette is the
/// app's, but these three are roles the theme already names — so the
/// screen reads on a light desktop instead of staying the grey it was
/// picked to be on a dark one.
#[derive(Clone, Copy)]
enum Ink {
/// A prompt, a command, ordinary output.
Plain,
/// The compiler's own chatter.
Quiet,
/// A passing test run.
Good,
}
impl Ink {
fn of(self, t: &Theme) -> u32 {
match self {
Ink::Plain => t.fg,
Ink::Quiet => t.muted,
Ink::Good => t.success,
}
.to_hex()
}
}
/// The fake session: a prompt, a command, its output, twice over, so the
/// screen can scroll through it. Trailing blanks are the app's own
/// padding, which is exactly what a copy has to trim.
const SESSION: [(&str, Ink); 12] = [
("~/kui $ cargo test -p kui-core", Ink::Plain),
(" Compiling kui-core v0.1.0-alpha.10", Ink::Quiet),
(" Finished `test` profile in 3.42s", Ink::Quiet),
("running 23 tests ..............", Ink::Plain),
("test result: ok. 23 passed; 0 failed", Ink::Good),
("~/kui $ cargo bench -p kui-core -- layout", Ink::Plain),
(" Compiling kui-core v0.1.0-alpha.10", Ink::Quiet),
("┌────────────────┬──────────┬───────┐", Ink::Plain),
("│ deep_nesting │ 41.2 µs │ ▁▂▃▅▇ │", Ink::Plain),
("│ list_10k_rows │ 2.98 ms │ ▇▅▃▂▁ │", Ink::Plain),
("└────────────────┴──────────┴───────┘", Ink::Plain),
("~/kui $ ", Ink::Plain),
];
/// Where the session's first line sits in its history: an end of a
/// selection is an *absolute* line, so scrolling the screen under it does
/// not move it (`origin_line`).
const FIRST_LINE: u64 = 1_204;
const TERM_COLS: usize = 44;
/// Rows on screen: fewer than the session has, so it scrolls.
const TERM_ROWS: usize = 6;
#[derive(Default)]
struct Cells {
/// The first session line on screen.
top: usize,
}
impl App for Cells {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
// One screenful of cells from the session at `top`. The app pads
// its own lines, which is why a copy trims them.
let mut cells = vec![Cell::new(' ', t.muted.to_hex(), 0); TERM_ROWS * TERM_COLS];
for r in 0..TERM_ROWS {
let Some((line, ink)) = SESSION.get(self.top + r) else {
break;
};
let fg = ink.of(&t);
for (c, ch) in line.chars().take(TERM_COLS).enumerate() {
cells[r * TERM_COLS + c] = Cell::new(ch, fg, 0);
}
// A shell's "did you mean": the word after `--` on the bench
// line carries an undercurl in the accent (SGR 4:3 + 58,
// `flags::WAVY` + `ul`; backlog K4).
if let Some(at) = line.find("-- ") {
let word = at + 3..line.len().min(TERM_COLS);
for c in word {
cells[r * TERM_COLS + c] = cells[r * TERM_COLS + c]
.with(flags::WAVY)
.underline_color(t.accent.to_hex());
}
}
}
// The cursor sits after the last prompt, when it is on screen.
let cursor = (SESSION.len() - 1)
.checked_sub(self.top)
.filter(|r| *r < TERM_ROWS)
.map(|r| (r, 8, CellCursor::Block, t.accent.with_alpha(0.7)));
let grid = CellGrid {
rows: TERM_ROWS,
cols: TERM_COLS,
cells: &cells,
style: TextStyle::new(13.0).family(FontFamily::Mono).color(t.fg),
cursor,
origin_line: FIRST_LINE + self.top as u64,
};
// What is selected: which *lines* of the session, since the ends
// are absolute and the rows are not.
let selected = ui.cell_selection().map(|sel| {
let (a, b) = sel.ordered();
format!("lines {}–{} of the session selected", a.line, b.line)
});
ui.with(
NodeSpec::column()
.fill()
.cross_align(Align::Center)
.pad(24.0)
.gap(12.0),
|ui| {
ui.with(
NodeSpec::column()
.grow_width()
.max_width(620.0)
.pad(12.0)
.gap(8.0)
.bg(t.surface)
.radius(10.0)
.border(1.0, t.border),
|ui| {
ui.text(
"a terminal selects in cells — double-click a word, Alt-drag a rectangle",
TextStyle::new(13.0).color(t.muted),
);
ui.cells_keyed(
"term",
&grid,
NodeSpec::column()
.grow_width()
.pad(10.0)
.radius(6.0)
.bg(t.sunken)
.border(1.0, t.border)
// A scope of one grid: the drag selects
// cells, and the stock menu's Select All
// takes the whole screen.
.selectable()
// The wheel, and a drag held past the
// edge, ask the app to scroll: the
// screen is the app's, so the core
// cannot.
.on_scroll("scroll"),
);
ui.with(
NodeSpec::row()
.grow_width()
.gap(8.0)
.cross_align(Align::Center),
|ui| {
ui.text(
&format!(
"lines {}–{} on screen",
FIRST_LINE + self.top as u64,
FIRST_LINE + (self.top + TERM_ROWS) as u64 - 1
),
TextStyle::new(12.0).color(t.muted),
);
ui.leaf(NodeSpec::row().grow_width());
ui.text(
selected.as_deref().unwrap_or("nothing selected"),
TextStyle::new(12.0).color(t.accent),
);
},
);
},
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
// The wheel over the grid, or a drag-select held past its edge:
// `lines` is how many rows later (positive) or earlier the screen
// should move — the whole lines the delta covered, the fraction
// carried by the core to the next notch.
if ev.kind() == Some("scroll") {
let lines = ev.payload.get_int("lines").unwrap_or(0);
self.top =
(self.top as i64 + lines).clamp(0, (SESSION.len() - TERM_ROWS) as i64) as usize;
}
}
}
impl Example for Cells {
const KEYS: &'static [(&'static str, &'static str)] = &[
("drag", "select cells; past the edge scrolls"),
("Alt-drag", "a rectangle"),
("⇧-click", "extend the selection"),
("double / triple click", "a word / a row"),
("wheel", "scroll the session"),
("⌘C", "copy, trailing blanks trimmed"),
];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(680.0, 300.0)
}
/// Selects a block by dragging across the grid, then scrolls the
/// screen under it with the wheel: the ends are absolute lines and
/// stay where they were. Then a drag held past the bottom edge asks
/// the app to scroll, a frame at a time, and the live end follows.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 680.0, 300.0);
d.frame(self);
let term = d.key_of("term").ok_or("no grid")?;
let r = d.rect_of(term).ok_or("the grid has no rect")?;
// A drag from the second row to the fourth.
let (x0, y0) = (r.x + 30.0, r.y + 10.0 + 1.5 * 18.0);
let (x1, y1) = (r.x + 200.0, r.y + 10.0 + 3.5 * 18.0);
d.input(
self,
kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(x0, y0)),
);
d.input(self, kui_native::InputEvent::mouse_down(1));
d.input(
self,
kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(x1, y1)),
);
d.input(self, kui_native::InputEvent::mouse_up());
d.frame(self);
let sel = d.core.cell_selection().ok_or("the drag selected nothing")?;
let (a, b) = sel.ordered();
d.check(a.line < b.line, "a drag selects a block of cells")?;
d.check(
a.line >= FIRST_LINE && b.line < FIRST_LINE + TERM_ROWS as u64,
"and its ends are absolute session lines",
)?;
let copied = d.core.copy_selection().unwrap_or_default();
d.check(
!copied.is_empty() && !copied.lines().any(|l| l.ends_with(' ')),
"a copy is the lines with their trailing blanks trimmed",
)?;
// Scroll the screen under the selection: the wheel over the grid
// is a `scroll` event with the lines it covers, the app moves its
// `top`, and the ends stay put.
let top = self.top;
d.wheel(self, x1, y1, 0.0, -2.0 * 18.0);
d.frame(self);
d.check(
self.top == top + 2,
"a two-row wheel notch is two lines the app scrolls by",
)?;
let after = d
.core
.cell_selection()
.ok_or("scrolling lost the selection")?;
let (a2, b2) = after.ordered();
d.check(
(a2.line, b2.line) == (a.line, b.line),
"scrolling the screen leaves the selection on its lines",
)?;
// A drag held past the bottom edge: the core asks the app for
// lines every frame, at a rate from how far past, and the live
// end follows the pointer onto the moved screen (ADR 0029).
d.input(
self,
kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(x0, y0)),
);
d.input(self, kui_native::InputEvent::mouse_down(1));
let below = r.y + r.h + 80.0;
d.input(
self,
kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(x1, below)),
);
let anchor = d.core.cell_selection().ok_or("no drag")?.anchor.line;
let top = self.top;
for _ in 0..30 {
d.advance(1.0 / 60.0);
d.frame(self);
}
d.check(
self.top > top,
"half a second past the edge scrolled the screen",
)?;
d.input(self, kui_native::InputEvent::mouse_up());
d.frame(self);
let held = d.core.cell_selection().ok_or("the drag is gone")?;
d.check(held.anchor.line == anchor, "the anchor kept its line")?;
d.check(
held.focus.line == FIRST_LINE + (self.top + TERM_ROWS) as u64 - 1,
"and the live end is on the last row of the moved screen",
)
}
}
kui_devtools::main!(Cells::default());
widgets/context_menu.rs
//! Context menus: who gets one, and what arrives when a row is chosen
//! (`docs/adr/0017-selection-as-a-scope.md`, decision 5). Four things to
//! right-click, each a different rule:
//!
//! * **The article.** Nothing declares a menu there, so the core offers
//! the standard one for a `selectable` scope — Copy, and Select All,
//! with Copy dimmed until something is selected.
//! * **A row of the list.** The row declares `on_context_menu`, so the
//! app's own menu wins: the standard items *and* two of the app's,
//! opened with `Ui::open_menu` on the next frame (an `on_event` has no
//! `Ui`). Whichever is chosen arrives as one `menu` event on the row.
//! * **The field.** An editor gets Cut / Copy / Paste / Select All
//! without asking for anything.
//! * **The footer.** A plain box: no menu, because a right-click on
//! nothing has never opened one.
//!
//! On macOS all of them are the platform's own `NSMenu`, because the
//! runner says it can draw one; everywhere else the core draws the same
//! items itself — the dock's `menus` row switches between the two. The
//! app's code is identical either way, which is the point of the items
//! being data.
//!
//! Run: cargo run -p kui-native --example context_menu [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::{
Align, App, Core, EditOptions, Key, Menu, MenuItem, MenuRole, NodeSpec, Span, TextStyle, Theme,
Ui, UiEvent, Value, Vec2,
};
const ROWS: [&str; 4] = ["alpha", "bravo", "charlie", "delta"];
#[derive(Default)]
struct Demo {
/// The last thing a menu reported, shown at the bottom — the whole
/// point of the items being data is that this is an ordinary event.
last: Option<String>,
/// Rows the app's own menu archived, to show a custom item doing
/// something the core could not have done for it.
archived: Vec<String>,
/// A menu the app was asked for and has not opened yet. `on_event`
/// has no `Ui` — an app changes its model there and builds from it —
/// so the request waits one frame, which is the frame it opens in.
pending: Option<(Key, Vec2, String)>,
}
impl App for Demo {
fn view(&mut self, ui: &mut Ui<'_>) {
// A menu the last frame's right-click asked for. Opened here
// because this is where a `Ui` is; the frame after this one draws
// it (or the platform shows it, on a host with menus of its own).
if let Some((target, at, row)) = self.pending.take() {
ui.open_menu(Menu::new(target, at, self.row_menu(&row)));
}
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(28.0)
.gap(16.0)
.cross_align(Align::Center)
.scroll_y(),
|ui| {
ui.with(
NodeSpec::column().grow_width().max_width(620.0).gap(16.0),
|ui| {
self.article(ui, &t);
self.list(ui, &t);
self.field(ui, &t);
self.footer(ui, &t);
},
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.kind() {
// Every chosen row, standard or not, arrives here — on the
// node the menu was about, with the role it played.
Some("menu") => {
let role = ev.payload.get_str("role").unwrap_or("");
let item = ev.payload.get("item").cloned().unwrap_or(Value::Null);
// A custom row says what it is *and* what it is about: the
// core hands back whatever the item carried, so a menu
// needs no lookup from the key to the thing.
let did = item.get_str("do");
let row = item.get_str("row").unwrap_or("");
if did == Some("archive") && !self.archived.iter().any(|a| a == row) {
self.archived.push(row.to_string());
}
self.last = Some(match did {
Some(d) => format!("{d} {row}"),
None => format!("{role}{}", if role == "custom" { " item" } else { "" }),
});
}
// A row asked to own its menu, so the app opens one: the
// standard items it wants, plus its own two.
Some("contextmenu") => {
let at = Vec2::new(num(&ev, "x"), num(&ev, "y"));
let tag = ev.payload.get_str("tag").unwrap_or_default().to_string();
self.pending = Some((ev.key, at, tag));
}
_ => {}
}
}
}
impl Demo {
fn article(&mut self, ui: &mut Ui<'_>, t: &Theme) {
ui.with_keyed("article", card(t).selectable().pad(20.0).gap(10.0), |ui| {
ui.text("Selectable article", TextStyle::new(20.0).color(t.fg));
ui.rich_text(
&[
Span::new("Right-click for the standard menu; nothing here declares one, so "),
Span::new("the core offers what it can do")
.bold()
.color(t.accent),
Span::new(
": Copy, and Select All — Copy dimmed until a drag selects something.",
),
],
TextStyle::new(15.0).line_height(24.0).color(t.fg),
);
});
}
fn list(&mut self, ui: &mut Ui<'_>, t: &Theme) {
ui.with(card(t).pad(12.0).gap(2.0), |ui| {
ui.text(
"Rows with a menu of their own",
TextStyle::new(13.0).color(t.muted),
);
for name in ROWS {
let archived = self.archived.iter().any(|a| a == name);
ui.with_keyed(
name,
NodeSpec::row()
.grow_width()
.pad_xy(10.0, 8.0)
.radius(6.0)
.hover_bg(t.hover)
// The declaration that wins: the core stands back
// and this reaches `on_event` instead.
.on_context_menu(Value::str(name)),
|ui| {
let style =
TextStyle::new(14.0).color(if archived { t.muted } else { t.fg });
ui.text(name, style);
if archived {
ui.leaf(NodeSpec::row().grow_width());
ui.text("archived", TextStyle::new(12.0).color(t.muted));
}
},
);
}
});
}
fn field(&mut self, ui: &mut Ui<'_>, t: &Theme) {
ui.with(card(t).pad(12.0).gap(8.0), |ui| {
ui.text(
"An editor gets the four a field has",
TextStyle::new(13.0).color(t.muted),
);
ui.text_edit(
"note",
"Cut, Copy, Paste, Select All — none of it declared.",
&EditOptions::default(),
NodeSpec::column()
.grow_width()
.pad(10.0)
.radius(6.0)
.bg(t.bg)
.border(1.0, t.border)
.label("note"),
);
});
}
fn footer(&mut self, ui: &mut Ui<'_>, t: &Theme) {
let said = self
.last
.clone()
.unwrap_or_else(|| "right-click anything above".into());
ui.with(
NodeSpec::row().grow_width().pad_xy(4.0, 2.0).gap(8.0),
|ui| {
ui.text("last menu event —", TextStyle::new(12.0).color(t.muted));
ui.text(&said, TextStyle::new(12.0).color(t.accent));
ui.leaf(NodeSpec::row().grow_width());
ui.text(
"a plain box: right-click here opens nothing",
TextStyle::new(12.0).color(t.faint),
);
},
);
}
/// The menu one row gets: the standard items worth having on it, and
/// two the app invented. A custom row carries its own payload, which
/// is what comes back in the event — so nothing here has to work out
/// afterwards which row the menu was about.
fn row_menu(&self, row: &str) -> Vec<MenuItem> {
let about = |what: &str| Value::map([("do", Value::str(what)), ("row", Value::str(row))]);
let archived = self.archived.iter().any(|a| a == row);
vec![
MenuItem::new("Copy name").id(about("copyname")),
MenuItem::new(if archived {
"Already archived"
} else {
"Archive"
})
.id(about("archive"))
.enabled(!archived),
MenuItem::separator(),
// The standard ones still act: the core performs Select All
// itself and turns Copy into a clipboard write, whoever put
// the row in the list.
MenuItem::role(MenuRole::Copy),
MenuItem::role(MenuRole::SelectAll),
]
}
}
/// A card in the theme's own surface and edge.
fn card(t: &Theme) -> NodeSpec {
NodeSpec::column()
.grow_width()
.bg(t.surface)
.radius(10.0)
.border(1.0, t.border)
}
fn num(ev: &UiEvent, name: &str) -> f32 {
ev.payload
.get(name)
.and_then(Value::as_float)
.unwrap_or(0.0) as f32
}
impl Example for Demo {
const KEYS: &'static [(&'static str, &'static str)] =
&[("right-click", "a menu, where one is offered")];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(700.0, 560.0)
}
/// Drawn menus, so the rows are nodes a drive can click on every host.
fn native_menus(&self) -> Option<bool> {
Some(false)
}
/// Right-clicks each of the four, and reads what the core offered:
/// the standard scope menu, the app's own, the editor's four, nothing.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 700.0, 560.0);
d.core.set_native_menus(false);
d.frame(self);
let right_click = |d: &mut Drive<'_>, app: &mut Demo, x: f32, y: f32| {
d.input(app, kui_native::InputEvent::CursorMoved(Vec2::new(x, y)));
d.input(
app,
kui_native::InputEvent::MouseDown {
button: kui_native::MouseButton::Secondary,
clicks: 1,
},
);
d.input(
app,
kui_native::InputEvent::MouseUp {
button: kui_native::MouseButton::Secondary,
},
);
};
let roles = |menu: &Menu| -> Vec<MenuRole> { menu.items.iter().map(|i| i.role).collect() };
// The article: the standard scope menu, offered by the core.
let article = d.key_of("article").ok_or("no article")?;
let bravo = d.key_of("bravo").ok_or("no row")?;
let note = d.key_of("note").ok_or("no field")?;
let article_rect = d.rect_of(article).ok_or("the article has no rect")?;
let row_rect = d.rect_of(bravo).ok_or("the row has no rect")?;
let note_rect = d.rect_of(note).ok_or("the field has no rect")?;
right_click(
&mut d,
self,
article_rect.x + 40.0,
article_rect.y + article_rect.h / 2.0,
);
let menu = d.core.menu().cloned().ok_or("no menu over the article")?;
d.check(
roles(&menu).contains(&MenuRole::Copy) && roles(&menu).contains(&MenuRole::SelectAll),
"the article gets Copy and Select All",
)?;
d.check(
menu.items
.iter()
.any(|i| i.role == MenuRole::Copy && !i.enabled),
"with Copy dimmed while nothing is selected",
)?;
d.core.close_menu();
// A row: the app's own menu, one frame later, with a custom item
// that comes back as data.
right_click(
&mut d,
self,
row_rect.x + 20.0,
row_rect.y + row_rect.h / 2.0,
);
d.check(
self.pending.is_some(),
"a row's right-click reaches the app",
)?;
d.frame(self);
let menu = d
.core
.menu()
.cloned()
.ok_or("the app's menu did not open")?;
d.check(
menu.items.iter().any(|i| i.label == "Archive"),
"and the app's own rows are in it",
)?;
let archive = menu
.items
.iter()
.position(|i| i.label == "Archive")
.unwrap();
for ev in d.core.activate_menu_item(archive).unwrap_or_default() {
self.on_event(ev);
}
d.check(
self.archived == ["bravo"],
"choosing Archive arrives as a `menu` event carrying the row",
)?;
d.frame(self);
// The field: the editor's four.
right_click(
&mut d,
self,
note_rect.x + 20.0,
note_rect.y + note_rect.h / 2.0,
);
let menu = d.core.menu().cloned().ok_or("no menu over the field")?;
d.check(
[
MenuRole::Cut,
MenuRole::Copy,
MenuRole::Paste,
MenuRole::SelectAll,
]
.iter()
.all(|r| roles(&menu).contains(r)),
"an editor gets Cut, Copy, Paste and Select All",
)?;
d.core.close_menu();
// The footer: nothing.
right_click(&mut d, self, 200.0, 540.0);
d.check(d.core.menu().is_none(), "a plain box opens nothing")
}
}
kui_devtools::main!(Demo::default());
widgets/controls.rs
//! The stock controls (`docs/adr/0034-stock-controls-over-the-roles.md`):
//! checkbox, radio group, switch and slider, each drawn from the state the
//! view declares and none holding any of its own.
//!
//! A settings page's worth of them: notifications on or off, a sound
//! checkbox beside a select-all box that goes mixed when only some of its
//! three rows are ticked, a theme chosen by radio (arrows move the choice),
//! and two sliders — a volume the core snaps to steps of 5, and a gain in
//! tenths — whose `change` events carry the value to store. Every change
//! is one line in the event log at the bottom.
//!
//! Run: cargo run -p kui-native --example controls [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{Align, App, Core, KeyMods, NodeSpec, Role, TextStyle, Ui, UiEvent, Value};
const THEMES: [&str; 3] = ["Light", "Dark", "System"];
const CHANNELS: [&str; 3] = ["Mail", "Calendar", "Chat"];
struct Controls {
notify: bool,
channels: [bool; 3],
theme: usize,
volume: f32,
gain: f32,
last: String,
}
impl Default for Controls {
fn default() -> Self {
Self {
notify: true,
channels: [true, false, true],
theme: 2,
volume: 40.0,
gain: 0.5,
last: "nothing yet — click, drag, or Tab and use the keys".into(),
}
}
}
fn tag(kind: &str) -> Value {
Value::map([("kind", Value::str(kind))])
}
fn tagged(kind: &str, i: usize) -> Value {
Value::map([("kind", Value::str(kind)), ("i", Value::Int(i as i64))])
}
impl App for Controls {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
let m = ui.metrics();
let heading = |ui: &mut Ui<'_>, s: &str| {
ui.text(s, TextStyle::new(12.0).color(t.muted));
};
ui.with(
NodeSpec::column()
.fill()
.pad(24.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg),
|ui| {
heading(ui, "switch");
widgets::switch(ui, "Notifications", self.notify, tag("notify"));
heading(ui, "checkbox, with a select-all that can be mixed");
let ticked = self.channels.iter().filter(|c| **c).count();
widgets::toggle_with(
ui,
widgets::Toggle::Checkbox,
"All channels",
"All channels",
widgets::toggle_spec(&m)
.checked(ticked == CHANNELS.len())
.mixed(ticked > 0 && ticked < CHANNELS.len())
.on_click(tag("all"))
.disabled(!self.notify),
None,
);
ui.with(
NodeSpec::column()
.padding(kui_native::Edges {
l: 24.0,
r: 0.0,
t: 0.0,
b: 0.0,
})
.gap(8.0),
|ui| {
for (i, name) in CHANNELS.iter().enumerate() {
widgets::toggle_with(
ui,
widgets::Toggle::Checkbox,
name,
name,
widgets::toggle_spec(&m)
.checked(self.channels[i])
.on_click(tagged("channel", i))
.disabled(!self.notify),
None,
);
}
},
);
heading(ui, "radio group — Tab to it, then the arrows");
widgets::radio_group(ui, "Theme", &THEMES, Some(self.theme), |i| {
tagged("theme", i)
});
heading(
ui,
"slider — press, drag, or the arrows, Page keys, Home and End",
);
ui.with(NodeSpec::row().gap(12.0).cross_align(Align::Center), |ui| {
widgets::slider(ui, "Volume", self.volume, 0.0, 100.0, 5.0, tag("volume"));
ui.text(
&format!("{:.0}", self.volume),
TextStyle::new(13.0).color(t.fg),
);
});
ui.with(NodeSpec::row().gap(12.0).cross_align(Align::Center), |ui| {
widgets::slider_with(
ui,
"Gain",
widgets::slider_spec(&m)
.width(120.0)
.value_now(self.gain)
.value_min(0.0)
.value_max(1.0)
.value_step(0.1)
.value_text(format!("gain {:.1}", self.gain))
.on_change(tag("gain")),
Some("Steps of a tenth, proposed as the decimal"),
);
ui.text(
&format!("{:.1}", self.gain),
TextStyle::new(13.0).color(t.fg),
);
});
ui.leaf(NodeSpec::column().height(8.0));
ui.text(&self.last, TextStyle::new(12.0).color(t.faint).mono());
},
);
}
fn on_event(&mut self, ev: UiEvent) {
let kind = ev.kind();
let tag = ev
.payload
.get("tag")
.and_then(|t| t.get("kind"))
.and_then(Value::as_str);
let i = |v: &Value| v.get_int("i").unwrap_or(0) as usize;
match (kind, tag) {
// A slider's proposal: store it, and the next frame draws it.
(Some("change"), Some(which)) => {
let v = ev.payload.get_float("value").unwrap_or(0.0) as f32;
match which {
"volume" => self.volume = v,
"gain" => self.gain = v,
_ => {}
}
}
(Some("notify"), _) => self.notify = !self.notify,
(Some("all"), _) => {
let all = !self.channels.iter().all(|c| *c);
self.channels = [all; 3];
}
(Some("channel"), _) => {
let k = i(&ev.payload);
self.channels[k] = !self.channels[k];
}
(Some("theme"), _) => self.theme = i(&ev.payload),
_ => return,
}
self.last = format!("last event: {:?}", ev.payload);
}
}
impl Example for Controls {
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(520.0, 560.0)
}
/// A toggle's press flips the model through its payload; the select-all
/// reads mixed on a partial selection and ticks every row; the radio
/// group's arrows move the choice; a slider's press, keys and ends
/// propose values snapped to its step, which the app stores.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 520.0, 560.0);
d.frame(self);
let node = |d: &mut Drive<'_>, name: &str, role: Role| {
d.core
.access_tree()
.nodes
.iter()
.find(|n| n.role == role && n.name.as_deref() == Some(name))
.cloned()
.ok_or(format!("no {name}"))
};
let all = node(&mut d, "All channels", Role::Checkbox)?;
d.check(all.mixed, "two of three channels: the select-all is mixed")?;
d.click_key(self, all.key);
d.frame(self);
d.check(self.channels == [true; 3], "and ticking it ticks every row")?;
let all = node(&mut d, "All channels", Role::Checkbox)?;
d.check(all.checked == Some(true), "then it reads checked")?;
let notify = node(&mut d, "Notifications", Role::Switch)?;
d.click_key(self, notify.key);
d.frame(self);
d.check(!self.notify, "the switch turns notifications off")?;
let mail = node(&mut d, "Mail", Role::Checkbox)?;
d.check(mail.disabled, "and the channel boxes go inert with them")?;
let light = node(&mut d, "Light", Role::Radio)?;
d.focus(self, light.key);
d.key(self, "down", KeyMods::default());
d.frame(self);
d.check(self.theme == 1, "Down from Light chose Dark")?;
let vol = node(&mut d, "Volume", Role::Slider)?;
let r = vol.rect;
let b = widgets::control_box(&kui_native::Metrics::default());
// Three quarters along the track, which is the node less half the
// thumb at each end.
let x = r.x + b / 2.0 + (r.w - b) * 0.73;
d.click(self, x, r.y + r.h / 2.0);
d.frame(self);
d.check(
self.volume == 75.0,
"a press three quarters along is 75, snapped to 5",
)?;
d.focus(self, vol.key);
d.key(self, "right", KeyMods::default());
d.frame(self);
d.check(self.volume == 80.0, "Right is one step")?;
d.key(self, "pagedown", KeyMods::default());
d.frame(self);
d.check(self.volume == 30.0, "PageDown is ten")?;
d.key(self, "end", KeyMods::default());
d.frame(self);
d.check(self.volume == 100.0, "End is the top")?;
let gain = node(&mut d, "Gain", Role::Slider)?;
d.check(
gain.value.as_deref() == Some("gain 0.5"),
"the gain reads as its text",
)?;
d.focus(self, gain.key);
d.key(self, "left", KeyMods::default());
d.frame(self);
d.check(self.gain == 0.4, "a tenth down is 0.4, not 0.39999998")?;
let warned = d.core.take_warnings();
d.check(warned.is_empty(), "nothing warned")?;
Ok(())
}
}
kui_devtools::main!(Controls::default());
widgets/edit.rs
//! The `edit` element: a multiline document and the single-line
//! `widgets::text_input` beside it, both the same widget under two
//! options. Cursor motion, selection (shift+arrows, drag, double-click a
//! word), clipboard (Cmd/Ctrl C/X/V/A), undo, and scrolling as the
//! document grows are the core's; the view declares the editor and reads
//! its text back (`ui.edit_text`) — the buffer lives in the core keyed by
//! the widget's identity, so it survives every rebuild of the tree.
//!
//! What arrives in `on_event`: `changed` on every edit and `submit` from
//! the single-line field on Enter, both carrying the editor's key and
//! nothing else — the text is the core's, read back with `edit_text`.
//!
//! Run: cargo run -p kui-native --example edit [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::{
Align, App, Core, EditOptions, FontFamily, Key, NodeSpec, TextStyle, Ui, UiEvent,
};
const INITIAL: &str = "\
kui edit
Type here. Everything works the way you'd expect:
- arrows, home/end, page up/down (+shift to select)
- alt+arrows for word motion, cmd+arrows for line/document
- click to place the caret, drag to select, double-click a word
- cmd+c / cmd+x / cmd+v / cmd+a, cmd+z / cmd+shift+z
- enter, tab, backspace, delete
The buffer lives in the core keyed by widget identity, so this
text survives every rebuild of the UI tree - the view below is
regenerated from scratch every frame, like any other kui view.
";
#[derive(Default)]
struct Edit {
doc: Option<Key>,
chars: usize,
lines: usize,
edited: bool,
seen_version: u64,
/// The field a `submit` arrived on, read back on the next view.
submit: Option<Key>,
/// What the single-line field last submitted.
submitted: Option<String>,
}
impl App for Edit {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(NodeSpec::column().fill(), |ui| {
// The single-line field: `widgets::text_input` is the same
// element with `multiline: false`, chrome, and a focus ring.
ui.with(
NodeSpec::row()
.grow_width()
.pad(12.0)
.gap(12.0)
.cross_align(Align::Center)
.bg(t.surface)
.border(1.0, t.border),
|ui| {
ui.text("title", TextStyle::new(12.0).color(t.muted));
let title = kui_native::widgets::text_input(ui, "title", "");
if self.submit.take() == Some(title) {
self.submitted = ui.edit_text(title);
}
ui.text(
&match &self.submitted {
Some(s) => format!("submitted: {s:?}"),
None => "Enter submits".into(),
},
TextStyle::new(12.0).color(t.muted),
);
},
);
// The document: the edit node grows its height with content
// inside a scroll container, so the document scrolls as it
// grows.
ui.with(NodeSpec::column().fill().scroll_y(), |ui| {
let key = ui.text_edit(
"doc",
INITIAL,
&EditOptions {
style: TextStyle::new(14.0)
.family(FontFamily::Mono)
.line_height(22.0),
multiline: true,
autofocus: true,
..Default::default()
},
// Nothing inside an editor names it, so
// `control-without-name` is right to ask: a screen
// reader would say "text input".
NodeSpec::column().grow_width().pad(20.0).label("document"),
);
self.doc = Some(key);
// Recount only when the document actually changed —
// pulling the full text out every frame would be
// O(doc) per keystroke.
let version = ui.core().edit.version(key);
if version != self.seen_version || self.chars == 0 {
self.recount(ui.edit_text(key));
self.seen_version = version;
}
});
// Status bar.
ui.with(
NodeSpec::row()
.grow_width()
.pad_xy(12.0, 6.0)
.gap(16.0)
.bg(t.surface)
.border(1.0, t.border)
.cross_align(Align::Center),
|ui| {
let muted = TextStyle::new(12.0).color(t.muted);
ui.text(&format!("{} lines", self.lines), muted);
ui.text(&format!("{} chars", self.chars), muted);
ui.leaf(NodeSpec::row().grow_width());
ui.text(
if self.edited { "edited" } else { "saved" },
TextStyle::new(12.0).color(if self.edited { t.warning } else { t.success }),
);
},
);
});
}
fn on_event(&mut self, ev: UiEvent) {
match ev.kind() {
Some("changed") if Some(ev.key) == self.doc => {
self.edited = true;
self.chars = 0; // recount next view
}
Some("submit") => self.submit = Some(ev.key),
_ => {}
}
}
}
impl Edit {
fn recount(&mut self, text: Option<String>) {
if let Some(t) = text {
self.chars = t.chars().count();
self.lines = t.lines().count().max(1);
}
}
}
impl Example for Edit {
const KEYS: &'static [(&'static str, &'static str)] = &[
("Shift-arrows", "select"),
("⌥/⌘ arrows", "word / line motion"),
("⌘C ⌘X ⌘V", "clipboard"),
("⌘Z", "undo"),
("Enter", "submits the field"),
];
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 640.0, 400.0);
d.frame(self);
let doc = self.doc.ok_or("no document")?;
let lines = self.lines;
d.check(
lines == INITIAL.lines().count(),
"the initial text is counted",
)?;
// Typing lands in the document (it autofocused) and the status
// bar sees the change through `changed`.
d.text(self, "x");
d.frame(self);
d.check(self.edited, "a keystroke reports `changed`")?;
let text = d.core.edit_text(doc).unwrap_or_default();
d.check(text.contains('x'), "and the text read back has it")?;
// The single-line field submits on Enter.
let title = d.key_of("title").ok_or("no title field")?;
d.focus(self, title);
d.text(self, "hello");
d.key(self, "enter", Default::default());
d.frame(self);
d.check(
self.submitted.as_deref() == Some("hello"),
"Enter submits the field's text",
)
}
}
kui_devtools::main!(Edit::default());
widgets/fragment.rs
//! The `fragment` element: a box a WGSL function paints
//! (`docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md`).
//!
//! Four of them, each one function the app wrote:
//!
//! - a **gradient**, which is the thing ADR 0005 declined to build as props
//! and this is the answer to;
//! - a **ring**, drawn from the prelude's `kui_sd_rounded_box` — a gauge
//! with no geometry and no second pass;
//! - a **shimmer**, which reads `time` and so declares `animate`;
//! - a **card**, a fragment holding a title and a button, to show that
//! children paint over it and take input normally;
//! - a **heatmap**, a fragment reading an `image` the app rewrites every
//! frame with `update_image` — 64×16 values in a texture, one cell per
//! texel through `kui_sample_nearest`, coloured between two theme roles
//! (backlog V1, ADR 0025 decision 7): the "many points" case, where the
//! data is a texture and the nodes are one;
//! - a **ripple**, the same input as an image effect — a registered icon
//! read through `kui_sample` with a time-driven offset, so the pixels
//! come from the atlas the icon lives in and the function never knows.
//!
//! The click target is the card's button, and the fragments themselves are
//! hoverable, so hovering one lifts its ring — a fragment takes input like
//! any box.
//!
//! Run: cargo run -p kui-native --example fragment [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::{
App, Color, Core, FragmentId, ImageId, NodeSpec, TextStyle, Ui, UiEvent, Value, widgets,
};
/// One colour as the four floats a `params` slot is. Fragment parameters
/// are where a theme meets a shader: the WGSL says *what* a gradient or a
/// ring is, and the palette says which colours it is made of — so every
/// fragment below follows the OS without a line of its shader changing
/// (`docs/adr/0019-a-theme-derived-from-appearance-and-accent.md`).
fn rgba(c: Color) -> [f32; 4] {
[c.r, c.g, c.b, c.a]
}
/// The four params a fragment takes, flattened: colours as `rgba`, plain
/// numbers as themselves.
fn params(slots: [[f32; 4]; 4]) -> [f32; 16] {
let mut out = [0.0; 16];
for (i, s) in slots.iter().enumerate() {
out[i * 4..i * 4 + 4].copy_from_slice(s);
}
out
}
/// A vertical gradient between `params[0]` and `params[1]`.
const GRADIENT: &str = "\
fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
let t = clamp(in.local.y / max(in.size.y, 1.0), 0.0, 1.0);
return mix(params[0], params[1], t);
}";
/// A progress ring: `params[1].x` of the way round, in `params[0]`, on a
/// track of `params[2]`. The arc is an angle test against the same
/// distance field the renderer draws every rounded box with.
const RING: &str = "\
fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
let c = in.size * 0.5;
let p = in.local - c;
let r = min(c.x, c.y) - params[3].x;
let d = abs(length(p) - r) - params[3].y;
let cov = 1.0 - smoothstep(-KUI_AA, KUI_AA, d);
// Angle from twelve o'clock, clockwise, in turns.
let turn = fract(atan2(p.x, -p.y) / 6.2831853 + 1.0);
let on = step(turn, clamp(params[1].x, 0.0, 1.0));
let col = mix(params[2].rgb, params[0].rgb, on);
return vec4<f32>(col, cov);
}";
/// A diagonal sheen sliding across a dim base — the skeleton-loading
/// shimmer, as one function of `time`.
const SHIMMER: &str = "\
fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
let u = (in.local.x + in.local.y) / max(in.size.x + in.size.y, 1.0);
let sweep = fract(in.time * params[2].x);
let d = abs(fract(u - sweep + 0.5) - 0.5);
let band = 1.0 - smoothstep(0.0, params[2].y, d);
return vec4<f32>(mix(params[0].rgb, params[1].rgb, band), 1.0);
}";
/// A soft radial wash for the card to sit on.
const WASH: &str = "\
fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
let c = in.size * 0.5;
let d = length((in.local - c) / max(c.x, 1.0));
return vec4<f32>(mix(params[0].rgb, params[1].rgb, clamp(d, 0.0, 1.0)), 1.0);
}";
/// One cell per texel of the `image`, its red channel the value, coloured
/// between `params[0]` (cold) and `params[1]` (hot). `in.image.zw` is the
/// grid's size, which is what draws the cell borders without a second
/// input.
const HEATMAP: &str = "\
fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
let uv = in.local / max(in.size, vec2<f32>(1.0));
let v = kui_sample_nearest(uv).r;
let cell = fract(uv * in.image.zw);
let edge = min(min(cell.x, 1.0 - cell.x), min(cell.y, 1.0 - cell.y));
let border = smoothstep(0.0, 0.08, edge);
return vec4<f32>(mix(params[0].rgb, params[1].rgb, v) * (0.6 + 0.4 * border), 1.0);
}";
/// An image effect: the `image` sampled through a horizontal ripple that
/// travels with `time`, desaturated by `params[0].y`.
const RIPPLE: &str = "\
fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
let uv = in.local / max(in.size, vec2<f32>(1.0));
let wobble = vec2<f32>(sin(uv.y * 18.0 + in.time * 3.0) * params[0].x, 0.0);
let c = kui_sample(uv + wobble);
let grey = dot(c.rgb, vec3<f32>(0.299, 0.587, 0.114));
return vec4<f32>(mix(vec3<f32>(grey), c.rgb, params[0].y), c.a);
}";
const HEAT_W: u32 = 64;
const HEAT_H: u32 = 16;
/// The heatmap's frame: a field of two travelling waves, one value per
/// texel in the red channel — what a sensor grid, a spectrogram column or
/// a 50k-point series would hand back.
fn heat(phase: f32, out: &mut Vec<u8>) {
out.clear();
out.reserve((HEAT_W * HEAT_H * 4) as usize);
for y in 0..HEAT_H {
for x in 0..HEAT_W {
let (u, v) = (x as f32 / HEAT_W as f32, y as f32 / HEAT_H as f32);
let a = ((u * 9.0 + phase).sin() * (v * 5.0 - phase * 0.6).cos() + 1.0) * 0.5;
out.extend_from_slice(&[(a * 255.0) as u8, 0, 0, 0xff]);
}
}
}
/// A 48×48 icon for the ripple: a ring on a disc, opaque in the middle
/// and transparent past the disc, so the effect has an alpha to keep.
fn icon() -> Vec<u8> {
let n = 48u32;
let mut px = Vec::with_capacity((n * n * 4) as usize);
for y in 0..n {
for x in 0..n {
let d = ((x as f32 - 23.5).powi(2) + (y as f32 - 23.5).powi(2)).sqrt();
let (r, g, b, a) = if d > 22.0 {
(0, 0, 0, 0)
} else if (d - 15.0).abs() < 3.0 {
(0xf4, 0xd0, 0x6f, 0xff)
} else if d < 8.0 {
(0x3b, 0x5b, 0xd4, 0xff)
} else {
(0x2a, 0x2e, 0x3c, 0xff)
};
px.extend_from_slice(&[r, g, b, a]);
}
}
px
}
#[derive(Default)]
struct Demo {
shaders: Option<Shaders>,
/// 0..1, what the ring shows. The button nudges it.
progress: f32,
/// The heatmap's data texture: registered once, replaced every frame.
heat: Option<ImageId>,
/// The ripple's icon: registered once, never replaced, so it lives in
/// the atlas and the fragment reads it from there.
icon: Option<ImageId>,
phase: f32,
pixels: Vec<u8>,
}
#[derive(Clone, Copy)]
struct Shaders {
gradient: FragmentId,
ring: FragmentId,
shimmer: FragmentId,
wash: FragmentId,
heatmap: FragmentId,
ripple: FragmentId,
}
fn label(ui: &mut Ui<'_>, text: &str) {
let muted = ui.theme().muted;
ui.text(text, TextStyle::new(12.0).color(muted));
}
/// One labelled tile.
fn tile(ui: &mut Ui<'_>, name: &str, f: impl FnOnce(&mut Ui<'_>)) {
ui.with_keyed(name, NodeSpec::column().gap(6.0), |ui| {
f(ui);
label(ui, name);
});
}
impl App for Demo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
// Registration is idempotent by source, so calling it every frame
// costs a comparison. A real app would still do this once.
let s = *self.shaders.get_or_insert_with(|| {
let core = ui.core();
Shaders {
gradient: core.add_fragment(GRADIENT).expect("gradient"),
ring: core.add_fragment(RING).expect("ring"),
shimmer: core.add_fragment(SHIMMER).expect("shimmer"),
wash: core.add_fragment(WASH).expect("wash"),
heatmap: core.add_fragment(HEATMAP).expect("heatmap"),
ripple: core.add_fragment(RIPPLE).expect("ripple"),
}
});
// The data texture: a handle once, then new pixels every frame
// through the same handle — the image is the canvas (ADR 0025),
// and the fragment is what draws it as cells rather than pixels.
let heat_id = *self.heat.get_or_insert_with(|| {
ui.core()
.resources
.add_image(HEAT_W, HEAT_H, vec![0; (HEAT_W * HEAT_H * 4) as usize])
});
self.phase += 0.05;
heat(self.phase, &mut self.pixels);
ui.core()
.update_image(heat_id, HEAT_W, HEAT_H, self.pixels.clone());
let icon_id = *self
.icon
.get_or_insert_with(|| ui.core().resources.add_image(48, 48, icon()));
ui.with(
NodeSpec::column().fill().pad(28.0).gap(24.0).bg(t.bg),
|ui| {
ui.text("fragments", TextStyle::new(22.0).color(t.fg));
ui.with(NodeSpec::row().gap(20.0), |ui| {
tile(ui, "gradient", |ui| {
ui.fragment(
s.gradient,
¶ms([
rgba(t.accent), // top
rgba(t.surface), // bottom
[0.0; 4],
[0.0; 4],
]),
NodeSpec::column().size(150.0, 96.0).radius(10.0),
);
});
tile(ui, "ring", |ui| {
ui.fragment(
s.ring,
¶ms([
rgba(t.accent), // the arc
[self.progress, 0.0, 0.0, 0.0], // how far round
rgba(t.border), // the track
[10.0, 5.0, 0.0, 0.0], // inset, half-width
]),
NodeSpec::column().size(96.0, 96.0),
);
});
tile(ui, "shimmer (animate)", |ui| {
ui.fragment(
s.shimmer,
¶ms([
rgba(t.sunken), // base
rgba(t.border_strong), // sheen
[0.35, 0.22, 0.0, 0.0], // turns per second, width
[0.0; 4],
]),
NodeSpec::column().size(150.0, 96.0).radius(10.0).animate(),
);
});
});
ui.with(NodeSpec::row().gap(20.0), |ui| {
// The image input: a texture the app replaces every
// frame, read as data — one node for a thousand cells.
tile(ui, "heatmap (an image as data)", |ui| {
ui.fragment_keyed(
"heatmap",
s.heatmap.with_image(heat_id),
¶ms([
rgba(t.sunken), // cold
rgba(t.accent), // hot
[0.0; 4],
[0.0; 4],
]),
NodeSpec::column().size(256.0, 64.0).radius(6.0).animate(),
);
});
// The same input as an effect over an atlas-backed icon.
tile(ui, "ripple (an image effect)", |ui| {
ui.fragment_keyed(
"ripple",
s.ripple.with_image(icon_id),
¶ms([
[0.02, 0.35, 0.0, 0.0], // ripple depth, colour kept
[0.0; 4],
[0.0; 4],
[0.0; 4],
]),
NodeSpec::column().size(96.0, 96.0).animate(),
);
});
});
// A fragment with children: they lay out inside it and paint over
// it, and the button takes input exactly as it would anywhere.
tile(ui, "a fragment with children", |ui| {
ui.fragment_with(
s.wash,
¶ms([
rgba(t.surface.mix(t.accent, 0.30)), // centre
rgba(t.surface), // edge
[0.0; 4],
[0.0; 4],
]),
NodeSpec::column()
.width(320.0)
.pad(18.0)
.gap(12.0)
.radius(12.0),
|ui| {
ui.text("on a wash", TextStyle::new(16.0).color(t.fg));
widgets::button(ui, "advance", Value::str("advance"));
},
);
});
},
);
}
fn on_event(&mut self, ev: UiEvent) {
if ev.payload.as_str() == Some("advance") {
self.progress = (self.progress + 0.125) % 1.125;
}
}
}
impl Example for Demo {
/// The image input lands on the right binding: the heatmap's data
/// texture takes the frame's one `textures` entry and its draw names
/// it, the ripple's icon is read from the atlas, no texture *quad* is
/// drawn for either, and the next frame's replacement moves the
/// revision the backend re-uploads on.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
use kui_native::{FragmentImage, QuadKind};
let mut d = Drive::new(core, 900.0, 700.0);
d.frame(self);
let (images, textures, texture_quads, rev) = {
let dl = &d.core.output().0;
(
dl.fragments.iter().map(|f| f.image).collect::<Vec<_>>(),
dl.textures.len(),
dl.quads
.iter()
.filter(|q| q.kind == QuadKind::Texture)
.count(),
dl.texture_pixels.first().map(|p| p.rev),
)
};
d.check(images.len() == 6, "six fragments drawn")?;
d.check(
images
.iter()
.filter(|i| matches!(i, FragmentImage::None))
.count()
== 4,
"four of them read no image",
)?;
d.check(
images.contains(&FragmentImage::Texture {
index: 0,
uv: [0, 0, HEAT_W, HEAT_H],
}),
"the heatmap reads the data texture, whole",
)?;
d.check(
images
.iter()
.any(|i| matches!(i, FragmentImage::Atlas([_, _, 48, 48]))),
"the ripple reads the icon from the atlas",
)?;
d.check(textures == 1, "one texture entry, the heatmap's")?;
d.check(
texture_quads == 0,
"and no texture quad: the fragment draws",
)?;
d.frame(self);
let rev2 = d.core.output().0.texture_pixels.first().map(|p| p.rev);
d.check(
rev2 > rev,
"the next frame's update_image moved the revision",
)?;
Ok(())
}
}
kui_devtools::main!(Demo::default());
widgets/image.rs
//! The `image` element: host-registered RGBA pixels drawn through the
//! same atlas and draw call as everything else. Shows intrinsic (Fit)
//! sizing, aspect-preserving responsive width, rounded corners, and alpha
//! — and, since ADR 0025 (`docs/adr/0025-the-image-is-the-canvas.md`),
//! the image as the canvas: a *stream* whose pixels the app replaces
//! every frame with `update_image_with`, rendered at exactly the pixel count
//! the `layout` event's `scale` says the box covers, shown `nearest`
//! beside `linear`; and `contain` / `cover` against a box of another
//! aspect.
//!
//! Run: cargo run -p kui-native --example image [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::{
App, Core, ImageFit, ImageId, ImageOpts, NodeSpec, Sampling, TextStyle, Ui, UiEvent,
};
/// A procedural "photo": vertical sky gradient with a sun disc.
fn sky(w: u32, h: u32) -> Vec<u8> {
let mut px = Vec::with_capacity((w * h * 4) as usize);
let (cx, cy, r) = (w as f32 * 0.72, h as f32 * 0.3, h as f32 * 0.16);
for y in 0..h {
let t = y as f32 / h as f32;
for x in 0..w {
let d = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt();
let sun = (1.0 - ((d - r) / 6.0).clamp(0.0, 1.0)).powi(2);
let base = [
(30.0 + 160.0 * t) as u8,
(60.0 + 120.0 * t) as u8,
(120.0 + 80.0 * t) as u8,
];
px.push(base[0].saturating_add((sun * 220.0) as u8));
px.push(base[1].saturating_add((sun * 180.0) as u8));
px.push(base[2].saturating_add((sun * 60.0) as u8));
px.push(0xff);
}
}
px
}
/// Checkerboard with transparent squares — alpha compositing over the bg.
fn checker(w: u32, h: u32) -> Vec<u8> {
let mut px = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
let on = ((x / 12) + (y / 12)) % 2 == 0;
px.extend_from_slice(if on {
&[0xd8, 0x86, 0x3b, 0xff]
} else {
&[0, 0, 0, 0]
});
}
}
px
}
/// A frame of plasma at `w`×`h`, `phase` along, written into `out`
/// (`w × h × 4` bytes): what a video decoder, a camera or a plot library
/// would hand back — pixels the app made.
fn plasma(w: u32, h: u32, phase: f32, out: &mut [u8]) {
for (i, px) in out.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let (x, y) = (i as u32 % w, i as u32 / w);
let (u, v) = (x as f32 / w as f32, y as f32 / h as f32);
let a = ((u * 6.0 + phase).sin()
+ (v * 5.0 - phase * 0.7).sin()
+ ((u + v) * 4.0 + phase * 1.3).sin())
/ 3.0;
*px = [
(128.0 + 100.0 * a) as u8,
(128.0 + 100.0 * (a + 2.1).sin()) as u8,
(128.0 + 100.0 * (a + 4.2).sin()) as u8,
0xff,
];
}
}
struct Gallery {
sky: Option<ImageId>,
checker: Option<ImageId>,
/// The stream: registered once at a token size, then replaced every
/// frame at the size the layout event last reported.
stream: Option<ImageId>,
/// Physical pixels the stream's box covers, from `on_layout`'s `w`,
/// `h` and `scale`: what the next frame renders to. Zero until the
/// first layout arrives, which is the frame model — one frame late.
stream_px: (u32, u32),
phase: f32,
/// How many frames were rendered at the reported size — the
/// headless drive's evidence that the loop closed.
rendered: u32,
}
impl App for Gallery {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
// Register once, lazily, through the escape hatch — resources are
// long-lived core state, not per-frame data.
let sky_id = *self
.sky
.get_or_insert_with(|| ui.core().resources.add_image(480, 270, sky(480, 270)));
let checker_id = *self
.checker
.get_or_insert_with(|| ui.core().resources.add_image(96, 96, checker(96, 96)));
let stream_id = *self
.stream
.get_or_insert_with(|| ui.core().resources.add_image(16, 9, vec![0; 16 * 9 * 4]));
// The loop: render at the size the box covers, replace the pixels,
// keep the handle. Before the first `layout` event the token
// 16×9 shows, stretched — one frame. `update_image_with` hands
// over a buffer the core recycles, so the plasma is rendered
// straight into it: no buffer of the app's own, no copy, and no
// allocation after the second frame (backlog W20).
let (pw, ph) = self.stream_px;
if pw > 0 && ph > 0 {
let phase = self.phase;
ui.core()
.update_image_with(stream_id, pw, ph, |px| plasma(pw, ph, phase, px));
self.rendered += 1;
}
self.phase += 0.04;
ui.with(
NodeSpec::column().fill().pad(24.0).gap(16.0).scroll_y(),
|ui| {
let muted = TextStyle::new(12.0).color(t.muted);
ui.text(
"Grow width + Fit height: rescales with the window, keeps aspect",
muted,
);
ui.image(
sky_id,
NodeSpec::column()
.grow_width()
.max_width(720.0)
.radius(12.0)
.label("a generated sky gradient"),
);
ui.text(
"Intrinsic size, rounded, over a colored card (alpha shows through)",
muted,
);
ui.with(
NodeSpec::row()
.pad(16.0)
.gap(16.0)
.bg(t.surface)
.radius(10.0),
|ui| {
ui.image(
checker_id,
NodeSpec::column()
.radius(8.0)
.label("a checkerboard, at its intrinsic size"),
);
ui.image(
checker_id,
NodeSpec::column()
.size(48.0, 96.0)
.radius(8.0)
.label("the same checkerboard, stretched to 48x96"),
);
ui.text("same image, intrinsic and stretched", muted);
},
);
ui.text(
"A stream: pixels replaced every frame at the size the box covers (`layout.scale`), linear and nearest",
muted,
);
ui.with(NodeSpec::row().gap(16.0), |ui| {
// The box that reports its size; the stream node
// itself sits inside it so the report is the box's.
ui.with_keyed(
"stream-box",
NodeSpec::column()
.size(240.0, 135.0)
.on_layout(kui_native::Value::str("stream"))
.animate(),
|ui| {
ui.image_with(
stream_id,
ImageOpts::default(),
NodeSpec::column().fill().radius(8.0).label("a plasma, rendered at the box's size"),
);
},
);
// The same pixels through `nearest`, at a size the
// texels are bigger than the pixels: each one a square.
ui.image_with(
stream_id,
ImageOpts {
sampling: Sampling::Nearest,
..ImageOpts::default()
},
NodeSpec::column()
.size(240.0, 135.0)
.radius(8.0)
.label("the same stream, nearest-sampled"),
);
});
ui.text(
"The 16:9 sky in a square box: `contain` letterboxes, `cover` crops, the box is the same",
muted,
);
ui.with(NodeSpec::row().gap(16.0), |ui| {
for (fit, label) in [
(ImageFit::Fill, "fill: stretched"),
(ImageFit::Contain, "contain: letterboxed"),
(ImageFit::Cover, "cover: cropped"),
] {
ui.with(
NodeSpec::column()
.size(140.0, 140.0)
.bg(t.surface)
.border(1.0, t.border)
.radius(10.0),
|ui| {
ui.image_with(
sky_id,
ImageOpts {
fit,
..ImageOpts::default()
},
NodeSpec::column().fill().radius(10.0).label(label),
);
},
);
}
});
},
);
}
fn on_event(&mut self, ev: UiEvent) {
if ev.payload.get("kind").and_then(|v| v.as_str()) == Some("layout")
&& ev.payload.get("tag").and_then(|v| v.as_str()) == Some("stream")
{
let f = |k: &str| ev.payload.get(k).and_then(|v| v.as_float()).unwrap_or(0.0);
// `scale` is the number the ADR put on the payload for exactly
// this multiply: physical px per logical px at the node.
self.stream_px = (
(f("w") * f("scale")).round() as u32,
(f("h") * f("scale")).round() as u32,
);
}
}
}
impl Example for Gallery {
/// The loop closes: after the first frame's layout report the stream
/// is re-rendered at the box's pixel count and drawn from a texture
/// of its own; the three fits share one box size and differ in what
/// is painted.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
// Tall enough that the scrolling page culls nothing.
let mut d = Drive::new(core, 900.0, 1100.0);
d.frame(self);
d.check(
self.rendered == 0,
"before the layout report nothing is rendered",
)?;
d.check(
self.stream_px == (240, 135),
"the report says how many pixels the box covers",
)?;
d.frame(self);
d.check(
self.rendered == 1,
"the frame after renders once at that size",
)?;
// Read everything off the frame first; `check` wants the drive.
let (textures, size, nearest, sky) = {
let dl = &d.core.output().0;
let texture = |q: &&kui_native::Quad| q.kind == kui_native::QuadKind::Texture;
(
dl.quads.iter().filter(texture).count(),
dl.texture_pixels.first().map(|p| (p.width, p.height)),
dl.quads
.iter()
.filter(texture)
.filter(|q| q.border_w == 1.0)
.count(),
dl.quads
.iter()
.filter(|q| q.kind == kui_native::QuadKind::Image && q.rect.w >= 100.0)
.copied()
.collect::<Vec<_>>(),
)
};
d.check(
textures == 2,
"the stream draws from a texture of its own, twice",
)?;
d.check(
size == Some((240, 135)),
"and the texture is the box's size",
)?;
d.check(nearest == 1, "one of the two is nearest-sampled")?;
// The sky three ways: same box, `contain` paints a shorter rect,
// `cover` shows fewer texels.
d.check(sky.len() == 4, "the sky is drawn four times as an image")?;
let fits = &sky[1..];
d.check(
fits[0].rect.h > fits[1].rect.h,
"contain paints a shorter rect than fill",
)?;
d.check(
fits[2].uv[2] < fits[0].uv[2],
"cover shows fewer texels than fill",
)?;
Ok(())
}
}
kui_devtools::main!(Gallery {
sky: None,
checker: None,
stream: None,
stream_px: (0, 0),
phase: 0.0,
rendered: 0,
});
widgets/line.rs
//! The `line` element (`docs/adr/0010-a-segment-primitive.md`): a small
//! mind map whose links are `line` nodes — one curve per link, drawn in the
//! canvas's box space between the floats it connects, instead of the three
//! thin boxes a box-only vocabulary forces. Each link is the cubic Bézier
//! every flow chart draws, sampled here into the polyline the element
//! takes (see `link`). Hover a card and its links
//! brighten: the stroke colour rides the `bg` slot, so a `transition` eases
//! it like any background. The links are declared before the cards, so
//! they paint under them (floats stack in tree order).
//!
//! A card that is only planned hangs off a **dashed** link
//! (`Stroke::dash`): 7 px marks and 5 px gaps, the lengths seen, the
//! pattern running along the curve's whole length and not restarting at
//! each of its pieces. Hover one and its marks march towards the parent —
//! `dash_offset` grown by the clock, and a frame asked for only while one
//! is lit.
//!
//! Run: cargo run -p kui-native --example line
use kui_devtools::Example;
use kui_native::{App, FloatConfig, NodeSpec, Stroke, TextStyle, Ui, Vec2};
struct Card {
label: &'static str,
/// Top-left in the canvas, logical px.
at: Vec2,
parent: Option<usize>,
/// Not built yet: its link is dashed.
planned: bool,
}
const W: f32 = 120.0;
const H: f32 = 36.0;
/// Samples the link from `from` to `to` into `out`: the cubic Bézier whose
/// two handles sit halfway across the gap, level with the edge each end
/// leaves, so the stroke departs the parent and meets the child dead
/// horizontal.
///
/// The four points are deliberately *not* handed to [`Stroke::curve`]. A
/// spline passes **through** its knots, so the two handles would become
/// places the line has to visit, and a curve that has to arrive at a
/// corner leans into it: the link out of `kui` bows about 5px below the
/// card's edge before it turns up, and the three links out of that one
/// card cross each other doing it. (It used to bow 10px — the core's
/// spline was uniformly parameterized until this example was drawn. It is
/// centripetal now, which halves the lean without removing it.) As Bézier
/// handles the same four points are only *pulled* towards, so the curve
/// stays inside them and leaves each card level.
///
/// One piece per [`kui_native::line::CURVE_STEP`] of control polygon, so the
/// sampling is as fine as the flattening the core would have done.
fn link(from: Vec2, to: Vec2, out: &mut Vec<Vec2>) {
let h = (to.x - from.x) * 0.5;
let (c1, c2) = (Vec2::new(from.x + h, from.y), Vec2::new(to.x - h, to.y));
let span = h.abs() * 2.0 + (to.y - from.y).abs();
let n = ((span / kui_native::line::CURVE_STEP).ceil() as usize).clamp(1, 64);
out.clear();
for i in 0..=n {
let t = i as f32 / n as f32;
let u = 1.0 - t;
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
out.push(Vec2::new(
a * from.x + b * c1.x + c * c2.x + d * to.x,
a * from.y + b * c1.y + c * c2.y + d * to.y,
));
}
}
struct Map {
cards: Vec<Card>,
/// Refilled per link per frame, so a frame allocates nothing.
points: Vec<Vec2>,
/// How far the lit dashed links have marched, in px, and when the
/// last frame that moved them was drawn.
march: f32,
last: Option<std::time::Instant>,
}
/// Px a second a lit dashed link's marks move.
const MARCH: f32 = 24.0;
impl Map {
fn new() -> Self {
let c = |label, x, y, parent| Card {
label,
at: Vec2::new(x, y),
parent,
planned: false,
};
let planned = |card: Card| Card {
planned: true,
..card
};
Map {
cards: vec![
c("kui", 60.0, 200.0, None),
c("layout", 300.0, 60.0, Some(0)),
c("paint", 300.0, 200.0, Some(0)),
c("input", 300.0, 340.0, Some(0)),
c("wrapping", 540.0, 30.0, Some(1)),
c("floats", 540.0, 100.0, Some(1)),
c("shadows", 540.0, 170.0, Some(2)),
c("segments", 540.0, 240.0, Some(2)),
planned(c("gradients", 540.0, 290.0, Some(2))),
c("focus", 540.0, 345.0, Some(3)),
planned(c("gestures", 540.0, 400.0, Some(3))),
],
points: Vec::new(),
march: 0.0,
last: None,
}
}
}
impl App for Map {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(NodeSpec::column().fill().bg(t.bg).pad(24.0).gap(12.0), |ui| {
ui.text(
"Links are `line` nodes: a Bézier sampled into a polyline, in the canvas's box space; a planned card's link is dashed. Hover a card.",
TextStyle::new(12.0).color(t.muted),
);
let canvas = NodeSpec::column()
.fill()
// The canvas is a panel on the page, not the page.
.bg(t.surface)
.border(1.0, t.border)
.radius(12.0);
ui.with_keyed("canvas", canvas, |ui| {
// Which cards are hovered, read before anything is declared so
// the links (declared first, painted under) can see it.
let hovered: Vec<bool> = (0..self.cards.len())
.map(|i| ui.is_hovered(ui.child_key(&format!("card{i}"))))
.collect();
// The dashes of a lit link march, on the clock rather than
// the frame count, and only they keep the window drawing.
let marching = (0..self.cards.len()).any(|i| {
self.cards[i].planned
&& (hovered[i] || self.cards[i].parent.is_some_and(|p| hovered[p]))
});
let now = std::time::Instant::now();
if let (true, Some(last)) = (marching, self.last) {
self.march += now.duration_since(last).as_secs_f32() * MARCH;
}
self.last = marching.then_some(now);
if marching {
ui.request_frame();
}
for i in 0..self.cards.len() {
let Some(p) = self.cards[i].parent else {
continue;
};
let from = Vec2::new(self.cards[p].at.x + W, self.cards[p].at.y + H / 2.0);
let to = Vec2::new(self.cards[i].at.x, self.cards[i].at.y + H / 2.0);
let lit = hovered[i] || hovered[p];
// A lit link is the accent; a resting one is the strong
// border, which is what every other hairline on the page is.
let color = if lit { t.focus_ring } else { t.border_strong };
link(from, to, &mut self.points);
let mut stroke = Stroke::new(if lit { 3.0 } else { 2.0 }, color);
if self.cards[i].planned {
// The links run parent to child, so a growing offset
// carries the marks back to the parent.
stroke = stroke
.dash(7.0, 5.0)
.dash_offset(if lit { self.march } else { 0.0 });
}
ui.polyline_keyed(
&format!("link{i}"),
&self.points,
stroke,
NodeSpec::column().transition(160.0),
);
}
for (i, card) in self.cards.iter().enumerate() {
ui.with_keyed(
&format!("card{i}"),
NodeSpec::row()
.float(FloatConfig::parent().offset(card.at.x, card.at.y))
.size(W, H)
.pad_xy(12.0, 0.0)
.cross_align(kui_native::Align::Center)
.bg(t.raised)
// An opaque step toward the accent rather than the
// translucent `accent_soft`: a `hover_bg` replaces
// the background, it does not composite over it.
.hover_bg(t.raised.mix(t.accent, 0.18))
// The border is what makes the card a card on the
// light base, where `raised` and the canvas it
// floats over are the same white (ADR 0019).
.border(1.0, t.border)
.radius(8.0)
.transition(160.0)
.hoverable(),
|ui| {
ui.text(card.label, TextStyle::new(13.0).color(t.fg));
},
);
}
});
});
}
}
impl Example for Map {}
kui_devtools::main!(Map::new());
widgets/menu_bar.rs
//! The application menu bar (`docs/adr/0018-a-menu-bar-the-app-declares.md`).
//! The frame declares it, in one call — `widgets::menu_bar` says what it
//! is and where its strip goes when it has to be drawn — and the host
//! decides who shows it: on macOS the same declaration goes to the OS
//! and is the bar at the top of the screen, and the strip in the window
//! is not there; everywhere else the core draws the strip. The dock's
//! `menus` row switches between the two on a host that has both.
//!
//! Its rows are the rows a context menu has, so View ▸ Wrap checks itself
//! (`checked`, rebuilt from the model every frame with nothing retained),
//! View ▸ Clear is `enabled` only when there is something to clear, Edit
//! ▸ Select All and Copy are the same ones the core performs itself (the
//! card is a selection scope, so they have something to act on), and
//! every choice — standard or the app's own — arrives as the same one
//! `menu` event. The menu titled `Window` is the platform's by name
//! (`docs/adr/0030-the-standard-menus-the-runner-keeps.md`): on macOS
//! its row is joined by Fill, Center, the tiling submenus and Enter Full
//! Screen, and their shortcuts work; elsewhere it is one more drawn menu.
//!
//! Run: cargo run -p kui-native --example menu_bar [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{
Align, App, BarMenu, Core, MenuBar, MenuItem, MenuRole, NodeSpec, Span, TextStyle, TextWrap,
Ui, UiEvent, Value,
};
#[derive(Default)]
struct Demo {
/// A setting one of the bar's rows toggles: a `checked` row doing
/// what a checked row is for.
wrap: bool,
/// Rows "About" was chosen, for a row that is plain.
abouts: u32,
/// Something to clear, for a row that is `enabled` conditionally.
notes: Vec<String>,
/// The last thing the bar reported, shown at the bottom.
last: Option<String>,
/// Whether the counters line is hidden: the one row of the `Window`
/// menu, which on macOS shares that menu with the platform's rows.
hide_counters: bool,
}
impl Demo {
/// The bar, rebuilt every frame from the model. Every row is an
/// ordinary `MenuItem`: the app's own with `id`s, a `checked`
/// setting, and the standard Edit rows the core performs itself.
fn menu(&self) -> MenuBar {
let mine = |what: &str| Value::map([("do", Value::str(what))]);
MenuBar::new(vec![
// First, which on macOS is the position the OS titles with the
// app's own name whatever this label says.
BarMenu::new(
"Demo",
vec![
MenuItem::new("About this example").id(mine("about")),
MenuItem::separator(),
MenuItem::new("Add a note").id(mine("note")).accel("mod+n"),
],
),
BarMenu::new(
"Edit",
vec![
MenuItem::role(MenuRole::Cut),
MenuItem::role(MenuRole::Copy),
MenuItem::role(MenuRole::Paste),
MenuItem::separator(),
MenuItem::role(MenuRole::SelectAll),
],
),
BarMenu::new(
"View",
vec![
MenuItem::new("Wrap the paragraph")
.id(mine("wrap"))
.accel("mod+shift+w")
.checked(self.wrap),
MenuItem::new("Clear the notes")
.id(mine("clear"))
.enabled(!self.notes.is_empty()),
],
),
// `Window` by name: the platform's Window menu where there is
// one, the app's row beside whatever the platform adds (macOS
// puts its own rows first).
BarMenu::new(
"Window",
vec![
MenuItem::new("Hide the counters")
.id(mine("counters"))
.checked(self.hide_counters),
],
),
])
}
}
impl App for Demo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(NodeSpec::column().fill().bg(t.bg), |ui| {
// The application menu: full width and flush with the top,
// because that is where a menu bar goes — the padding the rest
// of the page has moves inside. On macOS this draws nothing.
widgets::menu_bar(ui, self.menu());
ui.with(
NodeSpec::column()
.fill()
.pad(24.0)
.gap(12.0)
.cross_align(Align::Center),
|ui| {
// A selection scope, so Edit ▸ Select All and Copy —
// the standard rows the core performs itself — have
// something to act on.
ui.with(
NodeSpec::column()
.grow_width()
.max_width(560.0)
.pad(20.0)
.gap(10.0)
.bg(t.surface)
.radius(10.0)
.border(1.0, t.border)
.selectable(),
|ui| {
ui.text(
"View ▸ Wrap the paragraph",
TextStyle::new(12.0).color(t.muted),
);
ui.rich_text(
&[
Span::new("A paragraph whose wrapping is a "),
Span::new("checked").bold(),
Span::new(
" row of the bar: the row reads the model, the model \
reads the row, and nothing in between remembers \
anything — the bar is rebuilt from `wrap` every frame.",
),
],
TextStyle::new(15.0).line_height(22.0).wrap(if self.wrap {
TextWrap::Word
} else {
TextWrap::None
}),
);
if !self.hide_counters {
ui.text(
&format!(
"About chosen {} time{} · {} note{}",
self.abouts,
if self.abouts == 1 { "" } else { "s" },
self.notes.len(),
if self.notes.len() == 1 { "" } else { "s" },
),
TextStyle::new(13.0).color(t.muted),
);
}
for note in &self.notes {
ui.text(note, TextStyle::new(13.0));
}
},
);
ui.with(
NodeSpec::row().grow_width().max_width(560.0).gap(8.0),
|ui| {
ui.text("last menu event —", TextStyle::new(12.0).color(t.muted));
ui.text(
self.last
.as_deref()
.unwrap_or("choose something from the bar"),
TextStyle::new(12.0).color(t.accent),
);
},
);
},
);
});
}
fn on_event(&mut self, ev: UiEvent) {
if ev.kind() != Some("menu") {
return;
}
// Every chosen row, standard or not, arrives here with the role it
// played; a custom row hands back whatever its `id` carried.
let role = ev.payload.get_str("role").unwrap_or("");
let item = ev.payload.get("item").cloned().unwrap_or(Value::Null);
let did = item.get_str("do");
match did {
Some("about") => self.abouts += 1,
Some("note") => self.notes.push(format!("note {}", self.notes.len() + 1)),
Some("wrap") => self.wrap = !self.wrap,
Some("clear") => self.notes.clear(),
Some("counters") => self.hide_counters = !self.hide_counters,
_ => {}
}
self.last = Some(match did {
Some(d) => d.to_string(),
None => role.to_string(),
});
}
}
impl Example for Demo {
const KEYS: &'static [(&'static str, &'static str)] =
&[("⌘N", "Demo ▸ Add a note"), ("⌘⇧W", "View ▸ Wrap")];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(640.0, 360.0)
}
/// Drawn menus, so the strip is in the frame on every host — which
/// is what makes the drive below the same everywhere.
fn native_menus(&self) -> Option<bool> {
Some(false)
}
/// The bar as data: the declaration the frame made, read back from
/// the core, and one of its rows chosen.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 640.0, 360.0);
d.core.set_native_menus(false);
d.frame(self);
let bar = d.core.menu_bar().ok_or("the frame declared no menu bar")?;
d.check(bar.menus.len() == 4, "the bar has its four menus")?;
let view = &bar.menus[2];
d.check(
view.items.iter().all(|i| !i.checked),
"Wrap is a checked row, unchecked to start",
)?;
d.check(
view.items.iter().any(|i| !i.enabled),
"Clear is disabled with nothing to clear",
)?;
// Choosing a row, both ways. The drawn bar: open View (what a
// press on its title does) and click its first row, keyed by
// index under the panel. The platform's bar reports the same
// choice as `activate_menu_bar_item`, and both land in the same
// `menu` event — with the row checked on the frame after.
d.core.set_menu_bar_open(Some(2));
d.frame(self);
let panel = d
.key_of("kui.menubar.menu")
.ok_or("the View menu did not open")?;
d.click_key(self, panel.index(0));
d.frame(self);
d.check(
self.wrap,
"a click on the drawn row arrives as a `menu` event",
)?;
d.check(
self.last.as_deref() == Some("wrap"),
"with the row's own id",
)?;
let bar = d.core.menu_bar().ok_or("the bar went away")?;
d.check(
bar.menus[2].items.iter().any(|i| i.checked),
"and the row is checked on the next frame",
)?;
let evs = d.core.activate_menu_bar_item(2, 0);
for ev in evs {
self.on_event(ev);
}
d.check(
!self.wrap,
"the platform's report of the same row is the same event",
)?;
// And by pointer, the way a person does it: a press on the View
// title opens it, a press on its first row chooses it — and the
// paragraph is taller wrapped than not.
d.frame(self);
let height = |d: &mut Drive<'_>| -> f32 {
d.core
.access_tree()
.nodes
.iter()
.find(|n| {
n.name
.as_deref()
.is_some_and(|s| s.starts_with("A paragraph whose"))
})
.map(|n| n.rect.h)
.unwrap_or(-1.0)
};
let unwrapped = height(&mut d);
// The titles are keyed under the bar by index, then by the one
// title key; View is the third.
let bar = d.key_of(widgets::MENU_BAR_KEY).ok_or("no bar")?;
let title = bar.index(2).str("kui.menubar.title");
let tr = d.rect_of(title).ok_or("the title has no rect")?;
d.click(self, tr.x + tr.w / 2.0, tr.y + tr.h / 2.0);
d.frame(self);
let row = d
.key_of("kui.menubar.menu")
.ok_or("the click did not open View")?
.index(0);
let rr = d.rect_of(row).ok_or("the row has no rect")?;
d.click(self, rr.x + rr.w / 2.0, rr.y + rr.h / 2.0);
d.frame(self);
d.check(self.wrap, "a pointer press on the row chooses it")?;
let wrapped = height(&mut d);
println!("paragraph: {unwrapped} unwrapped, {wrapped} wrapped");
d.check(
wrapped > unwrapped + 10.0,
"and the paragraph wraps onto more lines",
)
}
}
kui_devtools::main!(Demo::default());
widgets/path.rs
//! The `path` element (`docs/adr/0040-a-path-is-a-mask-in-the-atlas.md`):
//! any outline — SVG's `d`, or the `Path` builder — filled with `bg` by
//! its rule and stroked by its own stroke, placed like a `line` (a float
//! in its parent's box space sized to its own bounding box). Four things
//! an eight-point `polygon` could not draw: a pie whose wedges are round
//! and meet without a seam, lighting up under the pointer; a donut gauge
//! whose arc is a sector with an inner radius; an icon from SVG path
//! data, filled and stroked; and an even-odd ring whose hole the press
//! falls through. Every paint is one glyph-mask quad from the atlas,
//! rasterized once per shape and scale and re-tinted for free, so a hover
//! or a colour tween costs nothing a text's does not.
//!
//! A path is hit by its outline under its fill rule (ADR 0026): the
//! wedges are `hoverable` themselves, and a pointer in one wedge's
//! bounding box but past its arc is over the canvas, not it.
//!
//! Run: cargo run -p kui-native --example path [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::{App, Color, Core, FillRule, NodeSpec, Path, QuadKind, Stroke, TextStyle, Ui};
/// A heart, as an icon editor would export it: SVG path data, in a 24 px
/// box, drawn at any scale by the one parser the core owns.
const HEART: &str =
"M12 21 C12 21 3 14.5 3 8.5 A4.5 4.5 0 0 1 12 6 A4.5 4.5 0 0 1 21 8.5 C21 14.5 12 21 12 21 Z";
struct Demo {
slices: Vec<(&'static str, f32, Color)>,
gauge: f32,
}
impl App for Demo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(NodeSpec::column().fill().bg(t.bg).pad(24.0).gap(16.0), |ui| {
let muted = TextStyle::new(12.0).color(t.muted);
ui.text(
"Outlines of any shape, one mask quad each: a pie, a gauge, an icon, a ring. Hover a wedge.",
muted,
);
ui.with(NodeSpec::row().gap(24.0), |ui| {
// The pie: each wedge is a sector with a round arc, hit by
// its outline, and its fill eases toward the accent. Two
// wedges share each radial edge and meet without a seam,
// because a fill bleeds half a pixel (ADR 0040, decision 5).
let pie = NodeSpec::column()
.size(220.0, 220.0)
.bg(t.surface)
.border(1.0, t.border)
.radius(12.0);
ui.with_keyed("pie", pie, |ui| {
let mut from = 0.0;
for (i, &(label, frac, color)) in self.slices.iter().enumerate() {
let key = ui.child_key(&format!("wedge{i}"));
let lit = ui.is_hovered(key);
ui.path_keyed(
&format!("wedge{i}"),
&Path::sector(110.0, 110.0, 80.0, 0.0, from, frac),
NodeSpec::column()
.bg(if lit { t.accent } else { color })
.transition(160.0)
.hoverable()
.label(label),
);
from += frac;
}
});
ui.with(NodeSpec::column().gap(16.0), |ui| {
// The gauge: a track ring, and over it a sector whose
// sweep is the value. Both are one node each.
let card = NodeSpec::column()
.size(300.0, 102.0)
.bg(t.surface)
.border(1.0, t.border)
.radius(12.0);
ui.with(card, |ui| {
let (cx, cy) = (60.0, 70.0);
ui.path(
&Path::sector(cx, cy, 44.0, 32.0, 0.5, 0.5),
NodeSpec::column().bg(t.raised),
);
ui.path_keyed(
"gauge",
&Path::sector(cx, cy, 44.0, 32.0, 0.5, 0.5 * self.gauge),
NodeSpec::column().bg(t.accent).transition(300.0),
);
ui.with(
NodeSpec::column()
.float(kui_native::FloatConfig::parent().offset(130.0, 30.0))
.gap(4.0),
|ui| {
ui.text(
&format!("{:.0}%", self.gauge * 100.0),
TextStyle::new(22.0).color(t.fg),
);
ui.text("of the frame budget", muted);
},
);
});
// The icon: SVG path data, filled and stroked, at twice
// and four times its 24 px box — the same `d`, two
// masks of different sizes in the atlas.
let strip = NodeSpec::column()
.size(300.0, 102.0)
.bg(t.surface)
.border(1.0, t.border)
.radius(12.0);
ui.with(strip, |ui| {
let heart = Path::parse(HEART).expect("the icon parses");
for (i, (scale, x)) in [(2.0, 24.0), (4.0, 110.0)].into_iter().enumerate() {
let ops: Vec<kui_native::PathOp> = heart
.ops()
.iter()
.map(|op| scaled(*op, scale, x, 3.0 + (4.0 - scale) * 12.0))
.collect();
ui.path_keyed(
&format!("heart{i}"),
&Path::from_ops(ops).stroked(Stroke::new(2.0, t.fg)),
NodeSpec::column().bg(t.warning),
);
}
});
});
// The ring: two contours wound the same way, filled
// even-odd, so the hole is a hole — to the eye and to the
// pointer, which hovers the ring and not its middle.
let card = NodeSpec::column()
.size(120.0, 220.0)
.bg(t.surface)
.border(1.0, t.border)
.radius(12.0);
ui.with(card, |ui| {
let key = ui.child_key("ring");
let lit = ui.is_hovered(key);
ui.path_keyed(
"ring",
&Path::parse("M60 60 m-46 0 a46 46 0 1 0 92 0 a46 46 0 1 0 -92 0 M60 60 m-20 0 a20 20 0 1 0 40 0 a20 20 0 1 0 -40 0")
.expect("the ring parses")
.fill_rule(FillRule::EvenOdd),
NodeSpec::column()
.bg(if lit { t.accent } else { t.accent_soft })
.transition(160.0)
.hoverable()
.label("ring"),
);
ui.with(
NodeSpec::column()
.float(kui_native::FloatConfig::parent().offset(10.0, 130.0))
.size(100.0, 80.0),
|ui| ui.text("even-odd: the hole is the card's", muted),
);
});
});
});
}
}
/// `op` scaled by `s` and moved to `(x, y)`.
fn scaled(op: kui_native::PathOp, s: f32, x: f32, y: f32) -> kui_native::PathOp {
use kui_native::{PathOp, Vec2};
let at = |p: Vec2| Vec2::new(p.x * s + x, p.y * s + y);
match op {
PathOp::MoveTo(p) => PathOp::MoveTo(at(p)),
PathOp::LineTo(p) => PathOp::LineTo(at(p)),
PathOp::QuadTo(c, p) => PathOp::QuadTo(at(c), at(p)),
PathOp::CubicTo(a, b, p) => PathOp::CubicTo(at(a), at(b), at(p)),
PathOp::ArcTo {
rx,
ry,
rotation,
large,
sweep,
to,
} => PathOp::ArcTo {
rx: rx * s,
ry: ry * s,
rotation,
large,
sweep,
to: at(to),
},
PathOp::Close => PathOp::Close,
}
}
impl Example for Demo {
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(760.0, 320.0)
}
/// Every paint is one glyph-mask quad and no fragment; a wedge is hit
/// by its arc, so a pointer inside it lights it and one in its box past
/// the arc does not; the ring's hole is not the ring's.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 760.0, 320.0);
d.frame(self);
let count = |core: &mut Core, kind: QuadKind| {
core.output()
.0
.quads
.iter()
.filter(|q| q.kind == kind)
.count()
};
// 4 wedges + the track and the gauge + 2 icons × 2 paints + the ring.
let masks = count(d.core, QuadKind::GlyphMask);
d.check(masks >= 11, "every paint is one mask quad")?;
let fragments = count(d.core, QuadKind::Fragment);
d.check(fragments == 0, "and none is a fragment")?;
let warned = d.warnings();
d.check(warned.is_empty(), "nothing warned")?;
let w1 = d.key_of("wedge1").ok_or("no second wedge")?;
let r = d.rect_of(w1).ok_or("the wedge has no region")?;
// The pie's centre on the page: the wedge's region is its outline's
// bounding box a pixel out, and the outline is what `sector`
// computes, so the panel's origin follows from the two.
let (from, frac) = (self.slices[0].1, self.slices[1].1);
let mut outline = Vec::new();
kui_native::path::flatten(
Path::sector(110.0, 110.0, 80.0, 0.0, from, frac).ops(),
&mut outline,
);
let b = kui_native::path::bounds(&outline).ok_or("no bounds")?;
let c = kui_native::Vec2::new(r.x - (b.x - 2.0) + 110.0, r.y - (b.y - 2.0) + 110.0);
let mid = (from + frac * 0.5) * std::f32::consts::TAU;
let inside = kui_native::Vec2::new(c.x + 50.0 * mid.cos(), c.y + 50.0 * mid.sin());
let outside = kui_native::Vec2::new(c.x + 84.0 * mid.cos(), c.y + 84.0 * mid.sin());
d.input(self, kui_native::InputEvent::CursorMoved(inside));
d.check(
d.core.is_hovered(w1),
"a pointer inside the wedge hovers it",
)?;
d.input(self, kui_native::InputEvent::CursorMoved(outside));
d.check(
!d.core.is_hovered(w1),
"a pointer in its box past its arc does not",
)?;
// Back inside: the fill eases to the accent, from the same slot.
d.input(self, kui_native::InputEvent::CursorMoved(inside));
d.frame(self);
d.advance(0.5);
d.frame(self);
let accent = d.core.theme().accent;
let lit = d
.core
.output()
.0
.quads
.iter()
.any(|q| q.kind == QuadKind::GlyphMask && q.color == accent);
d.check(lit, "a hovered wedge's fill is the accent")?;
// The ring: its band hovers, its hole does not.
let ring = d.key_of("ring").ok_or("no ring")?;
let rr = d.rect_of(ring).ok_or("the ring has no region")?;
let centre = kui_native::Vec2::new(rr.x + rr.w * 0.5, rr.y + rr.h * 0.5);
d.input(self, kui_native::InputEvent::CursorMoved(centre));
d.check(
!d.core.is_hovered(ring),
"the ring's hole is not the ring's",
)?;
d.input(
self,
kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(centre.x + 33.0, centre.y)),
);
d.check(d.core.is_hovered(ring), "its band is")?;
Ok(())
}
}
kui_devtools::main!(Demo {
slices: vec![
("layout", 0.34, Color::hex(0x7f9cf5ff)),
("paint", 0.26, Color::hex(0xd8863bff)),
("input", 0.22, Color::hex(0x9ad9a0ff)),
("text", 0.18, Color::hex(0xe07a8aff)),
],
gauge: 0.62,
});
widgets/polygon.rs
//! The `polygon` element (`docs/adr/0025-the-image-is-the-canvas.md`,
//! decision 6): a filled outline of up to eight points, placed like a
//! `line` — a float in its parent's box space, sized to its own bounding
//! box — with the fill in `bg`, so a `transition` eases it like any
//! background. Four things a box-and-stroke vocabulary could not draw:
//! a pie whose wedges light up under the pointer, arrowheads on the
//! links of a small graph, the area under a sparkline, and a concave
//! star. Every one is a `fragment` quad on the wire, painted by the stock
//! source the core registers itself.
//!
//! A polygon is hit by its outline (`docs/adr/0026-hit-testing-by-shape.md`):
//! the wedges are `hoverable` themselves, and a pointer in one wedge's
//! bounding box but past its arc is over the neighbour, not it. Before
//! that ADR this example floated a hover box over each wedge's middle.
//!
//! What this pie shows that a chart would not want: an arc of seven
//! chords, and a hairline of the panel through every shared edge, since
//! two signed-distance fills each cover the edge's pixels by half. A
//! round pie that meets without a seam is the `path` element's
//! (`docs/adr/0040-a-path-is-a-mask-in-the-atlas.md`, `--example path`);
//! what `polygon` keeps is a fill that costs nothing per frame however it
//! moves — the arrowheads and the area strip below.
//!
//! Run: cargo run -p kui-native --example polygon [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::{App, Color, Core, FloatConfig, NodeSpec, QuadKind, Stroke, TextStyle, Ui, Vec2};
/// A wedge of `frac` of the circle starting at `from` turns: the centre,
/// then the arc flattened into as many points as eight allows. Coarse for
/// a big wedge — a polygon is eight points, and a rounder pie is more
/// wedges, not more points — but the edge ramp hides the facets at this
/// radius.
fn wedge(center: Vec2, r: f32, from: f32, frac: f32, out: &mut Vec<Vec2>) {
out.clear();
out.push(center);
let steps = 6;
for i in 0..=steps {
let a = (from + frac * i as f32 / steps as f32) * std::f32::consts::TAU;
out.push(Vec2::new(center.x + r * a.cos(), center.y + r * a.sin()));
}
// Eight at most: the centre, seven on the arc.
out.truncate(8);
}
/// A filled arrowhead at `tip` pointing along `dir`, `len` long.
fn arrowhead(tip: Vec2, dir: Vec2, len: f32) -> [Vec2; 3] {
let n = Vec2::new(-dir.y, dir.x);
let base = Vec2::new(tip.x - dir.x * len, tip.y - dir.y * len);
let half = len * 0.45;
[
tip,
Vec2::new(base.x + n.x * half, base.y + n.y * half),
Vec2::new(base.x - n.x * half, base.y - n.y * half),
]
}
struct Demo {
slices: Vec<(&'static str, f32, Color)>,
points: Vec<Vec2>,
samples: Vec<f32>,
}
impl App for Demo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(NodeSpec::column().fill().bg(t.bg).pad(24.0).gap(16.0), |ui| {
let muted = TextStyle::new(12.0).color(t.muted);
ui.text(
"Fills the stroke vocabulary could not draw: a pie, arrowheads, an area, a star. Hover a wedge.",
muted,
);
ui.with(NodeSpec::row().gap(24.0), |ui| {
// The pie: each wedge is hoverable in its own outline, and
// its fill eases toward the accent.
let pie = NodeSpec::column()
.size(220.0, 220.0)
.bg(t.surface)
.border(1.0, t.border)
.radius(12.0);
ui.with_keyed("pie", pie, |ui| {
let center = Vec2::new(110.0, 110.0);
let mut from = 0.0;
for (i, &(label, frac, color)) in self.slices.iter().enumerate() {
let key = ui.child_key(&format!("wedge{i}"));
let lit = ui.is_hovered(key);
wedge(center, 80.0, from, frac, &mut self.points);
ui.polygon_keyed(
&format!("wedge{i}"),
&self.points,
NodeSpec::column()
.bg(if lit { t.accent } else { color })
.transition(160.0)
.hoverable()
.label(label),
);
from += frac;
}
});
ui.with(NodeSpec::column().gap(16.0), |ui| {
// Arrowheads: a stroke to the base, a triangle at the
// tip, both in the panel's box space.
let graph = NodeSpec::column()
.size(300.0, 102.0)
.bg(t.surface)
.border(1.0, t.border)
.radius(12.0);
ui.with(graph, |ui| {
let nodes = [
Vec2::new(40.0, 50.0),
Vec2::new(150.0, 24.0),
Vec2::new(150.0, 78.0),
Vec2::new(260.0, 50.0),
];
for &(a, b) in &[(0, 1), (0, 2), (1, 3), (2, 3)] {
let (from, to) = (nodes[a], nodes[b]);
let d = Vec2::new(to.x - from.x, to.y - from.y);
let len = (d.x * d.x + d.y * d.y).sqrt();
let dir = Vec2::new(d.x / len, d.y / len);
let tip = Vec2::new(to.x - dir.x * 14.0, to.y - dir.y * 14.0);
let base = Vec2::new(tip.x - dir.x * 10.0, tip.y - dir.y * 10.0);
ui.line(from, base, Stroke::new(2.0, t.border_strong), NodeSpec::column());
ui.polygon(&arrowhead(tip, dir, 12.0), NodeSpec::column().bg(t.border_strong));
}
for n in nodes {
ui.leaf(
NodeSpec::column()
.float(FloatConfig::parent().offset(n.x - 12.0, n.y - 12.0))
.size(24.0, 24.0)
.bg(t.raised)
.border(1.0, t.border)
.radius(12.0));
}
});
// The area under a sparkline: a strip of quads, one
// per span, so no polygon needs more than four points
// and the outline is the stroke over it.
let chart = NodeSpec::column()
.size(300.0, 102.0)
.bg(t.surface)
.border(1.0, t.border)
.radius(12.0);
ui.with(chart, |ui| {
let (w, h, pad) = (300.0, 102.0, 14.0);
let n = self.samples.len();
let x = |i: usize| pad + (w - 2.0 * pad) * i as f32 / (n - 1) as f32;
let y = |v: f32| h - pad - (h - 2.0 * pad) * v;
for i in 0..n - 1 {
let quad = [
Vec2::new(x(i), y(self.samples[i])),
Vec2::new(x(i + 1), y(self.samples[i + 1])),
Vec2::new(x(i + 1), y(0.0)),
Vec2::new(x(i), y(0.0)),
];
ui.polygon(&quad, NodeSpec::column().bg(t.accent_soft));
}
self.points.clear();
self.points
.extend((0..n).map(|i| Vec2::new(x(i), y(self.samples[i]))));
ui.polyline(&self.points, Stroke::new(2.0, t.accent), NodeSpec::column());
});
});
// A concave outline fills correctly: the polygon SDF, not
// a convex hull.
let card = NodeSpec::column()
.size(120.0, 220.0)
.bg(t.surface)
.border(1.0, t.border)
.radius(12.0);
ui.with(card, |ui| {
let c = Vec2::new(60.0, 110.0);
self.points.clear();
for i in 0..8 {
let a = i as f32 / 8.0 * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2;
let r = if i % 2 == 0 { 46.0 } else { 20.0 };
self.points.push(Vec2::new(c.x + r * a.cos(), c.y + r * a.sin()));
}
ui.polygon(&self.points, NodeSpec::column().bg(t.warning));
});
});
});
}
}
impl Example for Demo {
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(760.0, 320.0)
}
/// Every fill is one fragment quad; a wedge is hit by its outline, so a
/// pointer inside it lights it and one in its bounding box past its
/// arc lights the neighbour instead (ADR 0026).
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 760.0, 320.0);
d.frame(self);
let count = |core: &mut Core, kind: QuadKind| {
core.output()
.0
.quads
.iter()
.filter(|q| q.kind == kind)
.count()
};
// 4 wedges + 4 arrowheads + 9 area quads + the star.
let fills = count(d.core, QuadKind::Fragment);
d.check(fills == 18, "every fill is one fragment quad")?;
let wedges = d
.core
.interaction
.hits()
.iter()
.filter(|h| {
d.core
.label_of(h.key)
.is_some_and(|l| l.starts_with("wedge"))
})
.count();
d.check(wedges == 4, "each wedge is a hit region of its own")?;
let before = count(d.core, QuadKind::Fragment);
let w1 = d.key_of("wedge1").ok_or("no second wedge")?;
let r = d.rect_of(w1).ok_or("the wedge has no region")?;
// Where the pie's centre is on the page: the wedge's region is its
// outline's bounding box a pixel out, and the outline is what
// `wedge` computes, so the panel's origin follows from the two.
let (from, frac) = (self.slices[0].1, self.slices[1].1);
let mut pts = Vec::new();
wedge(Vec2::new(110.0, 110.0), 80.0, from, frac, &mut pts);
let (min_x, min_y) = pts
.iter()
.fold((f32::MAX, f32::MAX), |(x, y), p| (x.min(p.x), y.min(p.y)));
let c = kui_native::Vec2::new(r.x - (min_x - 1.0) + 110.0, r.y - (min_y - 1.0) + 110.0);
let mid_angle = (from + frac * 0.5) * std::f32::consts::TAU;
// Halfway out along the wedge's middle: inside it. Past the rim
// along the same line, still inside its bounding box: not it.
let inside =
kui_native::Vec2::new(c.x + 50.0 * mid_angle.cos(), c.y + 50.0 * mid_angle.sin());
let outside =
kui_native::Vec2::new(c.x + 84.0 * mid_angle.cos(), c.y + 84.0 * mid_angle.sin());
d.input(self, kui_native::InputEvent::CursorMoved(inside));
d.check(
d.core.is_hovered(w1),
"a pointer inside the wedge hovers it",
)?;
d.input(self, kui_native::InputEvent::CursorMoved(outside));
d.check(
!d.core.is_hovered(w1),
"a pointer in its box past its arc does not",
)?;
// Back inside: the fill eases to the accent, and stays one quad.
d.input(self, kui_native::InputEvent::CursorMoved(inside));
d.frame(self);
d.advance(0.5);
d.frame(self);
let accent = d.core.theme().accent;
let lit = d
.core
.output()
.0
.quads
.iter()
.any(|q| q.kind == QuadKind::Fragment && q.color == accent);
d.check(lit, "a hovered wedge's fill is the accent")?;
let after = count(d.core, QuadKind::Fragment);
d.check(after == before, "and it is still one quad")?;
Ok(())
}
}
kui_devtools::main!(Demo {
slices: vec![
("layout", 0.34, Color::hex(0x7f9cf5ff)),
("paint", 0.26, Color::hex(0xd8863bff)),
("input", 0.22, Color::hex(0x9ad9a0ff)),
("text", 0.18, Color::hex(0xe07a8aff)),
],
points: Vec::new(),
samples: vec![0.2, 0.5, 0.35, 0.8, 0.6, 0.9, 0.55, 0.7, 0.4, 0.65],
});
widgets/select.rs
//! The select: a field that shows the choice in force and, clicked, drops
//! the core's own menu of the choices with the current one checked
//! (`widgets::select`). The app holds no open state — the menu is the
//! same one a right-click opens, drawn in the frame or shown by the
//! platform, dismissed by Escape or a press outside, walked by the
//! arrows — and hears one event: the choice, as the `menu` event a menu
//! row posts, on the field's key. Drawing the field again with the new
//! `current` is the whole loop.
//!
//! Two fields: a `select` over plain labels, whose choice posts the label,
//! and a `select_items` over `MenuItem`s, whose choice posts each item's
//! `id` — here the size in points, so the app parses nothing.
//!
//! Run: cargo run -p kui-native --example select [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{Align, App, Core, MenuItem, NodeSpec, TextStyle, Ui, UiEvent, Value};
const LANGUAGES: [&str; 4] = ["English", "Deutsch", "Français", "日本語"];
const SIZES: [u32; 4] = [11, 13, 15, 18];
struct Select {
language: usize,
size: usize,
/// What the last choice posted, verbatim: the whole of what an app sees.
last: Option<Value>,
}
impl Default for Select {
fn default() -> Self {
Self {
language: 0,
size: 1,
last: None,
}
}
}
impl App for Select {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
let row = |ui: &mut Ui<'_>, label: &str, f: &mut dyn FnMut(&mut Ui<'_>)| {
ui.with(
NodeSpec::row()
.grow_width()
.gap(12.0)
.cross_align(Align::Center),
|ui| {
ui.text_in(
NodeSpec::row().width(90.0),
label,
TextStyle::new(13.0).color(t.muted),
);
f(ui);
},
);
};
ui.with(
NodeSpec::column()
.fill()
.pad(24.0)
.gap(14.0)
.cross_align(Align::Start),
|ui| {
ui.text(
"`select`: the choice posts its label",
TextStyle::new(12.0).color(t.muted),
);
row(ui, "language", &mut |ui| {
widgets::select(ui, "language", &LANGUAGES, Some(self.language));
});
ui.text(
"`select_items`: the choice posts the item's `id` — the size itself",
TextStyle::new(12.0).color(t.muted),
);
row(ui, "size", &mut |ui| {
let items: Vec<MenuItem> = SIZES
.iter()
.map(|s| MenuItem::new(format!("{s} pt")).id(Value::Int(i64::from(*s))))
.collect();
widgets::select_items(ui, "size", &items, Some(self.size));
});
ui.leaf(NodeSpec::column().height(10.0));
ui.text(
&format!("{} at {} pt", LANGUAGES[self.language], SIZES[self.size]),
TextStyle::new(SIZES[self.size] as f32),
);
let last = match &self.last {
Some(v) => format!("last event: {v:?}"),
None => {
"no choice yet — click a field, or Tab to it and press Space".to_string()
}
};
ui.text(&last, TextStyle::new(12.0).color(t.faint).mono());
},
);
}
fn on_event(&mut self, ev: UiEvent) {
// `{kind: "menu", role: "custom", item: …}` on the field's key.
if ev.kind() != Some("menu") {
return;
}
let Some(item) = ev.payload.get("item") else {
return;
};
self.last = Some(item.clone());
if let Some(name) = item.as_str()
&& let Some(i) = LANGUAGES.iter().position(|l| *l == name)
{
self.language = i;
}
if let Some(pt) = item.as_int()
&& let Some(i) = SIZES.iter().position(|s| i64::from(*s) == pt)
{
self.size = i;
}
}
}
impl Example for Select {
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(520.0, 300.0)
}
/// The drawn menu, so the rows are in the frame to click.
fn native_menus(&self) -> Option<bool> {
Some(false)
}
/// A click on the field opens its menu under it and reaches the app
/// as nothing; a row chosen is one event naming the option, and the
/// field then shows it.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 520.0, 300.0);
d.frame(self);
let field = d.key_of("language").ok_or("no language field")?;
let rect = d.rect_of(field).ok_or("the field is not in the hit list")?;
let evs = d.click_key(self, field);
d.check(evs.is_empty(), "the field's click never reaches the app")?;
let menu = d.core.menu().ok_or("no menu opened")?;
d.check(menu.target == field, "the menu is about the field")?;
d.check(
menu.at.y == rect.y + rect.h && menu.at.x == rect.x,
"and hangs under it",
)?;
d.check(menu.items[0].checked, "the current choice is checked")?;
d.frame(self);
let row = d
.core
.access_tree()
.nodes
.iter()
.find(|n| n.role == kui_native::Role::MenuItem && n.name.as_deref() == Some("Deutsch"))
.map(|n| n.rect)
.ok_or("the rows are not drawn")?;
let evs = d.click(self, row.x + row.w / 2.0, row.y + row.h / 2.0);
d.check(
evs.len() == 1 && evs[0].key == field,
"one event, on the field",
)?;
d.check(self.language == 1, "the app took the choice")?;
d.check(d.core.menu().is_none(), "and the menu closed")?;
d.frame(self);
let shown = d
.core
.access_tree()
.nodes
.iter()
.find(|n| n.role == kui_native::Role::Button && n.name.as_deref() == Some("language"))
.and_then(|n| n.description.clone());
d.check(
shown.as_deref() == Some("Deutsch"),
"the field describes itself by the new choice",
)?;
// The item form: the id, not the label.
let size = d.key_of("size").ok_or("no size field")?;
d.click_key(self, size);
d.frame(self);
let row = d
.core
.access_tree()
.nodes
.iter()
.find(|n| n.role == kui_native::Role::MenuItem && n.name.as_deref() == Some("18 pt"))
.map(|n| n.rect)
.ok_or("the size rows are not drawn")?;
d.click(self, row.x + row.w / 2.0, row.y + row.h / 2.0);
d.check(
self.last == Some(Value::Int(18)),
"the choice posted the item's id",
)?;
d.check(self.size == 3, "and the app took it")
}
}
kui_devtools::main!(Select::default());
widgets/table.rs
//! The table (ADR 0033): `NodeSpec::table()` is a column whose rows'
//! children line up in columns, each column as wide as its widest cell.
//! No width is picked by hand and nothing is measured: the name column
//! sits at the longest name, the size column at the widest size, and the
//! kind column grows into the rest because its cells say `grow`.
//!
//! The rows are rows — this one's are clickable, with a hover wash and a
//! selected background, and the header's cells are clickable too, sorting
//! the rows by the column pressed — and a number right-aligns inside its
//! column with a `main_align: end` row around the text, as it would
//! anywhere.
//!
//! Run: cargo run -p kui-native --example table [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::{Align, App, Core, NodeSpec, Sizing, TextStyle, Ui, UiEvent, Value};
/// Name, size in bytes, kind.
const FILES: [(&str, u64, &str); 6] = [
("Cargo.toml", 1_204, "manifest"),
("src", 0, "directory"),
("README.md", 18_930, "markdown"),
("target", 0, "directory"),
("a-rather-long-file-name.rs", 402, "rust source"),
("LICENSE", 1_067, "text"),
];
#[derive(Clone, Copy, PartialEq)]
enum Sort {
Name,
Size,
Kind,
}
struct Table {
sort: Sort,
selected: Option<usize>,
}
impl Default for Table {
fn default() -> Self {
Self {
sort: Sort::Name,
selected: None,
}
}
}
impl Table {
fn order(&self) -> Vec<usize> {
let mut rows: Vec<usize> = (0..FILES.len()).collect();
match self.sort {
Sort::Name => rows.sort_by_key(|&i| FILES[i].0),
Sort::Size => rows.sort_by_key(|&i| std::cmp::Reverse(FILES[i].1)),
Sort::Kind => rows.sort_by_key(|&i| (FILES[i].2, FILES[i].0)),
}
rows
}
}
fn size(bytes: u64) -> String {
if bytes == 0 {
"—".to_string()
} else if bytes < 10_000 {
format!("{bytes} B")
} else {
format!("{:.1} KB", bytes as f64 / 1024.0)
}
}
impl App for Table {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(NodeSpec::column().fill().pad(24.0).gap(12.0), |ui| {
ui.text(
"a table: each column as wide as its widest cell, the last one growing",
TextStyle::new(12.0).color(t.muted),
);
ui.with_keyed(
"files",
NodeSpec::table()
.grow_width()
.gap(2.0)
.bg(t.sunken)
.radius(6.0)
.pad(4.0),
|ui| {
// The header: a row of three clickable cells, each
// sorting by its column; the sorted one in the accent.
ui.with(
NodeSpec::row().grow_width().gap(16.0).pad_xy(8.0, 4.0),
|ui| {
for (label, sort, align) in [
("name", Sort::Name, Align::Start),
("size", Sort::Size, Align::End),
("kind", Sort::Kind, Align::Start),
] {
let color = if self.sort == sort { t.accent } else { t.muted };
let width = if sort == Sort::Kind {
Sizing::Grow(1.0)
} else {
Sizing::Fit
};
ui.text_in_keyed(
&format!("sort-{label}"),
NodeSpec::row()
.width(width)
.main_align(align)
.on_click(Value::map([
("kind", Value::str("sort")),
("by", Value::str(label)),
]))
.label(format!("sort by {label}").as_str()),
label,
TextStyle::new(11.0).color(color),
);
}
},
);
for i in self.order() {
let (name, bytes, kind) = FILES[i];
let selected = self.selected == Some(i);
ui.with_keyed(
name,
NodeSpec::row()
.grow_width()
.gap(16.0)
.pad_xy(8.0, 3.0)
.radius(4.0)
.bg(if selected { t.accent } else { t.sunken })
.hover_bg(if selected { t.accent } else { t.hover })
.on_click(Value::map([
("kind", Value::str("select")),
("row", Value::Int(i as i64)),
]))
.label(name),
|ui| {
let fg = if selected { t.on_accent } else { t.fg };
// A bare text is a cell, held to its column.
ui.text(name, TextStyle::new(13.0).color(fg).nowrap());
// A number sits at the column's right edge.
ui.text_in_keyed(
"size",
NodeSpec::row().main_align(Align::End),
&size(bytes),
TextStyle::new(13.0).color(fg).mono().nowrap(),
);
ui.text(
kind,
TextStyle::new(13.0).color(if selected {
t.on_accent
} else {
t.muted
}),
);
},
);
}
},
);
let picked = self
.selected
.map(|i| format!("selected: {}", FILES[i].0))
.unwrap_or_else(|| "click a row to select it, a header to sort by it".to_string());
ui.text(&picked, TextStyle::new(12.0).color(t.faint).mono());
});
}
fn on_event(&mut self, ev: UiEvent) {
match ev.kind() {
Some("select") => {
self.selected = ev.payload.get_int("row").map(|i| i as usize);
}
Some("sort") => {
self.sort = match ev.payload.get_str("by") {
Some("size") => Sort::Size,
Some("kind") => Sort::Kind,
_ => Sort::Name,
};
}
_ => {}
}
}
}
impl Example for Table {
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(560.0, 320.0)
}
/// Every row's size cell starts at one x whatever its name's length,
/// the size column is as wide as its widest size, and the kind column
/// ends at the table's edge; a click selects a row, a header re-sorts.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
core.set_inspect(true);
let mut d = Drive::new(core, 560.0, 320.0);
d.frame(self);
let cells = |d: &Drive<'_>, label: &str| -> Vec<kui_native::Rect> {
d.core
.nodes()
.iter()
.filter(|n| n.label.as_deref() == Some(label))
.map(|n| n.rect)
.collect()
};
let sizes = cells(&d, "size");
d.check(sizes.len() == FILES.len(), "a size cell per row")?;
let x = sizes[0].x;
d.check(
sizes.iter().all(|r| r.x == x && r.w == sizes[0].w),
"every size cell starts at one x and is one width",
)?;
let table = d.key_of("files").ok_or("no table")?;
let table_rect = d
.core
.nodes()
.iter()
.find(|n| n.key == table)
.map(|n| n.rect)
.ok_or("the table is not in the snapshot")?;
let long = d
.key_of("a-rather-long-file-name.rs")
.ok_or("no long row")?;
let long_rect = d.rect_of(long).ok_or("the row is not clickable")?;
d.check(
long_rect.w == table_rect.w - 8.0,
"a row is as wide as the table's content",
)?;
let evs = d.click_key(self, long);
d.check(evs.len() == 1, "a row's click is the app's")?;
d.check(self.selected == Some(4), "and selects it")?;
d.frame(self);
let before = d.rect_of(long).ok_or("the row after the click")?;
let header = d.key_of("sort-kind").ok_or("no kind header")?;
d.click_key(self, header);
d.check(
self.sort == Sort::Kind,
"a header's click sorts by its column",
)?;
d.frame(self);
let after = d.rect_of(long).ok_or("the row after the sort")?;
d.check(after.y != before.y, "and the rows moved")?;
let sizes = cells(&d, "size");
d.check(
sizes.iter().all(|r| r.x == x),
"the columns stayed where they were",
)?;
let warnings = d.core.take_warnings();
d.check(warnings.is_empty(), "no warnings")
}
}
kui_devtools::main!(Table::default());
widgets/text.rs
//! The `text` element, and the paragraph it becomes with spans: styled
//! runs shaped and wrapped as one flow — bold, italic and coloured runs
//! and emoji share lines and wrap mid-sentence — plus what a single run
//! can ask for on its own: a font family, decorations (underline,
//! strikethrough, a span background), a line height, `nowrap`,
//! `max_lines` with an `ellipsis`. Shrink the window and every paragraph
//! rewraps live.
//!
//! Nothing here is selectable: that is `features/selection`, which puts
//! a scope over exactly this kind of text.
//!
//! Run: cargo run -p kui-native --example text
use kui_devtools::Example;
use kui_native::{Align, App, FontFamily, NodeSpec, Span, TextStyle, Theme, Ui};
struct Text;
/// A card in the theme's own surface and edge.
fn card(t: &Theme, title: &str, ui: &mut Ui<'_>, body: impl FnOnce(&mut Ui<'_>)) {
ui.with(
NodeSpec::column()
.grow_width()
.max_width(560.0)
.pad(24.0)
.gap(12.0)
.bg(t.surface)
.radius(12.0)
.border(1.0, t.border),
|ui| {
ui.text(title, TextStyle::new(12.0).color(t.muted));
body(ui);
},
);
}
impl App for Text {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
// Scrollable page: when the window is shorter than the content,
// the wheel scrolls it.
ui.with(
NodeSpec::column()
.fill()
.cross_align(Align::Center)
.pad(24.0)
.gap(16.0)
.scroll_y(),
|ui| {
card(
&t,
"SPANS · one paragraph, many styles, one shaping flow",
ui,
|ui| {
ui.rich_text(
&[
Span::new("Spans are "),
Span::new("plain data").bold().color(t.accent),
Span::new(", so every frontend — Rust, "),
Span::new("Lua").color(t.success),
Span::new(
", or C — can build them. The whole paragraph is shaped ",
),
Span::new("together").italic(),
Span::new(
", which means wrapping crosses style boundaries correctly \
instead of breaking at every run: ",
),
Span::new("bold").bold(),
Span::new(" and "),
Span::new("italic").italic(),
Span::new(" and "),
Span::new("bold-italic").bold().italic().color(t.warning),
Span::new(
" all sit on the same baselines. Emoji ride along via the \
color-glyph atlas path: 🦀🔥✨",
),
],
TextStyle::new(16.0).line_height(26.0),
);
ui.rich_text(
&[
Span::new(
"Per-span colour overrides the node colour at glyph level — ",
),
Span::new("red").color(t.danger),
Span::new(", "),
Span::new("green").color(t.success),
Span::new(", "),
Span::new("blue").color(t.accent),
Span::new(" — while unstyled runs inherit it."),
],
TextStyle::new(16.0).line_height(26.0).color(t.muted),
);
},
);
card(&t, "DECORATIONS · paint, not layout", ui, |ui| {
ui.rich_text(
&[
Span::new("An "),
Span::new("underline").underline(),
Span::new(", a "),
Span::new("strikethrough").strikethrough(),
Span::new(", a "),
Span::new("highlight").bg(t.accent_soft),
Span::new(" behind a span, and all three at once: "),
Span::new("marked")
.underline()
.strikethrough()
.bg(t.warning.with_alpha(0.25)),
Span::new(". A decorated span measures like a plain one."),
],
TextStyle::new(16.0).line_height(26.0),
);
});
card(&t, "FAMILIES · the same run in each", ui, |ui| {
for (name, family) in [
("sans", FontFamily::Sans),
("serif", FontFamily::Serif),
("mono", FontFamily::Mono),
] {
// The row grows so the run can wrap at the card's
// edge; in a fit row a growing column has no width.
ui.with(
NodeSpec::row()
.grow_width()
.gap(12.0)
.cross_align(Align::Center),
|ui| {
ui.text_in(
NodeSpec::row().width(44.0),
name,
TextStyle::new(11.0).color(t.muted),
);
ui.text_in(
NodeSpec::column().grow_width(),
"The quick brown fox jumps over the lazy dog 0123456789",
TextStyle::new(15.0).family(family),
);
},
);
}
});
card(
&t,
"WRAP · nowrap, and max_lines with an ellipsis",
ui,
|ui| {
ui.text(
"A run that may not wrap keeps going past its box, and the box \
clips it or does not — nowrap is a promise about lines, not width.",
TextStyle::new(14.0).nowrap(),
);
ui.text(
"Two lines at most, and an ellipsis where the second one ends: a \
card title that has to stay a title however long the string behind \
it grows, which is what a list of files or messages needs from a \
label — the layout stays where it was and the text yields.",
TextStyle::new(14.0)
.line_height(20.0)
.max_lines(2)
.ellipsis(),
);
ui.text(
"Line height is the run's, too: this paragraph asks for 30 px per \
line and gets the air that comes with it, whatever the size of \
the glyphs on the line.",
TextStyle::new(14.0).line_height(30.0).color(t.muted),
);
},
);
},
);
}
}
impl Example for Text {}
kui_devtools::main!(Text);
widgets/titlebar.rs
//! Custom chrome: `widgets::titlebar`, `titlebar_with` and
//! `window_buttons`, in the window they are for. The window opens with
//! `Chrome::Custom`, so the OS draws no titlebar of its own and the app's
//! first row is one — a full-width drag strip with the title, and the
//! window controls where the platform does not draw them itself.
//!
//! The strip reads `env.window` and adapts by itself: under macOS custom
//! chrome it insets past the native traffic lights
//! (`env.window.native_controls` says where they are) and draws no
//! buttons; elsewhere it appends minimize, maximize and close, and the
//! maximize glyph follows `env.window.maximized`. `titlebar_with` puts the
//! app's own content — tabs here — between the inset and the buttons;
//! interactive children inside it win hit-testing, so the tabs click and
//! the rest of the strip drags. `window_buttons` alone is the cluster
//! without the strip. The status line reads back the window facts the
//! strip read.
//!
//! The pin is the other thing a window with its own chrome wants (backlog
//! C30): `ui.always_on_top(pinned)` is declared every frame the app wants
//! the window above every other app's, and the frame that stops declaring
//! it is what lowers the window again — so the button toggles the app's
//! own flag and undoes nothing. Its label is drawn from
//! `env.window.always_on_top`, what the platform actually did, not from
//! the flag: a window manager can refuse or drop the level, and on Wayland
//! there is none to ask for.
//!
//! Run: cargo run -p kui-native --example titlebar
use kui_devtools::Example;
use kui_native::widgets;
use kui_native::{Align, App, NodeSpec, TextStyle, Ui, UiEvent, Value};
const TABS: [&str; 3] = ["main.rs", "layout.rs", "README"];
#[derive(Default)]
struct Chrome {
tab: usize,
/// The app's ask; what the window has is `env.window.always_on_top`.
pinned: bool,
}
impl App for Chrome {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
let win = ui.env().window;
ui.always_on_top(self.pinned);
ui.with(NodeSpec::column().fill().bg(t.bg), |ui| {
// The strip, with tabs in it: the tabs are clickable, the rest
// drags the window.
widgets::titlebar_with(ui, |ui| {
let t = ui.theme();
ui.with(
NodeSpec::row()
.fill()
.gap(4.0)
.cross_align(Align::End),
|ui| {
for (i, name) in TABS.iter().enumerate() {
let on = i == self.tab;
ui.text_in_keyed(name, NodeSpec::row()
.pad_xy(12.0, 6.0)
.radius_top(6.0)
.bg(if on { t.bg } else { t.surface })
.hover_bg(if on { t.bg } else { t.hover })
.on_click(Value::map([
("kind", Value::str("tab")),
("name", Value::str(*name)),
])), name,
TextStyle::new(12.0).color(if on { t.fg } else { t.muted }));
}
},
);
});
ui.with(
NodeSpec::column()
.fill()
.pad(24.0)
.gap(12.0),
|ui| {
ui.text(TABS[self.tab], TextStyle::new(20.0));
ui.text(
"the strip above is the app's: drag it, double-click it, click a tab",
TextStyle::new(13.0).color(t.muted),
);
let facts = format!(
"custom_chrome {} · maximized {} · fullscreen {} · always_on_top {} · native controls {}",
win.custom_chrome,
win.maximized,
win.fullscreen,
win.always_on_top,
match win.native_controls {
Some(r) => format!("{}×{} at the origin (the strip insets past them)", r.w.round(), r.h.round()),
None => "none (the strip draws its own buttons)".into(),
}
);
ui.text(&facts, TextStyle::new(12.0).color(t.faint));
// The pin: the label is the window's answer, not the
// flag's, so a platform that refused shows "pin" still.
ui.with(NodeSpec::row().gap(8.0).cross_align(Align::Center), |ui| {
widgets::button(
ui,
if win.always_on_top { "unpin" } else { "pin" },
Value::map([("kind", Value::str("pin"))]),
);
ui.text(
if self.pinned == win.always_on_top {
"always_on_top follows the pin: the window stays above every other app's"
} else {
"asked, and the platform has not agreed (Wayland has no window level)"
},
TextStyle::new(12.0).color(t.muted),
);
});
ui.text(
"and the cluster alone, `widgets::window_buttons` — nothing where the OS draws them:",
TextStyle::new(12.0).color(t.muted),
);
ui.with(
NodeSpec::row()
.pad(6.0)
.bg(t.surface)
.radius(6.0)
.border(1.0, t.border),
|ui| {
widgets::window_buttons(ui);
if win.native_controls.is_some() || !win.custom_chrome {
ui.text("(the OS's are the buttons here)", TextStyle::new(11.0).color(t.faint));
}
},
);
},
);
});
}
fn on_event(&mut self, ev: UiEvent) {
if ev.kind() == Some("pin") {
self.pinned = !self.pinned;
}
if let Some(name) = ev.payload.get_str("name")
&& let Some(i) = TABS.iter().position(|t| *t == name)
{
self.tab = i;
}
}
}
impl Example for Chrome {
const KEYS: &'static [(&'static str, &'static str)] = &[
("drag the strip", "move the window"),
("double-click it", "maximize (where the platform does)"),
(
"pin",
"keep the window above every other app's; again to let go",
),
];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default()
.size(640.0, 360.0)
.custom_titlebar()
}
/// The dock below the strip, so the strip stays the window's top edge.
fn dock(&self) -> kui_devtools::Dock {
kui_devtools::Dock::Bottom
}
}
kui_devtools::main!(Chrome::default());
widgets/tooltip.rs
//! The tooltip: a float below a hovered node, and the same fact told to
//! assistive technology. Three spellings of one thing:
//!
//! - `widgets::tooltip(ui, text)` inside a node the view knows is hovered
//! (`hoverable` + `is_hovered`) — the float, drawn by the view when the
//! view decides, which is also how a tooltip shows on a click or a
//! first frame;
//! - `NodeSpec::tooltip(hint)`: the prop — the node tracks hover, the
//! hint is its accessible `description` (what a screen reader says
//! after the name), and the core floats it while hovered. On any node;
//! `apply_tooltip` is the first two alone, for a view that floats its
//! own;
//! - `tooltip_with`: the same chrome around anything — a legend, a
//! shortcut hint with its own layout.
//!
//! A tooltip is a float with `fit`: near the window's bottom edge it
//! mirrors above the node instead of hanging off the frame.
//!
//! Run: cargo run -p kui-native --example tooltip [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{Align, App, Core, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Default)]
struct Tooltip {
clicks: u32,
/// The badge's tooltip is pinned open until the first click, so it can
/// be seen without a pointer.
pinned: bool,
}
impl App for Tooltip {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(24.0)
.gap(20.0)
.cross_align(Align::Start),
|ui| {
ui.text("the view's: `hoverable`, `is_hovered`, `widgets::tooltip`", TextStyle::new(12.0).color(t.muted));
let badge = ui.child_key("badge");
let show = ui.is_hovered(badge) || !self.pinned;
ui.with_keyed(
"badge",
NodeSpec::row()
.pad_xy(12.0, 6.0)
.radius(10.0)
.bg(t.raised)
.border(1.0, t.border)
.hoverable(),
|ui| {
ui.text("?", TextStyle::new(13.0));
if show {
widgets::tooltip(ui, "a float: out of flow, on top, unclipped — pinned until the first click");
}
},
);
ui.text("the prop's: `tooltip` — hover tracking, the accessible description and the float in one", TextStyle::new(12.0).color(t.muted));
ui.with(NodeSpec::row().gap(10.0), |ui| {
widgets::button_with(
ui,
"save",
"save",
widgets::button_spec(&ui.theme(), &ui.metrics()).on_click("save").tooltip("⌘S · write the file"),
None,
);
widgets::button_with(
ui,
"run",
"run",
widgets::button_spec(&ui.theme(), &ui.metrics()).accent().on_click("run").tooltip("⌘R · run the current file"),
None,
);
});
ui.text("`tooltip_with`: the chrome around anything", TextStyle::new(12.0).color(t.muted));
let legend = ui.child_key("legend");
let over = ui.is_hovered(legend);
ui.with_keyed(
"legend",
NodeSpec::row()
.pad_xy(12.0, 6.0)
.radius(8.0)
.bg(t.surface)
.border(1.0, t.border)
.hoverable(),
|ui| {
ui.text("hover for the shortcuts", TextStyle::new(13.0));
if over {
widgets::tooltip_with(ui, |ui| {
ui.with(NodeSpec::column().gap(4.0), |ui| {
for (k, what) in [("⌘S", "save"), ("⌘R", "run"), ("⌘-Shift-P", "the palette")] {
ui.with(NodeSpec::row().gap(10.0), |ui| {
ui.text_in(NodeSpec::row().width(80.0), k, TextStyle::new(12.0).color(t.accent).mono());
ui.text(what, TextStyle::new(12.0).color(t.muted));
});
}
});
});
}
},
);
ui.leaf(NodeSpec::column().grow_height());
let low = ui.child_key("low");
let over_low = ui.is_hovered(low);
ui.with_keyed(
"low",
NodeSpec::row()
.pad_xy(12.0, 6.0)
.radius(8.0)
.bg(t.surface)
.border(1.0, t.border)
.hoverable(),
|ui| {
ui.text("near the bottom: the float mirrors above", TextStyle::new(13.0));
if over_low {
widgets::tooltip(ui, "fit: mirrored back across the node, not off the frame");
}
},
);
ui.text(&format!("{} clicks", self.clicks), TextStyle::new(12.0).color(t.faint));
},
);
}
fn on_event(&mut self, ev: UiEvent) {
if ev.payload.as_str().is_some() {
self.clicks += 1;
self.pinned = true;
}
}
}
impl Example for Tooltip {
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(520.0, 360.0)
}
/// The badge's tooltip is in the frame until a click, then only while
/// hovered; a button's hint is its accessible description whether or
/// not it is hovered.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 520.0, 360.0);
d.frame(self);
let has_text = |d: &mut Drive<'_>, s: &str| {
d.core
.access_tree()
.nodes
.iter()
.any(|n| n.name.as_deref().is_some_and(|x| x.contains(s)))
};
let shown = has_text(&mut d, "a float: out of flow");
d.check(shown, "the badge's tooltip shows before any click")?;
let save = d.key_of("save").ok_or("no save button")?;
d.click_key(self, save);
d.frame(self);
let shown = has_text(&mut d, "a float: out of flow");
d.check(!shown, "and hides after the first click")?;
let badge = d.key_of("badge").ok_or("no badge")?;
d.hover(self, badge);
d.frame(self);
let shown = has_text(&mut d, "a float: out of flow");
d.check(shown, "hovering the badge shows it again")?;
let desc = d
.core
.access_tree()
.nodes
.iter()
.find(|n| n.name.as_deref() == Some("save"))
.and_then(|n| n.description.clone());
d.check(
desc.as_deref() == Some("⌘S · write the file"),
"a button's hint is its accessible description, hovered or not",
)
}
}
kui_devtools::main!(Tooltip::default());
widgets/virtual_list.rs
//! A ten-thousand-row list that costs a screenful.
//!
//! The core builds every child a view declares, so a naive thousand-row
//! column pays for a thousand rows on every frame. `Core::scroll_geometry`
//! is the way out: it retains what the last layout resolved for a scroll
//! container — its box, its content size and the clamped offset — so the
//! *view* can decide which rows are worth declaring, and hold the space of
//! the rest with two spacers.
//!
//! Two spellings of the same thing:
//!
//! - **`widgets::uniform_list`** is the uniform-row case done — visible
//! range, two rows of overscan, the two spacers, and rows opened at
//! their *data* index so a row keeps its hover, focus and tweens as the
//! built range slides over it.
//! - **`widgets::visible_rows` + `ui.scroll_geometry`** is the arithmetic
//! alone, for a view that builds its own container (a header inside the
//! scroller, a grid, rows that are not all one node). `--by-hand` runs
//! that one; it is what the widget does, unrolled.
//!
//! And when the rows are *not* all one height — a log whose lines wrap —
//! `widgets::list` is the same idea over prefix sums instead of a
//! stride: heights come from a `measure` callback it runs only for the rows
//! it is about to build, everything else stands at the mean of those, and
//! the row the window starts in is put back where it was after each frame
//! learns something, so the content never slides. `--variable` runs that.
//!
//! Run: cargo run -p kui-native --example virtual_list
//! cargo run -p kui-native --example virtual_list -- --by-hand
//! cargo run -p kui-native --example virtual_list -- --variable
//! cargo run -p kui-native --example virtual_list -- --headless [--variable]
use kui_devtools::{Drive, Example};
use kui_native::{
Align, App, Color, Core, Key, NodeSpec, Role, TextStyle, TextWrap, Theme, Ui, UiEvent, Value,
widgets,
};
const ROWS: usize = 10_000;
const ROW_H: f32 = 28.0;
/// The geometry a view slices by is the previous frame's, so a resize (and
/// the frame a wheel jump lands on) is one frame late; two rows cover it.
const OVERSCAN: usize = 2;
struct VirtualList {
selected: usize,
mode: Mode,
/// What the last frame built, for the header to report.
built: std::ops::Range<usize>,
/// `--variable` only: the row heights, which the app owns because the
/// widget is composed from primitives and keeps nothing of its own.
heights: widgets::RowHeights,
}
#[derive(Clone, Copy, PartialEq)]
enum Mode {
Widget,
ByHand,
Variable,
}
/// `--variable`'s data: lines of very different lengths, so wrapping gives
/// the rows three or four different heights and no stride describes them.
fn line_of(i: usize) -> String {
let words = 2 + (i * 7 + i / 3) % 22;
let mut s = format!("{i:>5} ");
for w in 0..words {
s.push_str(["log", "line", "of", "some", "length", "here"][w % 6]);
s.push(' ');
}
s
}
const BODY: f32 = 13.0;
/// A row's background: the accent where it is selected, and otherwise the
/// two surfaces a striped list alternates between. Opaque rather than a
/// translucent wash, because a `hover_bg` replaces a background instead of
/// compositing over it.
fn row_bg(t: &Theme, i: usize, selected: usize) -> Color {
if i == selected {
t.surface.mix(t.accent, 0.28)
} else if i.is_multiple_of(2) {
t.surface
} else {
t.sunken
}
}
/// One row. Whatever declares it, it has to come out exactly `ROW_H` tall:
/// that is the stride the arithmetic above and below it assumes.
fn row(ui: &mut Ui<'_>, i: usize, selected: usize) {
let t = ui.theme();
let bg = row_bg(&t, i, selected);
ui.with(
NodeSpec::row()
.fill()
.pad(6.0)
.gap(8.0)
.cross_align(Align::Center)
.bg(bg)
.hover_bg(bg.mix(t.accent, 0.12))
.on_click(Value::map([
("kind", Value::str("pick")),
("row", Value::Int(i as i64)),
]))
.role(Role::ListItem)
.label(format!("row {i} of {ROWS}")),
|ui| {
ui.text(&format!("{i:>5}"), TextStyle::new(13.0).color(t.faint));
ui.text(&format!("log line {i}"), TextStyle::new(13.0).color(t.fg));
},
);
}
/// The style a `--variable` row's text is measured *and* drawn in. The
/// measurement is only worth anything if it is the same style: `measure_text`
/// is what layout would give a text node with this content and this style.
fn body(t: &Theme) -> TextStyle {
TextStyle::new(BODY).color(t.fg).wrap(TextWrap::Word)
}
fn list_spec(t: &Theme) -> NodeSpec {
NodeSpec::column()
.fill()
.bg(t.bg)
.role(Role::List)
.label("log")
}
impl VirtualList {
/// The whole list, in one call.
fn widget(&mut self, ui: &mut Ui<'_>) {
let selected = self.selected;
let spec = list_spec(&ui.theme());
let (mut first, mut last) = (usize::MAX, 0usize);
widgets::uniform_list(ui, "log", spec, ROWS, ROW_H, |ui, i| {
first = first.min(i);
last = i + 1;
row(ui, i, selected);
});
self.built = if last == 0 { 0..0 } else { first..last };
}
/// The same list with the container in the app's hands: read the
/// geometry, slice, and open each row at its data index.
fn by_hand(&mut self, ui: &mut Ui<'_>) {
// The key the container *will* have — `child_key` is the same hash
// the open below computes, so the geometry can be read before the
// node it belongs to is declared.
let key: Key = ui.child_key("log");
// `None` until a layout has resolved the container: on the first
// frame slice by the viewport instead, and ask for the frame that
// will know better.
let (offset_y, vh) = match ui.scroll_geometry(key) {
Some(g) => (g.offset.y, g.rect.h),
None => {
ui.request_frame();
(ui.scroll_offset(key).y, ui.viewport().h)
}
};
let range = widgets::visible_rows(offset_y, vh, 0.0, ROW_H, ROWS, OVERSCAN);
self.built = range.clone();
let selected = self.selected;
// `scroll_y()` implies the clip; `gap(0)` because the stride is
// `ROW_H` and nothing else.
let spec = list_spec(&ui.theme()).scroll_y().gap(0.0);
ui.with_keyed("log", spec, |ui| {
// Keyed, not auto-keyed: an auto key *is* the sibling index, and
// the rows already occupy that namespace at their data indices.
let lead = range.start as f32 * ROW_H;
if lead > 0.0 {
ui.leaf_keyed("lead", spacer(lead));
}
for i in range.clone() {
// The key auto-keying would have given row `i` in a list
// that built them all — so hover, focus and any tween stay
// with the row as the window slides over it.
ui.with_indexed(
i as u64,
NodeSpec::column().grow_width().height(ROW_H),
|ui| row(ui, i, selected),
);
}
let tail = (ROWS - range.end) as f32 * ROW_H;
if tail > 0.0 {
ui.leaf_keyed("tail", spacer(tail));
}
});
}
}
fn spacer(h: f32) -> NodeSpec {
NodeSpec::column().grow_width().height(h)
}
impl VirtualList {
/// Rows of different heights, from `measure_text` — the number layout
/// itself would give the row's text at that width, so what the row
/// measures is what the row gets.
fn variable(&mut self, ui: &mut Ui<'_>) {
let selected = self.selected;
let t = ui.theme();
let (mut first, mut last) = (usize::MAX, 0usize);
widgets::list(
ui,
"log",
list_spec(&t).pad(6.0),
&mut self.heights,
|ui, i, w| {
// The row pads itself by 6 on each side, and that padding is
// part of the stride the arithmetic uses.
ui.measure_text(&line_of(i), &body(&t), Some(w - 12.0))
.height
+ 12.0
},
|ui, i| {
first = first.min(i);
last = i + 1;
let bg = row_bg(&t, i, selected);
ui.text_in(
NodeSpec::column()
.fill()
.pad(6.0)
.bg(bg)
.hover_bg(bg.mix(t.accent, 0.12))
.on_click(Value::map([
("kind", Value::str("pick")),
("row", Value::Int(i as i64)),
]))
.role(Role::ListItem)
.label(format!("row {i} of {ROWS}")),
&line_of(i),
body(&t),
);
},
);
self.built = if last == 0 { 0..0 } else { first..last };
}
}
impl App for VirtualList {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(NodeSpec::column().fill().bg(t.bg), |ui| {
let built = self.built.len();
let how = match self.mode {
Mode::Widget => "uniform_list",
Mode::ByHand => "by hand",
Mode::Variable => "list",
};
ui.text_in(
NodeSpec::row().grow_width().pad(8.0).bg(t.surface),
&format!(
"{ROWS} rows, {built} built ({how}) — row {} selected",
self.selected
),
TextStyle::new(14.0).color(t.fg),
);
match self.mode {
Mode::Widget => self.widget(ui),
Mode::ByHand => self.by_hand(ui),
Mode::Variable => self.variable(ui),
}
});
}
fn on_event(&mut self, ev: UiEvent) {
// A row's `on_click` payload arrives verbatim: the row it named.
if let Some(i) = ev.payload.get_int("row") {
self.selected = i as usize;
}
}
}
impl Example for VirtualList {
const FLAGS: &'static [(&'static str, &'static str)] = &[
(
"--by-hand",
"the same list unrolled from scroll_geometry + visible_rows",
),
(
"--variable",
"rows of no fixed height, through widgets::list",
),
];
const KEYS: &'static [(&'static str, &'static str)] = &[("wheel", "scroll 10,000 rows")];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(560.0, 480.0)
}
/// The same view against a bare `Core`. Two frames, because the first
/// has only the viewport to slice by; then a wheel to row 300, which
/// reaches no event handler — the next frame's view reads the new
/// offset; then a click on a row the first frame never built.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 480.0, 300.0);
d.frame(self);
let screenful = (300.0 / ROW_H) as usize;
d.check(
!self.built.is_empty() && self.built.len() < 4 * screenful,
"frame 1 slices by the viewport, having no geometry yet",
)?;
d.frame(self);
d.check(
self.built.start == 0
&& self.built.len() >= screenful
&& self.built.len() < 4 * screenful,
"frame 2 builds a screenful and its overscan, not 10,000 rows",
)?;
let log = d.key_of("log").ok_or("no log container")?;
let g = d
.core
.scroll_geometry(log)
.ok_or("the log was not laid out")?;
d.check(
g.content.h >= (ROWS as f32) * ROW_H * 0.9,
"the content is the whole list's height",
)?;
d.wheel(self, 240.0, 200.0, 0.0, -300.0 * ROW_H);
d.frame(self);
d.frame(self);
// Uniform rows land within the overscan of row 300; rows of
// varying height land wherever 8,400 px of them reach, which is
// the point of measuring them.
let landed = if self.mode == Mode::Variable {
self.built.start > 100
} else {
self.built.start >= 290 && self.built.start <= 300
};
d.check(landed, "a wheel to row 300 re-slices the built range")?;
if self.mode == Mode::Variable {
let measured = (0..ROWS)
.filter(|&i| self.heights.measured(i).is_some())
.count();
d.check(
measured > 0 && measured < ROWS / 10,
"only the rows built so far were measured",
)?;
}
// A row that was never in the first frame's range is an ordinary
// node: it hit-tests, and its payload comes back as the app wrote it.
d.click(self, 240.0, 100.0);
let clicked = self.selected;
d.check(
clicked > 100 && self.built.contains(&clicked),
"a row the first frame never built is clickable",
)
}
}
fn main() {
let mode = if std::env::args().any(|a| a == "--variable") {
Mode::Variable
} else if std::env::args().any(|a| a == "--by-hand") {
Mode::ByHand
} else {
Mode::Widget
};
kui_devtools::run(
env!("CARGO_BIN_NAME"),
VirtualList {
selected: 0,
mode,
built: 0..0,
// 28 is a guess, and all it decides is how wrong the scrollbar
// is before anything has been measured.
heights: widgets::RowHeights::new(ROWS, ROW_H),
},
);
}
features/
One cross-cutting behaviour, with exactly the widgets it touches.
accessibilityalignaudioclipboarddevtools_tabdragdropenter_exitexit_budgetfocushovermetricsmodalpopupselectionspringthemetransitionwaker
features/accessibility.rs
//! Every accessibility prop in one window, and the fixture the platform
//! audit drives (`scripts/ax-audit.swift` on macOS, or a screen reader by
//! hand). Each control is built so that assistive technology can both
//! read it and change it, and see the change: the button's name counts
//! its presses, the sliders' values follow increment and decrement, and
//! both editors report the text they hold. The two sliders differ in one
//! row: `Volume` declares only its number, so a reader turns the position
//! into a percentage, while `Focus length` also declares a `value_text`
//! and is read as "25 minutes" (backlog F8).
//!
//! Run: cargo run -p kui-native --example accessibility
//!
//! With VoiceOver (⌘F5): VO-right walks the controls, VO-space presses
//! the button, and inside either editor the arrow keys read by character
//! and VO-arrows by word and line.
//!
//! With the keyboard alone (`docs/adr/0002-keyboard-focus-as-data.md`):
//! Tab walks every control in order — the button, the icon button, the
//! switch, the two sliders, the built-in editor, the app-owned editor — with a
//! ring around the focused one; Enter or Space presses a button or flips
//! the switch, the arrows move the slider, Escape lets go. The app-owned
//! editor is a key sink, so it keeps Tab; its declaration below takes
//! focus once, on the first frame, and never clobbers a Tab press.
//!
//! Four things here are **composites**
//! (`docs/adr/0007-composite-keyboard-patterns.md`): the tab list, the
//! theme radio group, the mailbox list and the Actions menu are **one**
//! Tab stop each, not one per item. Tab enters on the item that is
//! selected (or checked, or first), and inside it the arrow keys — both
//! pairs — move the selection, Home and End reach the ends, and typing a
//! name jumps to it. The tab list and the radio group *activate* as focus
//! moves, because that is what those two patterns mean on every platform;
//! the list and the menu leave activation to Enter or Space. Nothing
//! below declares any of that: the core derives a composite from a
//! container role whose items are focusable, so the tab list and the list
//! read exactly as they did before this and behave differently.
//!
//! The tab list reads as "General, tab, 1 of 3, selected" and the
//! disclosure below it as "Advanced, collapsed": `selected` says which of
//! a set is the current one (distinct from a switch being on), `expanded`
//! names a disclosure's state so a shut one can say it is shut, and "1 of
//! 3" is not declared at all — the core numbers the tabs a `tabList`
//! holds.
//!
//! The list under them is the other half of `selected`: a picked row
//! reads as selected where a tab reads as on, and macOS spells those two
//! differently (`AXSelected` against `AXValue`), which is what
//! `scripts/ax-audit.swift` is there to check.
//!
//! "Delete…" opens a modal dialog (`docs/adr/0003-modal-surfaces.md`):
//! Tab cannot leave it, nothing behind it clicks, VoiceOver announces a
//! modal dialog and stays inside it, and Escape or a click outside asks
//! it to close — the app decides, and focus returns to the button that
//! opened it. It opens on Cancel, because Cancel says `initialFocus`:
//! a destructive confirm should not be one habitual Enter away from
//! confirming.
use kui_devtools::Example;
use kui_native::widgets;
use kui_native::{
Align, App, EditOptions, FloatConfig, Key, Live, NodeSpec, Role, TextStyle, Ui, UiEvent, Value,
};
const DOC: &str = "hello world\nsecond line";
struct A11y {
presses: u32,
volume: f32,
/// Minutes, in [5..60] — the pomodoro report's own range. Its slider
/// says what the number *reads as*; the volume slider above says only
/// the number, so the window carries both readings side by side.
focus_min: f32,
muted: bool,
/// The custom editor's document and its caret (line, byte offset):
/// the app owns the buffer, kui only learns where the caret sits.
lines: Vec<String>,
caret: (usize, usize),
edit: Key,
/// Whether the confirm dialog is declared this frame. Nothing else:
/// the modal is the frame that declares it.
dialog: bool,
/// Which tab the tab list shows, and whether the disclosure below is
/// open: `selected` and `expanded` are these two fields, read out.
tab: usize,
advanced: bool,
/// The picked row of the list below — `selected` again, on the other
/// role that carries it.
row: usize,
/// The checked radio of the theme group. `checked`, not `selected`:
/// a radio is on or off the way a checkbox is, and the *group* is
/// what makes it one of a set.
theme: usize,
/// Whether the Actions menu is declared this frame. Like `dialog`,
/// nothing else: a menu is a modal float that the app stops
/// declaring.
menu: bool,
/// The live region's message, and how many saves are behind it. This
/// is state a view reads, like everything else here — the region is
/// `live`, so a reader hears it *because it changed*, without being
/// asked and without the app saying "now".
saves: u32,
/// The other half: something to say once, with nothing on screen
/// holding it. `on_event` takes no `Ui`, so the handler leaves it
/// here and the view announces it and clears it — the guard the core
/// reports as `announcement-repeated` when an app forgets it
/// (`docs/adr/0008-live-regions-and-announcements.md`).
pending: Option<String>,
}
impl A11y {
fn new() -> Self {
Self {
presses: 0,
volume: 3.0,
focus_min: 25.0,
muted: false,
lines: vec!["fn main() {".into(), " greet()".into(), "}".into()],
caret: (1, 4),
edit: Key::ROOT,
dialog: false,
tab: 0,
advanced: false,
row: 1,
theme: 1,
menu: false,
saves: 0,
pending: None,
}
}
/// The line the caret is on, clamped into the document.
fn caret_line(&self) -> usize {
self.caret.0.min(self.lines.len().saturating_sub(1))
}
}
impl App for A11y {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
let text = TextStyle::new(14.0).color(t.fg);
// The label colour for the hand-built buttons below: readable on
// whatever `button_spec` actually carries, which is the rule
// `widgets::button_with` applies. Not `t.on_accent` — none of
// them declares `accent`, so their background stays the stock
// blue however the OS's accent is set, and a light accent's
// black label would land on that blue.
let on_button =
widgets::readable_on(widgets::button_spec(&ui.theme(), &ui.metrics()).style.bg);
// No pad or gap on the root: the scrolling column below owns
// both, so the scrollbar rides the window edge rather than
// floating inside a margin.
ui.open(NodeSpec::column().fill().bg(t.bg));
// The drawn titlebar is part of the fixture: the audit checks that
// a titlebar named like the window is read once, not twice.
widgets::titlebar(ui, "kui — accessibility");
// Everything but the chrome scrolls. Every control below is in one
// column, and a column that overflows squeezes its children —
// squeezed rows are exactly what a fixture must not show when the
// point of it is that they read correctly. Scrolling keeps them
// their own size on a window too short for all of them, and gives
// assistive technology a scroll view it can move (`ScrollIntoView`
// arrives as an access request and the core applies it).
//
// The modal and the latency HUD stay outside it: both are floats,
// and a float inside a clipping container is clipped by it.
let mut sink = Key::ROOT;
ui.with_keyed(
"content",
NodeSpec::column().fill().scroll_y().gap(10.0).pad(14.0),
|ui| {
// A heading: named by the text inside it, which is then read as
// part of it rather than as a label of its own.
ui.text_in(
NodeSpec::row().role(Role::Heading),
"Controls",
TextStyle::new(20.0).color(t.fg),
);
// A tab list: `selected` is which one the view shows, and every
// tab reports the state so a reader can say which is on. Nothing
// here says "1 of 3" — the core counts what the list holds.
ui.with_keyed("tabs", NodeSpec::row().role(Role::TabList).gap(4.0), |ui| {
for (i, name) in ["General", "Network", "About"].iter().enumerate() {
let on = i == self.tab;
ui.text_in_keyed(
name,
NodeSpec::row()
.role(Role::Tab)
.selected(on)
.on_click(Value::Int(i as i64))
.pad_xy(10.0, 6.0)
.bg(if on { t.accent } else { t.surface })
.radius(6.0),
name,
TextStyle::new(13.0).color(if on { t.on_accent } else { t.muted }),
);
}
});
// A disclosure: `expanded` names its state, so a reader says
// "collapsed" rather than nothing at all when it is shut.
ui.text_in_keyed(
"advanced",
widgets::button_spec(&ui.theme(), &ui.metrics())
.expanded(self.advanced)
.on_click("advanced")
.label("Advanced"),
if self.advanced {
"▾ Advanced"
} else {
"▸ Advanced"
},
TextStyle::new(widgets::BUTTON_TEXT).color(on_button),
);
if self.advanced {
ui.text_in(
NodeSpec::row().pad_xy(10.0, 6.0).bg(t.sunken).radius(6.0),
"Nothing here yet.",
text,
);
}
// A radio group: the one pattern whose arrows *must* also check
// the radio they land on, which is why the group is here at all.
// The stock group (`docs/adr/0034-stock-controls-over-the-roles.md`)
// is the `radioGroup` container; each stock `radio` says
// `checked`, and the group's `row` spec is what tells the
// platform the set is laid out horizontally. Nothing declares a
// Tab stop or an arrow key — the group holds focusable radios,
// and that is a composite.
ui.text_in(
NodeSpec::row().role(Role::Heading),
"Theme",
TextStyle::new(15.0).color(t.fg),
);
widgets::radio_group_with(ui, "Theme", NodeSpec::row().gap(16.0), |ui| {
for (i, name) in ["Light", "Dark", "Auto"].iter().enumerate() {
widgets::radio(ui, name, i == self.theme, format!("theme{i}"));
}
});
// A list whose rows can be picked. A row is not named by its
// content the way a button is — it is a container of content, and
// giving it a label as well would have it read twice — so its
// text child is what a reader announces. Nothing here says "2 of
// 3" either: the core numbers the rows it holds.
ui.with_keyed(
"mailboxes",
NodeSpec::column().role(Role::List).gap(2.0),
|ui| {
for (i, name) in ["Inbox", "Drafts", "Sent"].iter().enumerate() {
let on = i == self.row;
ui.text_in_keyed(
name,
NodeSpec::row()
.role(Role::ListItem)
.selected(on)
.focusable()
.on_click(Value::str(format!("row{i}")))
.width(200.0)
.pad_xy(10.0, 5.0)
.bg(if on { t.accent_pressed } else { t.surface })
.radius(4.0),
name,
TextStyle::new(13.0).color(if on { t.on_accent } else { t.muted }),
);
}
},
);
// A button named by its content, so a press is visible through
// the accessibility API alone. Keyed by hand: `widgets::button`
// keys a node by its text, and this text changes on every press —
// a re-keyed node is a new node, which drops keyboard focus and
// leaves a screen reader's cursor on an element that no longer
// exists.
ui.text_in_keyed(
"count",
widgets::button_spec(&ui.theme(), &ui.metrics()).on_click("press"),
&format!("count {}", self.presses),
TextStyle::new(widgets::BUTTON_TEXT).color(on_button),
);
// An icon button: nothing to read inside, so it needs a label.
ui.text_in_keyed(
"save",
widgets::button_spec(&ui.theme(), &ui.metrics())
.on_click("save")
.label("Save"),
"⌘",
TextStyle::new(15.0).color(on_button),
);
// A one-off announcement: nothing on screen says "Copied", and
// nothing should — a reader hears it, everyone else sees the
// button they just pressed. Announced here rather than in
// `on_event` because `App::on_event` takes no `Ui`; the `take`
// is the guard, since a view runs every frame.
if let Some(msg) = self.pending.take() {
ui.announce(&msg, Live::Polite);
}
widgets::button(ui, "Copy", Value::str("copy"));
// The live region. A plain box, so without the row it would be
// elided and its text would be read only when asked for; with it
// the box stays in the tree as a group, its liveness reaches the
// text inside, and a reader announces the count each time it
// changes. `polite` waits for a pause — `assertive` would
// interrupt, which a save confirmation has not earned.
ui.text_in_keyed(
"status",
NodeSpec::row()
.live(Live::Polite)
.pad_xy(10.0, 5.0)
.bg(t.sunken)
.radius(4.0),
&if self.saves == 0 {
"No changes saved".to_string()
} else if self.saves == 1 {
"Saved 1 change".to_string()
} else {
format!("Saved {} changes", self.saves)
},
text,
);
// The button that opens the modal below.
widgets::button(ui, "Delete…", Value::str("open-confirm"));
// A menu, opened from a button: `modal` plus `role="menu"`, which
// is the shape a context menu takes. It floats below its trigger,
// so the plain box around the pair is what the float anchors to —
// a button is read as one control, and a menu declared inside it
// would be read as part of that control rather than as a menu.
// Focus enters it, the arrows move between its items *without*
// running them (a menu that ran whatever you passed over would be
// unusable), and Enter, Space or Escape ends it.
ui.with(NodeSpec::column(), |ui| {
widgets::button(ui, "Actions ▾", Value::str("open-menu"));
if self.menu {
ui.with_keyed(
"menu",
NodeSpec::column()
// Left-aligned under the button and clamped
// into the window: `below()` alone centres a
// float on its anchor — right for a tooltip,
// which is what it is for — so a 180-wide
// menu under a 90-wide button near the left
// edge starts outside the window, and without
// `fit` nothing pulls it back. A menu belongs
// under the left edge of the control that
// opened it, and `fit` is the in-window
// answer ADR 0004 decision 11 keeps: this one
// does fit, once it is placed properly.
.float(
FloatConfig::below()
.at(Align::Start, Align::End)
.self_at(Align::Start, Align::Start)
.fit(),
)
.modal("menu")
.role(Role::Menu)
.label("Actions")
.width(180.0)
.pad(4.0)
.gap(2.0)
.bg(t.surface)
.border(1.0, t.accent)
.radius(6.0),
|ui| {
for name in ["Rename", "Duplicate", "Archive"] {
ui.text_in_keyed(
name,
NodeSpec::row()
.role(Role::MenuItem)
.on_click(Value::str(format!("menu:{name}")))
.grow_width()
.pad_xy(8.0, 5.0)
.radius(4.0)
.focus_bg(t.accent_soft),
name,
TextStyle::new(13.0).color(t.fg),
);
}
},
);
}
});
// A switch: `checked` is the state assistive technology reads,
// and its text is its name.
widgets::switch(ui, "Mute", self.muted, "mute");
// A stock slider: the value, the range and the step are data,
// and every way of moving it — the pointer, the arrows, the
// Page keys, Home and End, a reader's increment and decrement —
// arrives as one `change` event proposing the new value.
let m = ui.metrics();
widgets::slider(ui, "Volume", self.volume, 0.0, 10.0, 1.0, "volume");
// The same control, saying what its position *reads as*.
// With only `value_now` and the range a reader has to
// invent a reading and says a percentage — 25 in [5..60]
// is "36 percent", which is what the pomodoro report hit
// (backlog F8). `value_text` is the reading itself, and it
// replaces the number rather than joining it. It is not
// the `label`: the name of the control does not change
// when its value does.
// Five minutes a step, which is the step a reader's increment
// and the arrows both take.
widgets::slider_with(
ui,
"Focus length",
widgets::slider_spec(&m)
.value_now(self.focus_min)
.value_min(5.0)
.value_max(60.0)
.value_step(5.0)
.value_text(format!("{} minutes", self.focus_min as i32))
.on_change("focus"),
None,
);
// A built-in editor: the core owns the buffer, so its runs, caret
// and selection come out of the edit store, and a screen reader's
// selection and text requests are applied for you.
ui.text_in(
NodeSpec::row().role(Role::Heading),
"Built-in editor",
TextStyle::new(15.0).color(t.fg),
);
self.edit = ui.text_edit(
"doc",
DOC,
&EditOptions {
multiline: true,
style: text,
..Default::default()
},
NodeSpec::column()
.size(280.0, 56.0)
.pad(8.0)
.bg(t.sunken)
.radius(6.0)
.clip()
.label("Notes"),
);
// An editor the app owns: the sink says what it is, each drawn
// row is a line of its text, and the caret rides along as a byte
// offset. Text requests come back as `access` events.
ui.text_in(
NodeSpec::row().role(Role::Heading),
"App-owned editor",
TextStyle::new(15.0).color(t.fg),
);
let (lines, caret) = (&self.lines, self.caret);
sink = ui.with_keyed(
"code",
NodeSpec::column()
.width(280.0)
.pad(8.0)
.bg(t.sunken)
.radius(6.0)
.on_key("code")
.role(Role::MultilineTextInput)
.label("Source"),
|ui| {
ui.with(NodeSpec::row().gap(8.0), |ui| {
// Decoration: line numbers are not part of the text.
ui.with(NodeSpec::column().role(Role::None), |ui| {
for i in 0..lines.len() {
ui.text(
&format!("{}", i + 1),
TextStyle::new(12.0).mono().color(t.faint),
);
}
});
ui.with(NodeSpec::column(), |ui| {
for (i, line) in lines.iter().enumerate() {
let mut row = NodeSpec::row().role(Role::Line);
if caret.0 == i {
row = row.caret(caret.1.min(line.len()) as u32);
}
ui.text_in_keyed(
&format!("l{i}"),
row,
line,
TextStyle::new(13.0).mono().color(text.color_or_default()),
);
}
});
});
},
);
},
);
ui.take_key_focus(sink);
// The modal, declared last so it floats over everything. One row
// makes it modal:
// focus enters it, Tab cannot leave, nothing behind it takes
// input, and a screen reader announces a dialog and stays inside.
if self.dialog {
ui.with_keyed(
"confirm",
NodeSpec::column()
.float(FloatConfig::viewport().inside(Align::Center, Align::Center))
.modal("confirm")
.label("Delete note")
.width(260.0)
.gap(10.0)
.pad(14.0)
.bg(t.surface)
.border(1.0, t.accent)
.radius(8.0),
|ui| {
ui.text("Delete this note?", TextStyle::new(15.0).color(t.fg));
ui.with(NodeSpec::row().gap(8.0), |ui| {
// Where focus lands when the dialog opens, said
// rather than inherited from declaration order: a
// destructive confirm opens on its safe option, so
// Enter out of habit cancels. Without the row the
// ring's first node wins, which is Cancel here only
// because Cancel happens to be declared first — and
// that is exactly the thing an app should not have
// to keep true by hand.
ui.text_in_keyed(
"Cancel",
widgets::button_spec(&ui.theme(), &ui.metrics())
.on_click("cancel")
.initial_focus(),
"Cancel",
TextStyle::new(widgets::BUTTON_TEXT).color(on_button),
);
widgets::button(ui, "Delete", Value::str("delete"));
});
},
);
}
ui.close();
}
fn on_event(&mut self, ev: UiEvent) {
let payload = &ev.payload;
match payload.as_str() {
Some("press") => {
self.presses += 1;
println!("press -> {}", self.presses);
return;
}
Some("save") => {
// Changes the live region's text; nothing announces.
self.saves += 1;
println!("save -> {}", self.saves);
return;
}
Some("copy") => {
// Changes nothing on screen, so the announcement is the
// only thing a reader gets.
self.pending = Some("Copied to clipboard".to_string());
println!("copy");
return;
}
// Opening the dialog is a field the view reads; closing it is
// the same field. The core moves focus into it and hands
// focus back to this button when it goes away.
Some("open-confirm") => {
self.dialog = true;
return;
}
Some("open-menu") => {
self.menu = true;
return;
}
Some("cancel") => {
self.dialog = false;
println!("cancelled");
return;
}
Some("delete") => {
self.dialog = false;
println!("deleted");
return;
}
Some("advanced") => {
self.advanced = !self.advanced;
println!("advanced -> {}", self.advanced);
return;
}
Some("mute") => {
self.muted = !self.muted;
println!("mute -> {}", self.muted);
return;
}
_ => {}
}
// A menu item runs and the menu goes away — the app closes it,
// as it opened it. Prefixed like the rows, for the same reason.
if let Some(item) = payload.as_str().and_then(|s| s.strip_prefix("menu:")) {
self.menu = false;
println!("menu -> {item}");
return;
}
// The radios. Arrow keys reach here too, unchanged: moving focus
// inside a radio group emits the radio's own click payload, which
// is the whole of what decision 11 buys an app.
if let Some(i) = payload
.as_str()
.and_then(|s| s.strip_prefix("theme"))
.and_then(|s| s.parse::<usize>().ok())
{
self.theme = i;
println!("theme -> {}", self.theme);
return;
}
// The rows carry their ordinal in a tagged string, so they do not
// collide with the tabs' plain indices.
if let Some(i) = payload
.as_str()
.and_then(|s| s.strip_prefix("row"))
.and_then(|s| s.parse::<usize>().ok())
{
self.row = i;
println!("row -> {}", self.row);
return;
}
// The tabs carry their index as the payload.
if let Some(i) = payload.as_int() {
self.tab = i as usize;
println!("tab -> {}", self.tab);
return;
}
// Escape, or a click outside the dialog: the core asks, the app
// decides. A dialog holding unsaved work could ask again here.
if payload.get_str("kind") == Some("dismiss") {
let reason = payload.get_str("reason").unwrap_or("");
// Two modals now, so the tag says which one asked to go.
let which = payload.get_str("tag").unwrap_or("");
println!("dismiss {which} ({reason})");
match which {
"menu" => self.menu = false,
_ => self.dialog = false,
}
return;
}
// The sliders: the core proposes a value — stepped, clamped and
// snapped already — and which slider moved is its own tag. The
// reading the next frame declares is what a reader announces.
if payload.get_str("kind") == Some("change") {
let value = payload.get_float("value").unwrap_or(0.0) as f32;
match payload.get_str("tag") {
Some("focus") => {
self.focus_min = value;
println!("focus length -> {} minutes", self.focus_min as i32);
}
_ => {
self.volume = value;
println!("volume -> {}", self.volume);
}
}
return;
}
if payload.get_str("kind") != Some("access") {
return;
}
let action = payload.get_str("action").unwrap_or("");
match action {
// The app-owned editor: line ordinals among the rows it drew
// (all of them here), byte offsets into their text.
"setTextSelection" => {
if let Some(f) = payload.get("focus") {
let line = f.get_int("line").unwrap_or(0) as usize;
let offset = f.get_int("offset").unwrap_or(0) as usize;
self.caret = (line, offset);
println!("caret -> {line}:{offset}");
}
}
"replaceSelectedText" | "setValue" => {
let text = payload.get_str("text").unwrap_or("");
if action == "setValue" {
self.lines = text.split('\n').map(String::from).collect();
self.caret = (0, 0);
} else {
let line = self.caret_line();
let at = self.caret.1.min(self.lines[line].len());
self.lines[line].insert_str(at, text);
self.caret = (line, at + text.len());
}
println!("text -> {:?}", self.lines);
}
_ => {}
}
}
}
impl Example for A11y {
/// Tall enough for most of the controls; the rest are a scroll away,
/// because the column holding them scrolls (see `view`). Custom
/// chrome, so the drawn titlebar the audit checks is there.
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default()
.size(560.0, 820.0)
.custom_titlebar()
}
/// The fixture is the window the audit walks, and nothing else: no
/// dock, no sink of the harness's, no focus it took. `--dock side`
/// puts the readout beside it for a look by hand.
fn dock(&self) -> kui_devtools::Dock {
kui_devtools::Dock::Off
}
}
kui_devtools::main!(A11y::new());
features/align.rs
//! Where the free space goes and what lines up (backlog C13), and a box
//! that keeps its shape (C14). Three panels:
//!
//! - **mainAlign**: a track of four chips of different widths, laid out
//! by whichever alignment the buttons above it pick — the three that
//! put the chips together, and the three spreads that deal the free
//! space out between them (`space-between`, `-around`, `-evenly`).
//! - **crossAlign: baseline**: a reading and its unit in three sizes,
//! once aligned at the top and once on their baselines, where the
//! small label and unit sit on the line the large number stands on.
//! - **aspectRatio**: a `grow`-wide card at 16:9 that keeps its shape as
//! the window is resized, and a row of squares sized from a fixed
//! height alone.
//!
//! Run: cargo run -p kui-native --example align [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{Align, App, Color, Core, NodeSpec, TextStyle, Ui, UiEvent, Value};
/// The alignments the buttons pick, in `schema::ALIGNS` order; `baseline`
/// is a cross-axis value and has no button here.
const MAIN: [Align; 6] = [
Align::Start,
Align::Center,
Align::End,
Align::SpaceBetween,
Align::SpaceAround,
Align::SpaceEvenly,
];
const CHIPS: [f32; 4] = [48.0, 72.0, 36.0, 60.0];
struct Page {
main: Align,
}
impl Page {
fn main_panel(&self, ui: &mut Ui<'_>) {
let t = ui.theme();
let m = ui.metrics();
ui.text("mainAlign", TextStyle::new(15.0).color(t.fg));
ui.with(NodeSpec::row().gap(6.0).wrap().cross_gap(6.0), |ui| {
for a in MAIN {
let spec = widgets::button_spec(&t, &m).on_click(Value::str(a.name()));
let spec = if a == self.main {
spec.accent()
} else {
spec.bg(t.surface).hover_bg(t.hover).pressed_bg(t.pressed)
};
widgets::button_with(ui, a.name(), a.name(), spec, None);
}
});
ui.with_keyed(
"track",
NodeSpec::row()
.grow_width()
.pad(8.0)
.gap(4.0)
.radius(m.radius)
.bg(t.sunken)
.main_align(self.main),
|ui| {
for (i, w) in CHIPS.into_iter().enumerate() {
ui.leaf_keyed(
&format!("chip{i}"),
NodeSpec::column()
.size(w, 24.0)
.radius(m.radius_inner)
.bg(t.accent),
);
}
},
);
}
fn baseline_panel(&self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.text(
"crossAlign: start, then baseline",
TextStyle::new(15.0).color(t.fg),
);
for (label, cross) in [("top", Align::Start), ("base", Align::Baseline)] {
ui.with_keyed(
label,
NodeSpec::row()
.gap(6.0)
.pad(8.0)
.bg(t.surface)
.cross_align(cross),
|ui| {
for (part, text, size) in [
("label", "Frame time", 13.0),
("value", "8.3", 36.0),
("unit", "ms", 13.0),
] {
ui.with_keyed(part, NodeSpec::column(), |ui| {
let colour = if part == "value" { t.fg } else { t.muted };
ui.text(text, TextStyle::new(size).color(colour));
});
}
},
);
}
}
fn aspect_panel(&self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.text("aspectRatio", TextStyle::new(15.0).color(t.fg));
ui.with(NodeSpec::row().grow_width().gap(12.0), |ui| {
ui.text_in_keyed(
"video",
NodeSpec::column()
.grow_width()
.max_width(360.0)
.aspect_ratio(16.0 / 9.0)
.radius(ui.metrics().radius)
.bg(t.raised)
.center(),
"16 : 9",
TextStyle::new(14.0).color(t.muted),
);
ui.with(NodeSpec::row().gap(6.0), |ui| {
for (i, c) in [0x3b5bd4ffu32, 0x73d98cff, 0xffcc00ff]
.into_iter()
.enumerate()
{
ui.leaf_keyed(
&format!("square{i}"),
NodeSpec::column()
.height(40.0)
.aspect_ratio(1.0)
.bg(Color::hex(c)),
);
}
});
});
}
}
impl App for Page {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(24.0)
.gap(14.0)
.scroll_y()
.bg(t.bg),
|ui| {
self.main_panel(ui);
self.baseline_panel(ui);
self.aspect_panel(ui);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
if let Some(name) = ev.payload.as_str()
&& let Some(a) = MAIN.into_iter().find(|a| a.name() == name)
{
self.main = a;
}
}
}
impl Example for Page {
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
core.set_inspect(true);
let mut d = Drive::new(core, 800.0, 600.0);
let rect = |d: &mut Drive<'_>, label: &str| {
let key = d.key_of(label).ok_or(format!("no {label}"))?;
d.core
.nodes()
.into_iter()
.find(|n| n.key == key)
.map(|n| n.rect)
.ok_or(format!("{label} is not in the snapshot"))
};
d.frame(self);
// mainAlign: spaceBetween puts the first chip at the track's
// padding and the last against its far padding.
let between = d.key_of("spaceBetween").ok_or("no spaceBetween button")?;
d.click_key(self, between);
d.frame(self);
let track = rect(&mut d, "track")?;
let (first, last) = (rect(&mut d, "chip0")?, rect(&mut d, "chip3")?);
d.check(
(first.x - (track.x + 8.0)).abs() < 0.5,
"the first chip starts the track",
)?;
d.check(
(last.x + last.w - (track.x + track.w - 8.0)).abs() < 0.5,
"and the last ends it",
)?;
let evenly = d.key_of("spaceEvenly").ok_or("no spaceEvenly button")?;
d.click_key(self, evenly);
d.frame(self);
let (first, last) = (rect(&mut d, "chip0")?, rect(&mut d, "chip3")?);
let track = rect(&mut d, "track")?;
let lead = first.x - (track.x + 8.0);
let tail = track.x + track.w - 8.0 - (last.x + last.w);
d.check(
lead > 1.0 && (lead - tail).abs() < 0.5,
"spaceEvenly leaves equal ends",
)?;
// crossAlign: at the top the three tops meet; on the baseline the
// small label drops below the large value's top.
let tops: Vec<f32> = d
.core
.nodes()
.into_iter()
.filter(|n| n.label.as_deref() == Some("label") || n.label.as_deref() == Some("value"))
.map(|n| n.rect.y)
.collect();
d.check(tops.len() == 4, "two rows of a label and a value")?;
d.check(
(tops[0] - tops[1]).abs() < 0.5,
"aligned at the top, the tops meet",
)?;
d.check(
tops[2] > tops[3] + 4.0,
"on the baseline, the small label sits lower",
)?;
// aspectRatio: the card is 16:9 whatever width it grew to, and a
// square is as wide as its fixed height.
let video = rect(&mut d, "video")?;
d.check(
(video.w / video.h - 16.0 / 9.0).abs() < 0.01,
"the card keeps 16:9",
)?;
let sq = rect(&mut d, "square0")?;
d.check(
(sq.w - 40.0).abs() < 0.01 && (sq.h - 40.0).abs() < 0.01,
"a 40 px square",
)?;
let warned = d.core.take_warnings();
d.check(warned.is_empty(), "nothing warned")?;
Ok(())
}
}
kui_devtools::main!(Page { main: Align::Start });
features/audio.rs
//! Sound as data. Three ways a view asks for one, and none of them is a
//! call: a button declares a `click_sound` and the core plays it on the
//! click; a badge declares a `hover_sound` for the pointer's arrival; and a
//! loop is an `audio` node the view keeps declaring while it is on and
//! stops declaring to stop — the same way a toast exists while the model
//! holds it. The sounds are synthesized here (`kui_native::audio::blip`,
//! `wav_pcm16`) and registered once as resources, since a resource is
//! long-lived core state and not per-frame data.
//!
//! The dock's `audio` row is the driver's side of the same story: the
//! device opens off-thread the first time a session holds a sound, stays
//! open while anything is live, and is let go after a while idle — an open
//! output stream is a real-time thread whether or not anything plays, so
//! `open · 0 live` ten seconds after the last click would be a bug
//! (`env.audio`, ADR 0021).
//!
//! Run: cargo run -p kui-native --example audio [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::audio::{blip, wav_pcm16};
use kui_native::widgets;
use kui_native::{Align, App, AudioSpec, Core, NodeSpec, SoundId, TextStyle, Ui, UiEvent, Value};
#[derive(Default)]
struct Audio {
clicks: u32,
hovers: u32,
hum: bool,
/// Registered on the first frame (synthesized, so no asset files).
sounds: Option<Sounds>,
}
#[derive(Clone, Copy)]
struct Sounds {
click: SoundId,
tick: SoundId,
hum: SoundId,
}
/// A button with a click sound: the stock button spec plus one prop.
///
/// `name` is the node's key and `label` is what it says, and they are two
/// arguments because for one of these buttons they are two things: `hum`
/// reads `hum: off` and then `hum: on`. Keyed by its label it would be a
/// *different node* the frame after it is pressed — and everything the
/// core keeps per node is keyed too, so the focus would be left on a key
/// nothing declares any more and the ring would vanish under the press
/// that caused it. Hover and any tween would go the same way. The key is
/// what the button *is*; the label is a view of the state it toggles.
fn sound_button(ui: &mut Ui<'_>, name: &str, label: &str, payload: Value, sound: SoundId) {
let spec = widgets::button_spec(&ui.theme(), &ui.metrics())
.on_click(payload)
.click_sound(sound);
// Readable on whatever this button's background *is*, which is the
// rule `widgets::button_with` applies to the stock one.
let fg = widgets::readable_on(spec.style.bg);
ui.text_in_keyed(
name,
spec,
label,
TextStyle::new(widgets::BUTTON_TEXT).color(fg),
);
}
impl App for Audio {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
let sounds = *self.sounds.get_or_insert_with(|| {
let core = ui.core();
// A 200-sample period at 44.1kHz loops seamlessly.
let hum: Vec<f32> = (0..2000)
.map(|i| (i as f32 / 200.0 * std::f32::consts::TAU).sin() * 0.25)
.collect();
Sounds {
click: core.add_sound(blip(44_100, 880.0, 60.0, 0.4)),
tick: core.add_sound(blip(44_100, 1760.0, 25.0, 0.2)),
hum: core.add_sound(wav_pcm16(44_100, &hum)),
}
});
ui.with(NodeSpec::column().fill().center().gap(24.0), |ui| {
// The loop: declared while on, and that is the whole of
// "playing". Stop declaring it and the core stops it.
if self.hum {
ui.audio_keyed("hum", AudioSpec::new(sounds.hum).looped().volume(0.3));
}
ui.with(
NodeSpec::column()
.pad(32.0)
.gap(20.0)
.bg(t.surface)
.radius(12.0)
.border(1.0, t.border)
.cross_align(Align::Center),
|ui| {
ui.text("sound is data", TextStyle::new(14.0).color(t.muted));
ui.text(
&format!("{} clicks · {} hovers", self.clicks, self.hovers),
TextStyle::new(24.0),
);
ui.with(NodeSpec::row().gap(12.0).cross_align(Align::Center), |ui| {
sound_button(
ui,
"blip",
"blip",
Value::map([("kind", "click".into())]),
sounds.click,
);
sound_button(
ui,
"hum",
if self.hum { "hum: on" } else { "hum: off" },
Value::map([("kind", "hum".into())]),
sounds.click,
);
// The hover sound: a `hoverable` badge with a
// `hover_sound` — the tick plays when the pointer
// arrives, and `on_hover` reports the same arrival
// as data so the count above can follow it.
ui.text_in_keyed(
"badge",
NodeSpec::column()
.pad_xy(12.0, 6.0)
.bg(t.raised)
.radius(10.0)
.hoverable()
.hover_sound(sounds.tick)
.on_hover(Value::map([("kind", "hover".into())])),
"hover me",
TextStyle::new(13.0),
);
});
},
);
ui.text(
"click_sound · hover_sound · an audio node declared while on",
TextStyle::new(12.0).color(t.faint),
);
});
}
fn on_event(&mut self, ev: UiEvent) {
match ev.kind() {
Some("click") => self.clicks += 1,
Some("hum") => self.hum = !self.hum,
Some("hover") if ev.payload.get_str("phase") == Some("enter") => self.hovers += 1,
_ => {}
}
}
}
impl Example for Audio {
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(520.0, 360.0)
}
/// A headless core has no device, so what this checks is the data:
/// the commands a click and a loop queue for a driver to apply.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 520.0, 360.0);
d.frame(self);
let blip = d.key_of("blip").ok_or("no blip button")?;
let hum = d.key_of("hum").ok_or("no hum button")?;
let _ = d.core.take_audio_commands();
d.click_key(self, blip);
let cmds = d.core.take_audio_commands();
d.check(self.clicks == 1, "the click counts")?;
d.check(!cmds.is_empty(), "and queues a play for the driver")?;
d.click_key(self, hum);
d.frame(self);
let cmds = d.core.take_audio_commands();
d.check(
self.hum && !cmds.is_empty(),
"declaring the loop queues its play",
)?;
d.click_key(self, hum);
d.frame(self);
let cmds = d.core.take_audio_commands();
d.check(
!self.hum && !cmds.is_empty(),
"and undeclaring it queues the stop",
)?;
// The hover: the pointer arriving over the badge is `on_hover`'s
// `enter`, and the tick is queued beside it.
let badge = d.key_of("badge").ok_or("no badge")?;
d.hover(self, badge);
let cmds = d.core.take_audio_commands();
d.check(
self.hovers == 1,
"the pointer's arrival is reported as data",
)?;
d.check(!cmds.is_empty(), "and the hover sound is queued")
}
}
kui_devtools::main!(Audio::default());
features/clipboard.rs
//! The clipboard: every way onto it, and the two ways a paste lands. The
//! clipboard is the host's — the core never reads it — so a copy is the
//! core, or the app, working out *what* and handing the host the text,
//! and a paste is the host reading the clipboard and handing the text
//! back as input. Four things to copy from, each a different rule:
//!
//! * **The field.** An `edit`: Cmd/Ctrl-C/X/V are the runner's own —
//! it performs the chords while an editor has focus and writes the
//! clipboard itself, no queue — and its context menu's Cut / Copy /
//! Paste are the same three as `MenuAction`s the host drains.
//! * **The card.** A `selectable` scope: the same chord copies its runs
//! in reading order, the bold carried beside the plain text as HTML;
//! its menu's Copy queues the same.
//! * **The log.** A selectable virtual list: a copy whose selection
//! reaches rows the frame never built cannot be answered by the core,
//! so it is a `selectionrange` ask to the app — the rows are the
//! app's — and `Ui::answer_selection_range` is what reaches the
//! clipboard (ADR 0017, tier 3).
//! * **The register.** An `on_key` sink that owns its lines: the runner
//! leaves the chords to it, and its handler binds `y` to
//! `Core::set_clipboard` and `p` to `Core::request_paste` on the core
//! `on_event_with` lends it (ADR 0036; backlog C33). The paste comes
//! back as the `{kind="text"}` event an IME's commit arrives on,
//! marked `pasted` (DX14), and the sink appends it as a line.
//!
//! And two things every selection does on the way to a copy (ADR 0029):
//! a Shift-click extends it from its anchor instead of starting over, and
//! a drag held past the log's edge scrolls the log toward the pointer —
//! the wheel under a held press moves the live end too.
//!
//! Every path but the first ends in one queue — `MenuAction::SetClipboard`
//! and `MenuAction::Paste` — which the runner drains after every input and
//! every frame, and a headless drive reads with `take_menu_actions`. The
//! readout at the bottom is the app's side of it: what it last handed
//! over, and what last came back.
//!
//! Run: cargo run -p kui-native --example clipboard [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{
Align, App, Core, EditKey, EditOptions, MenuAction, Mods, NodeSpec, Role, Span, TextStyle,
Theme, Ui, UiEvent, Value,
};
const ROWS: usize = 2000;
const ROW_H: f32 = 22.0;
struct Clipboard {
/// The register's lines and its highlighted one.
lines: Vec<String>,
cursor: usize,
/// The readout.
sent: String,
received: String,
}
impl Clipboard {
fn new() -> Self {
Self {
lines: vec![
"j / k move, y copies the line, p pastes a new one".into(),
"the sink hears Cmd-C raw and binds nothing to it".into(),
"so the clipboard is two calls on Ui".into(),
],
cursor: 0,
sent: "nothing yet".into(),
received: "nothing yet".into(),
}
}
/// The log's row `i`, the app's own data — what a `selectionrange`
/// ask is answered from.
fn row(i: usize) -> String {
format!("{i:>4} log line {i}")
}
/// The text of the rows an ask named: `from.index`..=`to.index`,
/// joined by newlines, cut to the bytes at each end.
fn range_text(p: &Value) -> Option<String> {
let end = |name: &str| {
let e = p.get(name)?;
Some((
e.get("index")?.as_int()? as usize,
e.get("byte")?.as_int()? as usize,
))
};
let (from, to) = (end("from")?, end("to")?);
let mut text: String = (from.0..=to.0.min(ROWS - 1))
.map(Self::row)
.collect::<Vec<_>>()
.join("\n");
// The last row is cut at `to.byte`, then the first at `from.byte`.
if to.0 < ROWS {
let last_start = text.len() - Self::row(to.0).len();
text.truncate(last_start + to.1.min(Self::row(to.0).len()));
}
Some(text[from.1.min(text.len())..].to_string())
}
}
fn card(t: &Theme) -> NodeSpec {
NodeSpec::column()
.grow_width()
.max_width(600.0)
.pad(14.0)
.gap(8.0)
.bg(t.surface)
.radius(10.0)
.border(1.0, t.border)
}
fn caption(ui: &mut Ui<'_>, t: &Theme, s: &str) {
ui.text(s, TextStyle::new(12.0).color(t.muted));
}
impl App for Clipboard {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.cross_align(Align::Center)
.pad(20.0)
.gap(10.0)
.scroll_y(),
|ui| {
// The field: the runner's chords, and the stock menu.
ui.with(card(&t), |ui| {
caption(
ui,
&t,
"an editor: ⌘C ⌘X ⌘V are the runner's, its menu queues",
);
ui.text_edit(
"note",
"Select some of this and copy it; paste lands as typing.",
&EditOptions::default(),
NodeSpec::column()
.grow_width()
.pad(8.0)
.radius(6.0)
.bg(t.bg)
.border(1.0, t.border)
.label("note"),
);
});
// The card: a scope's runs, with the formatting beside.
ui.with_keyed("article", card(&t).selectable(), |ui| {
caption(
ui,
&t,
"a selectable scope: ⌘C copies the runs, the bold as HTML beside",
);
ui.rich_text(
&[
Span::new("Drag across this and the "),
Span::new("bold").bold(),
Span::new(" comes along as a second flavour the host may offer."),
],
TextStyle::new(14.0).line_height(22.0),
);
});
// The log: a copy over rows the frame never built is
// asked of the app.
ui.with(card(&t).pad(8.0), |ui| {
caption(
ui,
&t,
"a virtual list: select, scroll away, ⌘C — the app answers",
);
widgets::uniform_list(
ui,
"log",
NodeSpec::column()
.grow_width()
.height(4.0 * ROW_H)
.bg(t.sunken)
.radius(6.0)
.selectable()
.role(Role::List)
.label("log"),
ROWS,
ROW_H,
|ui, i| {
ui.text_in(
NodeSpec::row()
.fill()
.pad_xy(8.0, 0.0)
.cross_align(Align::Center),
&Self::row(i),
TextStyle::new(12.0).mono().color(t.fg),
);
},
);
});
// The register: a sink with its own bindings.
let cursor = self.cursor;
let lines = &self.lines;
let sink = ui.with_keyed(
"register",
card(&t)
.key_sink()
.focusable()
.role(Role::Group)
.label("register"),
|ui| {
caption(
ui,
&t,
"an on_key sink: y → set_clipboard, p → request_paste",
);
for (i, line) in lines.iter().enumerate() {
ui.text_in(
NodeSpec::row()
.grow_width()
.pad_xy(8.0, 3.0)
.radius(4.0)
.bg(if i == cursor { t.selection } else { t.surface }),
line,
TextStyle::new(13.0).mono().color(t.fg),
);
}
},
);
// Edge-triggered: focus lands once, when the sink starts
// being declared, and a click in the field moves it away.
ui.take_key_focus(sink);
// The readout: the app's side of the queue.
ui.with(
NodeSpec::column().grow_width().max_width(600.0).gap(2.0),
|ui| {
ui.text(
&format!("→ clipboard: {}", self.sent),
TextStyle::new(12.0).color(t.accent),
);
ui.text(
&format!("← paste: {}", self.received),
TextStyle::new(12.0).color(t.accent),
);
},
);
},
);
}
// The app's half of the queue — what the keymap chose, the paste it
// asks for, the answer the log owes — done in answer to the event, on
// the core of the window it came from (ADR 0036).
fn on_event_with(&mut self, ev: UiEvent, core: &mut Core) {
if let Some((_, k)) = ev.key_press() {
match k.code.name().as_str() {
"j" | "down" => self.cursor = (self.cursor + 1).min(self.lines.len() - 1),
"k" | "up" => self.cursor = self.cursor.saturating_sub(1),
"y" => {
let line = self.lines[self.cursor].clone();
self.sent = format!("the register's line: {line:?}");
core.set_clipboard(line, None);
}
// One ask at a time: the core drops a second while this one
// is out (backlog AR34).
"p" => core.request_paste(),
_ => {}
}
return;
}
// The paste `p` asked for, and not an IME's commit, which this
// sink has no use for (DX14).
if let Some(t) = ev.text()
&& t.pasted
{
self.received = format!("{:?}", t.text);
self.lines.push(t.text.to_string());
self.cursor = self.lines.len() - 1;
return;
}
// The log's copy reached rows no frame built: the app knows its
// own rows, and answers from them now.
if ev.kind() == Some("selectionrange")
&& let Some(text) = Self::range_text(&ev.payload)
{
self.sent = format!("the log's rows: {} bytes", text.len());
core.answer_selection_range(&text);
}
}
}
impl Example for Clipboard {
const KEYS: &'static [(&'static str, &'static str)] = &[
("⌘C ⌘X ⌘V", "in the field and the card"),
("⇧-click", "extend a selection"),
("drag past the edge", "scroll the log"),
("right-click", "the stock menu's Copy / Paste"),
("j k y p", "in the register"),
];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(640.0, 640.0)
}
/// Each path onto the queue, and what it leaves there.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
use kui_native::{CopyRequest, InputEvent, KeyMods};
let mut d = Drive::new(core, 640.0, 640.0);
d.core.set_native_menus(false);
d.frame(self);
let note = d.key_of("note").ok_or("no field")?;
let article = d.key_of("article").ok_or("no card")?;
let log = d.key_of("log").ok_or("no log")?;
let register = d.key_of("register").ok_or("no register")?;
// The field: the chord's copy never enters the queue — the core
// answers `request_copy` at once and the runner writes the
// clipboard itself; a paste is typing.
d.core.set_focus(Some(note));
d.input(self, InputEvent::Key(EditKey::SelectAll, Mods::default()));
let ready = matches!(d.core.request_copy(), CopyRequest::Ready(t) if t.contains("copy it"));
d.check(
ready,
"the field's copy is answered at once, from the editor's selection",
)?;
let queued = d.core.take_menu_actions();
d.check(
queued.is_empty(),
"and nothing is queued: the runner writes the clipboard itself",
)?;
d.input(self, InputEvent::Text("pasted".into()));
d.check(
d.core.edit_text(note).as_deref() == Some("pasted"),
"a paste into the field is typing over the selection",
)?;
// The card: a drag across the scope selects its runs as one, the
// copy is answered at once, with the flavour, and its menu's Copy
// queues both for the host.
let r = d.rect_of(article).ok_or("the card has no rect")?;
d.input(
self,
InputEvent::CursorMoved(kui_native::Vec2::new(r.x + 30.0, r.y + 40.0)),
);
d.input(self, InputEvent::mouse_down(1));
d.input(
self,
InputEvent::CursorMoved(kui_native::Vec2::new(r.x + 300.0, r.y + 44.0)),
);
d.input(self, InputEvent::mouse_up());
d.check(
d.core.selection_text().is_some_and(|s| s.contains("bold")),
"a drag across the card selects its runs as one",
)?;
d.check(
d.core.selection_html().is_some_and(|h| h.contains("<b>")),
"and the copy carries the bold as HTML",
)?;
d.input(
self,
InputEvent::CursorMoved(kui_native::Vec2::new(r.x + 30.0, r.y + 40.0)),
);
d.input(
self,
InputEvent::MouseDown {
button: kui_native::MouseButton::Secondary,
clicks: 1,
},
);
d.input(
self,
InputEvent::MouseUp {
button: kui_native::MouseButton::Secondary,
},
);
let menu = d.core.menu().cloned().ok_or("no menu over the card")?;
let copy = menu
.items
.iter()
.position(|i| i.role == kui_native::MenuRole::Copy)
.ok_or("no Copy row")?;
d.core.activate_menu_item(copy).ok_or("Copy refused")?;
let queued = d.core.take_menu_actions();
d.check(
matches!(&queued[..], [MenuAction::SetClipboard { text, html: Some(h) }] if text.contains("bold") && h.contains("<b>")),
"the menu's Copy queues the text and the HTML for the host",
)?;
// The log: select across rows, scroll them out of the frame, and
// the copy is a question for the app.
let r = d.rect_of(log).ok_or("the log has no rect")?;
d.input(
self,
InputEvent::CursorMoved(kui_native::Vec2::new(r.x + 20.0, r.y + 6.0)),
);
d.input(self, InputEvent::mouse_down(1));
d.input(
self,
InputEvent::CursorMoved(kui_native::Vec2::new(r.x + 200.0, r.y + 2.5 * ROW_H)),
);
d.input(self, InputEvent::mouse_up());
d.frame(self);
// Held past the log's bottom edge, the log scrolls toward the
// pointer a frame at a time and the live end follows (ADR 0029):
// half a second 60 px past is 600 px/s, so rows 0..2 became rows
// 0..~13. The wheel under the held press moves it too.
d.input(
self,
InputEvent::CursorMoved(kui_native::Vec2::new(r.x + 20.0, r.y + 6.0)),
);
d.input(self, InputEvent::mouse_down(1));
d.input(
self,
InputEvent::CursorMoved(kui_native::Vec2::new(r.x + 200.0, r.y + r.h + 60.0)),
);
for _ in 0..30 {
d.advance(1.0 / 60.0);
d.frame(self);
}
let scrolled = d.core.scroll_offset(log).y;
d.check(
scrolled > 10.0 * ROW_H,
"a press held past the edge scrolls the log toward the pointer",
)?;
d.check(
d.core.selection().is_some_and(|s| s.focus.row >= Some(10)),
"and the live end followed onto the rows that scrolled in",
)?;
d.input(
self,
InputEvent::CursorMoved(kui_native::Vec2::new(r.x + 200.0, r.y + 2.5 * ROW_H)),
);
d.wheel(self, r.x + 200.0, r.y + 2.5 * ROW_H, 0.0, -20.0 * ROW_H);
d.frame(self);
d.frame(self);
d.check(
d.core.selection().is_some_and(|s| s.focus.row >= Some(30)),
"the wheel under a held press moves the live end with the rows",
)?;
d.input(self, InputEvent::mouse_up());
d.frame(self);
// A Shift-click keeps the anchor: the selection still starts on
// row 0 and now ends where the click landed.
d.input(self, InputEvent::Modifiers(KeyMods::NONE.with_shift()));
d.input(
self,
InputEvent::CursorMoved(kui_native::Vec2::new(r.x + 100.0, r.y + 1.5 * ROW_H)),
);
d.input(self, InputEvent::mouse_down(1));
d.input(self, InputEvent::mouse_up());
d.input(self, InputEvent::Modifiers(KeyMods::default()));
d.frame(self);
let sel = d
.core
.selection()
.ok_or("the Shift-click lost the selection")?;
d.check(
sel.anchor.row == Some(0) && sel.focus.row.is_some_and(|f| f > 25),
"a Shift-click extends from the anchor instead of starting over",
)?;
// Back to the top for the copy below: a plain drag over rows 0..2.
d.core.set_scroll(log, kui_native::Vec2::ZERO);
d.frame(self);
d.input(
self,
InputEvent::CursorMoved(kui_native::Vec2::new(r.x + 20.0, r.y + 6.0)),
);
d.input(self, InputEvent::mouse_down(1));
d.input(
self,
InputEvent::CursorMoved(kui_native::Vec2::new(r.x + 200.0, r.y + 2.5 * ROW_H)),
);
d.input(self, InputEvent::mouse_up());
d.frame(self);
d.wheel(self, r.x + 100.0, r.y + 40.0, 0.0, -40.0 * ROW_H);
d.frame(self);
let asked = d.core.request_copy() == CopyRequest::Asked;
d.check(
asked,
"a copy over rows the frame never built is asked of the app",
)?;
d.frame(self); // the ask reaches the handler, which answers it
d.check(
self.sent.starts_with("the log's rows"),
"as a `selectionrange` the log answers from its rows",
)?;
let queued = d.core.take_menu_actions();
// The press landed a few bytes into row 0, so the answer starts
// mid-row; the drag ended on row 2.
let forwards = match &queued[..] {
[MenuAction::SetClipboard { text, html: None }]
if text.contains("log line 0\n") && text.contains("log line 2") =>
{
text.clone()
}
_ => String::new(),
};
d.check(
!forwards.is_empty(),
"and the answer is what reaches the clipboard",
)?;
// The same drag made backwards — pressed on row 2, released on
// row 0 — is asked for as the same range: `from` precedes `to`
// whichever end the press was, so the app's `from..=to` answers
// the same rows.
d.core.set_scroll(log, kui_native::Vec2::ZERO);
d.frame(self);
d.input(
self,
InputEvent::CursorMoved(kui_native::Vec2::new(r.x + 200.0, r.y + 2.5 * ROW_H)),
);
d.input(self, InputEvent::mouse_down(1));
d.input(
self,
InputEvent::CursorMoved(kui_native::Vec2::new(r.x + 20.0, r.y + 6.0)),
);
d.input(self, InputEvent::mouse_up());
d.frame(self);
d.wheel(self, r.x + 100.0, r.y + 40.0, 0.0, -40.0 * ROW_H);
d.frame(self);
let asked = d.core.request_copy() == CopyRequest::Asked;
d.check(
asked,
"a backwards drag over unbuilt rows is asked the same way",
)?;
d.frame(self);
d.frame(self);
let queued = d.core.take_menu_actions();
d.check(
matches!(&queued[..], [MenuAction::SetClipboard { text, html: None }] if *text == forwards),
"and asks for the same rows in reading order, so the answer is the same text",
)?;
// The register: the sink's own bindings, through `Ui`.
d.core.set_focus(Some(register));
d.key(self, "j", KeyMods::default());
d.key(self, "y", KeyMods::default());
d.frame(self);
let queued = d.core.take_menu_actions();
d.check(
matches!(&queued[..], [MenuAction::SetClipboard { text, .. }] if text.starts_with("the sink hears")),
"y hands the sink's line to the host through set_clipboard",
)?;
d.key(self, "p", KeyMods::default());
d.frame(self);
let queued = d.core.take_menu_actions();
d.check(
queued == vec![MenuAction::Paste],
"p asks the host for the clipboard",
)?;
// The host reads it and commits; the sink hears the text.
d.input(self, InputEvent::Commit("from another app".into()));
d.check(
self.lines.last().map(String::as_str) == Some("from another app"),
"and the paste comes back as the sink's text event",
)?;
d.check(!d.core.awaiting_paste(), "once")
}
}
kui_devtools::main!(Clipboard::new());
features/devtools_tab.rs
//! A tab of the app's own in the core's devtools panel
//! (`docs/adr/0032-a-devtools-tab-mounts-a-slot.md`), in the shape a
//! tree-sitter inspector has. The page is a "source file" whose tokens
//! are coloured by **highlight group** (`@keyword`, `@function`, …) from
//! a small syntax tree this file writes by hand; the panel gains an
//! **Inspector** tab beside facts, events and tree, drawn by this app from
//! its own view with `Ui::devtools_tab_with`, listing that tree. Hover
//! goes both ways: a row of the tab lights the node's tokens in the
//! source, and a token in the source lights its path in the tab — both
//! are the app's own nodes, so both are the app's own `on_hover`. A click
//! on a row selects the token in the panel's tree tab
//! (`set_devtools_selected`), and the tab can raise the panel's picker
//! (`set_devtools_pick`): the pick lands in `devtools_selected`, which the
//! tab reads back as the node it names, with the tab still up. The page
//! has a button of its own that jumps to the tab (`set_devtools_tab`) —
//! an editor's `:syntax_tree` command — and prints which tab the panel
//! is on (`devtools_current_tab`).
//!
//! The closure runs only while the tab is on show — `built` counts the
//! runs, and the page prints it — so a tab nobody looks at costs the
//! declaration and nothing else. Walk to it with Ctrl+Shift+N (facts →
//! events → tree → Inspector), or click it in the strip.
//!
//! Run: cargo run -p kui-native --example devtools_tab [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{Align, App, Color, Core, Key, NodeSpec, TextStyle, Theme, Ui, UiEvent, Value};
/// One node of the syntax tree: its kind, the highlight group a token
/// carries (`None` for an inner node), the line it starts on, its
/// parent, and — for a token — the text.
struct Node {
kind: &'static str,
group: Option<&'static str>,
line: usize,
parent: Option<usize>,
text: Option<&'static str>,
}
const fn tok(
kind: &'static str,
group: &'static str,
line: usize,
parent: usize,
text: &'static str,
) -> Node {
Node {
kind,
group: Some(group),
line,
parent: Some(parent),
text: Some(text),
}
}
const fn inner(kind: &'static str, line: usize, parent: usize) -> Node {
Node {
kind,
group: None,
line,
parent: Some(parent),
text: None,
}
}
/// The tree, in preorder, over the six lines below — what a parser would
/// have said about them. Tokens appear in source order within a line.
#[rustfmt::skip]
const TREE: &[Node] = &[
Node { kind: "source_file", group: None, line: 0, parent: None, text: None }, // 0
inner("function_item", 0, 0), // 1
tok("fn", "keyword", 0, 1, "fn"), // 2
tok("identifier", "function", 0, 1, "main"), // 3
inner("parameters", 0, 1), // 4
tok("(", "punctuation", 0, 4, "("), // 5
tok(")", "punctuation", 0, 4, ")"), // 6
inner("block", 0, 1), // 7
tok("{", "punctuation", 0, 7, "{"), // 8
inner("let_declaration", 1, 7), // 9
tok("let", "keyword", 1, 9, "let"), // 10
tok("identifier", "variable", 1, 9, "tree"), // 11
tok("=", "operator", 1, 9, "="), // 12
inner("call_expression", 1, 9), // 13
tok("identifier", "function", 1, 13, "parse"), // 14
inner("arguments", 1, 13), // 15
tok("(", "punctuation", 1, 15, "("), // 16
tok("identifier", "variable", 1, 15, "source"), // 17
tok(")", "punctuation", 1, 15, ")"), // 18
tok(";", "punctuation", 1, 9, ";"), // 19
inner("for_expression", 2, 7), // 20
tok("for", "keyword", 2, 20, "for"), // 21
tok("identifier", "variable", 2, 20, "node"), // 22
tok("in", "keyword", 2, 20, "in"), // 23
inner("call_expression", 2, 20), // 24
inner("field_expression", 2, 24), // 25
tok("identifier", "variable", 2, 25, "tree"), // 26
tok(".", "punctuation", 2, 25, "."), // 27
tok("field_identifier", "function", 2, 25, "walk"), // 28
inner("arguments", 2, 24), // 29
tok("(", "punctuation", 2, 29, "("), // 30
tok(")", "punctuation", 2, 29, ")"), // 31
inner("block", 2, 20), // 32
tok("{", "punctuation", 2, 32, "{"), // 33
inner("expression_statement", 3, 32), // 34
inner("macro_invocation", 3, 34), // 35
tok("identifier", "macro", 3, 35, "println"), // 36
tok("!", "macro", 3, 35, "!"), // 37
inner("token_tree", 3, 35), // 38
tok("(", "punctuation", 3, 38, "("), // 39
tok("string_literal", "string", 3, 38, "\"{node:?}\""), // 40
tok(")", "punctuation", 3, 38, ")"), // 41
tok(";", "punctuation", 3, 34, ";"), // 42
tok("}", "punctuation", 4, 32, "}"), // 43
tok("}", "punctuation", 5, 7, "}"), // 44
];
/// How far each line is indented, in spaces.
const INDENT: [usize; 6] = [0, 4, 4, 8, 4, 0];
/// The colour a highlight group paints with, from the theme's roles.
fn group_color(t: &Theme, group: &str) -> Color {
match group {
"keyword" => t.accent,
"function" => t.success,
"string" => t.warning,
"macro" => t.danger,
"punctuation" | "operator" => t.muted,
_ => t.fg,
}
}
/// Whether `node` is `ancestor` or under it.
fn under(node: usize, ancestor: usize) -> bool {
let mut n = Some(node);
while let Some(i) = n {
if i == ancestor {
return true;
}
n = TREE[i].parent;
}
false
}
fn depth(i: usize) -> usize {
let mut d = 0;
let mut n = TREE[i].parent;
while let Some(p) = n {
d += 1;
n = TREE[p].parent;
}
d
}
/// The node's path from the root, `source_file › function_item › …`.
fn path(i: usize) -> String {
let mut names = vec![TREE[i].kind];
let mut n = TREE[i].parent;
while let Some(p) = n {
names.push(TREE[p].kind);
n = TREE[p].parent;
}
names.reverse();
names.join(" › ")
}
#[derive(Default)]
struct Page {
/// How many times the tab's closure ran: the laziness, on screen.
built: u32,
/// The tab row under the pointer: its node's tokens light up in the
/// source.
hover_row: Option<usize>,
/// The source token under the pointer: its path lights up in the tab.
hover_tok: Option<usize>,
/// The token the tab last scrolled to, so a hover reveals its row once
/// and the list stays where the user put it afterwards.
revealed: Option<usize>,
/// A row the tab clicked: select its token in the panel's tree on the
/// next view (the door is the core's, reached from the view).
reveal: Option<usize>,
/// The tab asked for the picker, applied the same way.
pick: bool,
/// The page's button asked for the tab, applied the same way.
show_tab: bool,
}
impl Page {
/// The token a source-side key names, back from the panel: what
/// `devtools_selected` / `devtools_picked` mean in the tree's own
/// terms.
fn node_of(ui: &mut Ui<'_>, key: Option<Key>) -> Option<usize> {
let key = key?;
(0..TREE.len())
.find(|i| TREE[*i].text.is_some() && ui.key_of(&format!("tok:{i}")) == Some(key))
}
}
impl App for Page {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
if let Some(i) = self.reveal.take()
&& let Some(k) = ui.key_of(&format!("tok:{i}"))
{
ui.core().set_devtools_selected(Some(k));
}
if std::mem::take(&mut self.pick) {
ui.core().set_devtools_pick(true);
}
if std::mem::take(&mut self.show_tab) {
ui.core().set_devtools_tab("inspector");
}
let current_tab = ui.devtools_current_tab();
// What the panel holds, in the tree's terms — read before the
// page is built, so the tab and the source agree on it.
let selected = Self::node_of(ui, ui.devtools_selected());
let picked = Self::node_of(ui, ui.devtools_picked());
let hover_row = self.hover_row;
let hover_tok = self.hover_tok;
// A token lights up when the tab row over it is the token itself
// or an ancestor, or when the panel selected or is picking it.
let lit = |i: usize| {
hover_row.is_some_and(|r| under(i, r)) || selected == Some(i) || picked == Some(i)
};
ui.with(
NodeSpec::column()
.fill()
.pad(16.0)
.gap(4.0),
|ui| {
ui.text(
"the source, coloured by highlight group — hover a token to see its node in the Inspector",
TextStyle::new(12.0).color(t.muted),
);
for (line, indent) in INDENT.iter().enumerate() {
ui.with(
NodeSpec::row()
.grow_width()
.cross_align(Align::Center),
|ui| {
ui.leaf(
NodeSpec::row().width(8.0 * *indent as f32));
let mut first = true;
for (i, n) in TREE.iter().enumerate() {
let Some(text) = n.text else { continue };
if n.line != line {
continue;
}
// A space before a token that is a word, or
// before `=` and `{`: the spacing a formatter
// leaves.
let word = text.chars().next().is_some_and(|c| c.is_alphanumeric());
if !first && (word || matches!(text, "=" | "{")) {
ui.leaf(NodeSpec::row().width(7.0));
}
first = false;
let on = lit(i);
let tag = Value::map([
("kind", Value::str("tok")),
("id", Value::Int(i as i64)),
]);
ui.text_in_keyed(&format!("tok:{i}"), NodeSpec::row()
.pad_xy(1.0, 2.0)
.radius(3.0)
.bg(if on { t.accent_soft } else { Color::TRANSPARENT })
.hover_bg(t.hover)
.on_hover(tag.clone())
.on_click(tag)
.label(n.kind), text,
TextStyle::new(14.0)
.mono()
.color(group_color(&t, n.group.unwrap_or(""))));
}
},
);
}
ui.text(
&match hover_tok.or(selected) {
Some(i) => {
format!("{} · @{}", path(i), TREE[i].group.unwrap_or("none"))
}
None => "hover a token, or pick one from the Inspector".into(),
},
TextStyle::new(12.0).color(t.muted),
);
ui.with(
NodeSpec::row().gap(8.0).cross_align(Align::Center),
|ui| {
widgets::button(
ui,
"open the Inspector",
Value::map([("kind", Value::str("show-tab"))]),
);
ui.text(
&format!(
"the panel is on `{current_tab}` · Inspector built {} time(s) — only while its tab is on show",
self.built
),
TextStyle::new(11.0).color(t.faint),
);
},
);
},
);
// The tab: declared every frame, drawn only while it is on show.
let built = &mut self.built;
let revealed = &mut self.revealed;
ui.devtools_tab_with("inspector", "Inspector", |ui| {
*built += 1;
let picking = ui.devtools_picking();
// A token hovered in the source scrolls the tab to its row,
// once per token: `reveal` reads last frame's layout, which
// is why it is asked here and not where the hover arrived.
if let Some(i) = hover_tok
&& *revealed != Some(i)
&& let Some(k) = ui.key_of(&format!("node:{i}"))
{
ui.reveal(k);
*revealed = Some(i);
}
ui.with(
NodeSpec::column()
.fill()
.gap(4.0),
|ui| {
ui.with(
NodeSpec::row().gap(6.0).cross_align(Align::Center),
|ui| {
widgets::button(
ui,
if picking {
"picking… (Escape leaves)"
} else {
"pick a node"
},
Value::map([("kind", Value::str("pick"))]),
);
ui.text(
&match (selected, picked) {
(_, Some(p)) => format!("picking {}", TREE[p].kind),
(Some(s), None) => format!("selected {}", TREE[s].kind),
(None, None) => "nothing selected in the panel".into(),
},
TextStyle::new(11.0).color(t.muted),
);
},
);
ui.text(
"the syntax tree — hover a row to light its tokens, click to select it in the panel's tree",
TextStyle::new(11.0).color(t.muted),
);
ui.with(
NodeSpec::column()
.fill()
.scroll_y(),
|ui| {
for (i, n) in TREE.iter().enumerate() {
// A row is lit when the source token under the
// pointer is it or under it, or when the panel
// selected or is picking it.
let on_path = hover_tok.is_some_and(|k| under(k, i));
let on = on_path || selected == Some(i) || picked == Some(i);
let tag = Value::map([
("kind", Value::str("row")),
("id", Value::Int(i as i64)),
]);
ui.with_keyed(
&format!("node:{i}"),
NodeSpec::row()
.grow_width()
.pad_xy(6.0, 2.0)
.gap(6.0)
.radius(3.0)
.cross_align(Align::Center)
.bg(if on { t.accent_soft } else { Color::TRANSPARENT })
.hover_bg(t.hover)
.on_hover(tag.clone())
.on_click(tag)
.label(n.kind),
|ui| {
ui.leaf(
NodeSpec::row()
.width(10.0 * depth(i) as f32));
let one_line = |size: f32| {
TextStyle::new(size).mono().wrap(kui_native::TextWrap::None)
};
ui.text(
n.kind,
one_line(12.0).color(if n.text.is_some() { t.fg } else { t.muted }),
);
if let Some(g) = n.group {
ui.text(&format!("@{g}"), one_line(11.0).color(group_color(&t, g)));
}
if let Some(text) = n.text {
ui.text(text, one_line(11.0).color(t.faint));
}
ui.leaf(NodeSpec::row().grow_width());
ui.text(
&format!("{}", n.line + 1),
TextStyle::new(10.0).color(t.faint),
);
},
);
}
},
);
},
);
});
}
fn on_event(&mut self, ev: UiEvent) {
let phase = ev.payload.get_str("phase");
// A hover carries its node's tag under `tag`; a click is the tag.
let tag = ev.payload.get("tag").unwrap_or(&ev.payload);
let id = tag.get_int("id").map(|i| i as usize);
match (tag.get_str("kind"), phase) {
(Some("tok"), Some("enter")) => self.hover_tok = id,
(Some("tok"), Some("leave")) => {
if self.hover_tok == id {
self.hover_tok = None;
}
}
(Some("row"), Some("enter")) => self.hover_row = id,
(Some("row"), Some("leave")) => {
if self.hover_row == id {
self.hover_row = None;
}
}
// A click on a row or a token: select it in the panel's tree.
(Some("row") | Some("tok"), None) => self.reveal = id,
(Some("pick"), None) => self.pick = true,
(Some("show-tab"), None) => self.show_tab = true,
_ => {}
}
}
}
impl Example for Page {
const KEYS: &'static [(&'static str, &'static str)] = &[
(
"Ctrl+Shift+N",
"the next tab — facts, events, tree, Inspector",
),
(
"Ctrl+Shift+P",
"pick a token; the Inspector reads it back as its node",
),
];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(760.0, 480.0)
}
/// The tab is declared and not built while another is up; on show it
/// is built once a frame, over the panel's body; hovering a row lights
/// the node's tokens in the source and hovering a token lights its
/// path in the tab; a row's click selects the token in the panel's
/// tree; the tab's picker lands a pick in `selected` with the tab up;
/// the page's button jumps to the tab from the app's side.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
// The bare core the drive gets has no panel: on, docked right, as
// the harness's window would have it.
core.set_devtools(true);
core.set_devtools_dock(kui_native::DevtoolsDock::Right);
core.set_inspect(true);
let mut d = Drive::new(core, 760.0, 480.0);
d.frame(self);
d.frame(self);
d.check(
self.built == 0,
"the tab is declared, not built, while events is up",
)?;
let listed = d.key_of("kui-devtools/tab-custom:inspector").is_some();
d.check(listed, "and the strip lists it")?;
let chord = |d: &mut Drive<'_>, app: &mut Page| {
d.key(app, "n", kui_native::KeyMods::NONE.with_ctrl().with_shift());
};
chord(&mut d, self); // tree
chord(&mut d, self); // Inspector
d.frame(self);
d.check(self.built == 1, "on show, the closure ran once")?;
d.frame(self);
d.check(self.built == 2, "and once a frame")?;
// The body is not interactive, so `rect_of` (the hit list) has no
// rect for it: the inspect snapshot has every node's.
let body_key = d
.key_of("kui-devtools/tab/inspector")
.ok_or("no tab body")?;
let body = d
.core
.nodes()
.iter()
.find(|n| n.key == body_key)
.map(|n| n.rect)
.ok_or("no tab body rect")?;
let row = d.key_of("node:9").ok_or("no let_declaration row")?;
let r = d.rect_of(row).ok_or("no row rect")?;
d.check(
r.x >= body.x && r.x + r.w <= body.x + body.w && r.y >= body.y,
"the tab's content is laid out over the panel's body",
)?;
let lit_of = |d: &mut Drive<'_>, prefix: &str| -> Vec<usize> {
let nodes = d.core.nodes();
(0..TREE.len())
.filter(|i| {
d.core.key_of(&format!("{prefix}:{i}")).is_some_and(|k| {
nodes
.iter()
.any(|n| n.key == k && n.bg != Color::TRANSPARENT)
})
})
.collect()
};
// Hover the `let_declaration` row: its tokens light up on line 2,
// nothing else does.
d.hover(self, row);
d.frame(self);
d.check(self.hover_row == Some(9), "hovering a row names its node")?;
let lit = lit_of(&mut d, "tok");
d.check(
lit == [10, 11, 12, 14, 16, 17, 18, 19],
"and the node's tokens are lit in the source, no others",
)?;
// Hover a token in the source: its path lights up in the tab.
let walk = d.key_of("tok:28").ok_or("no walk token")?;
d.hover(self, walk);
d.frame(self);
d.check(
self.hover_tok == Some(28) && self.hover_row.is_none(),
"hovering a token names it, and the row's hover ended",
)?;
let lit_rows = lit_of(&mut d, "node");
d.check(
lit_rows == [0, 1, 7, 20, 24, 25, 28],
"and its path is lit in the tab, root to leaf",
)?;
// The hover scrolled the tab to the token's row; a frame settles
// that, and a click on the row selects its token in the panel's
// tree.
d.frame(self);
let walk_row = d.key_of("node:28").ok_or("no walk row")?;
d.click_key(self, walk_row);
d.frame(self);
d.frame(self);
d.check(
d.core.devtools_selected() == Some(walk),
"a row's click selected its token in the panel's tree",
)?;
// The picker, raised from the tab: over the app, the tab stays,
// and the pick reads back as the node it names.
let pick = d.key_of("pick a node").ok_or("no pick button")?;
d.click_key(self, pick);
d.frame(self);
d.check(d.core.devtools_picking(), "the tab raised the picker")?;
d.frame(self);
let up = d.key_of("kui-devtools/tab/inspector").is_some();
d.check(up, "and stayed up while picking")?;
let string = d.key_of("tok:40").ok_or("no string token")?;
d.hover(self, string);
d.frame(self);
d.check(
d.core.devtools_picked() == Some(string),
"the token is under the picker",
)?;
let r = d.rect_of(string).ok_or("no rect")?;
d.click(self, r.x + 4.0, r.y + r.h / 2.0);
d.frame(self);
d.check(
!d.core.devtools_picking() && d.core.devtools_selected() == Some(string),
"the press picked the token into `selected`",
)?;
let up = d.key_of("kui-devtools/tab/inspector").is_some();
d.check(up, "and the tab is still the one on show")?;
chord(&mut d, self); // facts
let runs = self.built;
d.frame(self);
d.check(self.built == runs, "another tab up: the closure rests")?;
d.check(
d.core.devtools_current_tab() == "facts",
"and the panel says which it is on",
)?;
// The page's own button jumps to the tab: the app's command, not
// the strip's click.
let open = d.key_of("open the Inspector").ok_or("no open button")?;
d.click_key(self, open);
d.frame(self);
d.check(
d.core.devtools_current_tab() == "inspector",
"the button selected the tab from the app's side",
)?;
d.check(
self.built == runs + 1,
"and the same view built it, the door being read before the tab",
)?;
let up = d.key_of("kui-devtools/tab/inspector").is_some();
d.check(up, "over the panel's body")?;
Ok(())
}
}
kui_devtools::main!(Page::default());
features/drag.rs
//! `on_drag`: a press-move-release on a node arrives as `drag` events —
//! `start` at the press, `move` once the pointer has travelled past the
//! slop, `end` at the release — with the pointer in viewport px (`x`,
//! `y`), the displacement *since the press* (`dx`, `dy`, absolute against
//! the press point so no delta is ever summed), and the node's `parent`
//! rect, so a handler can turn a position into a fraction of its
//! container without a geometry query. The core captures the pointer for
//! the drag: it keeps reporting after the pointer leaves the node, and
//! the release lands wherever it lands.
//!
//! Two things it is for: a **slider** whose value is what it was at the
//! press plus `dx` over the track — drawn by hand here because the drag
//! is the subject; an app's slider is the stock one
//! (`widgets::slider`, ADR 0034), which does this and the keys — and a
//! **card** whose float offset is
//! where it was at the press plus the displacement, kept inside the stage
//! (its own size from `on_layout`, the stage's from the event's `parent`).
//! A drag that started on the card is the card's until it ends, whatever
//! the pointer crosses.
//!
//! The cursor is declared, not derived: an `on_drag` node with no `cursor`
//! is the plain arrow, so both declare `grab` at rest and `grabbing`
//! while their drag runs — the model already knows which drag is on, and
//! the shape is one more thing the view says from it. The core holds
//! whichever shape the dragged node declared for as long as the pointer
//! is captured, wherever it goes.
//!
//! Run: cargo run -p kui-native --example drag [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::{
Align, App, Core, CursorShape, FloatConfig, NodeSpec, Sizing, TextStyle, Ui, UiEvent, Value,
};
const TRACK_W: f32 = 320.0;
#[derive(Default)]
struct Drag {
/// The slider's value, 0..1.
value: f32,
/// The card's offset in its stage.
card: (f32, f32),
/// What was true at the press, for the displacement to add to.
at_press: (f32, (f32, f32)),
/// The card's own size, from its `on_layout`, so it stays inside the
/// stage whose rect the drag event carries.
card_size: (f32, f32),
dragging: Option<String>,
log: String,
}
impl App for Drag {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
// The open hand over a handle at rest, the closed one while its
// drag runs: `dragging` names which.
let hand = |tag: &str| {
if self.dragging.as_deref() == Some(tag) {
CursorShape::Grabbing
} else {
CursorShape::Grab
}
};
ui.with(
NodeSpec::column()
.fill()
.pad(24.0)
.gap(18.0)
.cross_align(Align::Start),
|ui| {
ui.text(
&format!("a slider: the drag's x is the value · {:.0}%", self.value * 100.0),
TextStyle::new(12.0).color(t.muted),
);
// The track owns the drag, and its fill is the value: a
// drag anywhere on it moves the value by the travel.
ui.with_keyed(
"track",
NodeSpec::row()
.size(TRACK_W, 24.0)
.bg(t.sunken)
.radius(12.0)
.on_drag("slider")
.cursor(hand("slider")),
|ui| {
ui.leaf(
NodeSpec::row()
.width(Sizing::Percent(self.value))
.grow_height()
.bg(t.accent)
.radius(12.0));
},
);
ui.text(
"a card: the drag's steps move its float offset · the pointer is captured until the release",
TextStyle::new(12.0).color(t.muted),
);
ui.with_keyed(
"stage",
NodeSpec::row()
.size(420.0, 200.0)
.bg(t.surface)
.radius(10.0)
.border(1.0, t.border),
|ui| {
let grabbing = self.dragging.as_deref() == Some("card");
ui.with_keyed(
"card",
NodeSpec::column()
.float(
FloatConfig::parent()
.inside(Align::Start, Align::Start)
.offset(self.card.0, self.card.1),
)
.pad_xy(16.0, 12.0)
.bg(if grabbing { t.accent_soft } else { t.raised })
.radius(8.0)
.border(1.0, if grabbing { t.accent } else { t.border })
.on_drag("card")
.cursor(hand("card"))
.on_layout("card"),
|ui| {
ui.text("drag me", TextStyle::new(14.0));
ui.text(
&format!("at {:.0}, {:.0}", self.card.0, self.card.1),
TextStyle::new(11.0).color(t.muted),
);
},
);
},
);
ui.text(&self.log, TextStyle::new(12.0).color(t.faint));
},
);
}
fn on_event(&mut self, ev: UiEvent) {
let num = |k: &str| ev.payload.get(k).and_then(Value::as_float).unwrap_or(0.0) as f32;
match ev.kind() {
Some("layout") => {
self.card_size = (num("w"), num("h"));
return;
}
Some("drag") => {}
_ => return,
}
let phase = ev.payload.get_str("phase").unwrap_or("");
let tag = ev.payload.get_str("tag").unwrap_or("");
self.log = format!(
"{tag} {phase} x {:.0} y {:.0} dx {:.0} dy {:.0}",
num("x"),
num("y"),
num("dx"),
num("dy")
);
match (tag, phase) {
("slider", "start") => {
self.dragging = Some("slider".into());
self.at_press.0 = self.value;
}
("slider", "end") => self.dragging = None,
("slider", _) => {
// The value at the press plus the displacement over the
// track's width: absolute, so nothing accumulates.
self.value = (self.at_press.0 + num("dx") / TRACK_W).clamp(0.0, 1.0);
}
("card", "start") => {
self.dragging = Some("card".into());
self.at_press.1 = self.card;
}
("card", "move") => {
// The offset at the press plus the displacement, kept
// inside the stage: the event carries the parent's rect,
// and the card's own size came from its `on_layout`.
let (ox, oy) = self.at_press.1;
let parent = ev.payload.get("parent");
let dim = |k: &str| {
parent
.and_then(|p| p.get(k))
.and_then(Value::as_float)
.unwrap_or(0.0) as f32
};
let max_x = (dim("w") - self.card_size.0).max(0.0);
let max_y = (dim("h") - self.card_size.1).max(0.0);
self.card = (
(ox + num("dx")).clamp(0.0, max_x),
(oy + num("dy")).clamp(0.0, max_y),
);
}
("card", "end") => self.dragging = None,
_ => {}
}
}
}
impl Example for Drag {
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(500.0, 380.0)
}
/// A drag along the track moves the value by the travel over the
/// track's width; a drag across the card moves it by the pointer's
/// travel and keeps reporting after the pointer has left it.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 500.0, 380.0);
d.frame(self);
let track = d.key_of("track").ok_or("no track")?;
let r = d.rect_of(track).ok_or("no track rect")?;
let (x0, y0) = (r.x + 10.0, r.y + 12.0);
d.input(
self,
kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(x0, y0)),
);
d.check(
d.core.cursor_shape() == CursorShape::Grab,
"the track declares the open hand at rest",
)?;
let evs = d.input(self, kui_native::InputEvent::mouse_down(1));
d.check(
evs.iter()
.any(|e| e.payload.get_str("phase") == Some("start")),
"a press on the track starts a drag",
)?;
d.frame(self);
d.check(
d.core.cursor_shape() == CursorShape::Grabbing,
"and the frame after the start declares the closed one",
)?;
d.input(
self,
kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(x0 + TRACK_W * 0.5, y0)),
);
d.check(
(self.value - 0.5).abs() < 0.02,
"half the track's width of travel is half the value",
)?;
d.input(
self,
kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(x0 + TRACK_W * 0.9, y0)),
);
d.check(
(self.value - 0.9).abs() < 0.02,
"and the value follows the pointer, not the steps",
)?;
d.input(self, kui_native::InputEvent::mouse_up());
d.frame(self);
d.check(
d.core.cursor_shape() == CursorShape::Grab,
"the release opens the hand again",
)?;
let card = d.key_of("card").ok_or("no card")?;
let c = d.rect_of(card).ok_or("no card rect")?;
let (x0, y0) = (c.x + c.w / 2.0, c.y + c.h / 2.0);
d.input(
self,
kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(x0, y0)),
);
d.input(self, kui_native::InputEvent::mouse_down(1));
d.check(
self.dragging.is_some(),
"a press on the card starts its drag",
)?;
d.input(
self,
kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(x0 + 60.0, y0 + 30.0)),
);
d.frame(self);
d.check(
(self.card.0 - 60.0).abs() < 1.0 && (self.card.1 - 30.0).abs() < 1.0,
"a move of 60,30 moves the card by 60,30",
)?;
// Far outside the card and the stage: the capture keeps the drag,
// and the card stops at the stage's edge.
d.input(
self,
kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(x0 + 600.0, y0 + 30.0)),
);
d.frame(self);
d.check(
self.card.0 > 200.0 && self.card.0 + self.card_size.0 <= 420.0 + 0.5,
"the pointer leaving the card does not end the drag, and the card stops at the edge",
)?;
d.input(self, kui_native::InputEvent::mouse_up());
d.check(
self.dragging.is_none(),
"the release ends it, wherever it lands",
)
}
}
kui_devtools::main!(Drag::default());
features/drop.rs
//! A drop zone, declared rather than tracked
//! (`docs/adr/0031-a-drop-zone-is-a-row-and-the-files-are-an-event.md`).
//! Drag files in from the Finder:
//!
//! - `on_drop`: the box is a zone, and the files over it arrive as four
//! phases of one event — `enter`, `move`, `leave`, `drop` — with their
//! paths and the pointer's position;
//! - `drop_bg`: the zone lights while they hover, swapped by the core the
//! way `hover_bg` is, with no state in the view;
//! - a button inside the zone is the zone's: files over it land here;
//! - the banner the view shows in answer to `enter` is a float over the
//! zone that is no zone, and the files look past it — the flicker
//! HTML's `dragleave` is known for cannot happen;
//! - the second box below takes nothing: over it the cursor shows the
//! not-allowed circle and a release there slides the icon home;
//! - "Open…" asks for the platform's Open dialog instead (backlog C51):
//! `ui.request_files`, answered by one `files` event whose `paths` are
//! what a drop's are, so the same list takes both.
//!
//! Run: cargo run -p kui-native --example drop [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::{
Align, App, Core, FileDialog, FloatConfig, NodeSpec, TextStyle, Ui, UiEvent, Value, Vec2,
};
#[derive(Default)]
struct Drop {
/// What landed, newest last.
landed: Vec<String>,
/// The files over the zone right now, from `enter` / `leave`.
hovering: Vec<String>,
/// Where they are, from `move`.
at: Option<(f32, f32)>,
events: u32,
cleared: u32,
/// "Open…" was clicked: the next view asks for the dialog. `on_event`
/// has no `Ui` to ask with, so the ask is the view's.
open: bool,
}
impl App for Drop {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
if std::mem::take(&mut self.open) {
// One ask at a time: a click while the dialog is up asks for
// nothing more.
ui.request_files(
FileDialog::open()
.multiple()
.title("Add files")
.tag(Value::str("add")),
);
}
ui.with(
NodeSpec::column()
.fill()
.pad(24.0)
.gap(14.0)
.cross_align(Align::Start),
|ui| {
ui.text(
"on_drop + drop_bg · drag files from the Finder onto the zone",
TextStyle::new(12.0).color(t.muted),
);
ui.with_keyed(
"zone",
NodeSpec::column()
.size(400.0, 180.0)
.pad(14.0)
.gap(8.0)
.radius(10.0)
.bg(t.surface)
.drop_bg(t.accent.with_alpha(0.35))
.border(1.0, t.border)
.on_drop("zone"),
|ui| {
let heading = if self.hovering.is_empty() {
"drop files here".to_string()
} else {
format!("{} file(s) over the zone", self.hovering.len())
};
ui.text(&heading, TextStyle::new(14.0));
if let Some((x, y)) = self.at {
ui.text(
&format!("pointer at {x:.0}, {y:.0}"),
TextStyle::new(12.0).color(t.faint),
);
}
// Inside the zone: a button, and files over it are
// the zone's — no `on_drop` of its own.
ui.text_in_keyed(
"clear",
NodeSpec::row()
.pad_xy(12.0, 6.0)
.radius(6.0)
.bg(t.raised)
.hover_bg(t.hover)
.on_click(Value::map([("kind", Value::str("clear"))]))
.label("Clear"),
"Clear the list",
TextStyle::new(12.0),
);
ui.text_in_keyed(
"open",
NodeSpec::row()
.pad_xy(12.0, 6.0)
.radius(6.0)
.bg(t.raised)
.hover_bg(t.hover)
.on_click(Value::map([("kind", Value::str("open"))]))
.label("Open…"),
"Open…",
TextStyle::new(12.0),
);
// What an app shows in answer to `enter`: a banner
// floated over the zone. It takes no files, so
// the files look past it (decision 2) — the zone
// stays lit and hears `move`, not `leave`.
if !self.hovering.is_empty() {
ui.text_in_keyed(
"banner",
NodeSpec::row()
.float(
FloatConfig::parent()
.inside(Align::Center, Align::End)
.offset(0.0, -8.0),
)
.pad_xy(14.0, 8.0)
.radius(8.0)
.bg(t.accent)
.hoverable(),
"release to add",
TextStyle::new(12.0)
.color(kui_native::widgets::readable_on(t.accent)),
);
}
},
);
ui.text_in_keyed(
"nowhere",
NodeSpec::row()
.width(400.0)
.pad(12.0)
.radius(10.0)
.bg(t.surface)
.border(1.0, t.border)
.hoverable(),
"not a zone · the cursor says no, a release slides home",
TextStyle::new(12.0).color(t.muted),
);
ui.text(
&format!(
"landed: {}",
if self.landed.is_empty() {
"nothing yet".to_string()
} else {
self.landed.join(", ")
}
),
TextStyle::new(12.0),
);
ui.text(
&format!("{} drop events · cleared {}×", self.events, self.cleared),
TextStyle::new(12.0).color(t.faint),
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.kind() {
Some("clear") => {
self.landed.clear();
self.cleared += 1;
return;
}
Some("open") => {
self.open = true;
return;
}
// The dialog's answer: the same paths a drop carries, none when
// it was cancelled.
Some("files") => {
let paths = ev.payload.get("paths").and_then(Value::as_list);
self.landed.extend(
paths
.unwrap_or(&[])
.iter()
.filter_map(|p| p.as_str().map(str::to_string)),
);
return;
}
Some("drop") => {}
_ => return,
}
self.events += 1;
let paths: Vec<String> = ev
.payload
.get("paths")
.and_then(Value::as_list)
.map(|l| {
l.iter()
.filter_map(|p| p.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let at = match (ev.payload.get_float("x"), ev.payload.get_float("y")) {
(Some(x), Some(y)) => Some((x as f32, y as f32)),
_ => None,
};
match ev.payload.get_str("phase") {
Some("enter") => {
self.hovering = paths;
self.at = at;
}
Some("move") => self.at = at,
Some("leave") => {
self.hovering.clear();
self.at = None;
}
Some("drop") => {
self.hovering.clear();
self.at = None;
self.landed.extend(paths);
}
_ => {}
}
}
}
impl Example for Drop {
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(460.0, 440.0)
}
/// Files in from the OS, headlessly: over the zone, over its button,
/// over the banner the view showed, off every zone, and landed.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 460.0, 440.0);
d.frame(self);
let zone = d.key_of("zone").ok_or("no zone")?;
let zone_rect = d.rect_of(zone).ok_or("the zone has no rect")?;
let paths = vec!["/tmp/a.txt".to_string(), "/tmp/b.png".to_string()];
let inside = Vec2::new(zone_rect.x + 30.0, zone_rect.y + 30.0);
d.input(
self,
kui_native::InputEvent::DragFiles {
paths: paths.clone(),
at: inside,
},
);
d.check(
self.hovering.len() == 2 && d.core.drop_target() == Some(zone),
"files over the zone: `enter`, and the zone is the target",
)?;
d.frame(self);
d.check(d.core.is_drop_target(zone), "the zone reads lit")?;
// Over the button inside it: the zone's still.
let button = d.key_of("clear").ok_or("no button")?;
let br = d.rect_of(button).ok_or("the button has no rect")?;
d.input(
self,
kui_native::InputEvent::DragFiles {
paths: paths.clone(),
at: Vec2::new(br.x + 4.0, br.y + 4.0),
},
);
d.check(
self.hovering.len() == 2 && self.events == 2,
"over the button inside the zone: a `move` on the zone, not a leave",
)?;
// Over the banner the view showed on enter: looked past.
d.frame(self);
let banner = d.key_of("banner").ok_or("the view showed no banner")?;
let bn = d.rect_of(banner).ok_or("the banner has no rect")?;
d.input(
self,
kui_native::InputEvent::DragFiles {
paths: paths.clone(),
at: Vec2::new(bn.x + 4.0, bn.y + 4.0),
},
);
d.check(
self.hovering.len() == 2 && d.core.drop_target() == Some(zone),
"over the banner: the files look past it to the zone",
)?;
// Off every zone: leave, nothing lit.
let nowhere = d.key_of("nowhere").ok_or("no second box")?;
let nr = d.rect_of(nowhere).ok_or("the second box has no rect")?;
d.input(
self,
kui_native::InputEvent::DragFiles {
paths: paths.clone(),
at: Vec2::new(nr.x + 4.0, nr.y + 4.0),
},
);
d.check(
self.hovering.is_empty() && d.core.drop_target().is_none(),
"over the box that is no zone: `leave`, and no target",
)?;
// Back in, and released: landed, no leave after.
d.input(
self,
kui_native::InputEvent::DragFiles {
paths: paths.clone(),
at: inside,
},
);
let before = self.events;
d.input(
self,
kui_native::InputEvent::DropFiles { paths, at: inside },
);
d.check(
self.landed.len() == 2 && self.hovering.is_empty() && self.events == before + 1,
"released over the zone: `drop` with both paths, and no `leave` after it",
)?;
d.frame(self);
d.check(!d.core.is_drop_target(zone), "and nothing is lit")?;
d.click_key(self, button);
d.check(
self.landed.is_empty() && self.cleared == 1,
"the button inside still clicks",
)?;
// "Open…": the view asks, the host (this drive) takes the ask and
// answers it, and the answer lands in the same list.
let open = d.key_of("open").ok_or("no Open button")?;
d.click_key(self, open);
d.frame(self);
let asks = d.core.take_file_requests();
d.check(
asks.len() == 1 && asks[0].multiple && asks[0].tag == Value::str("add"),
"Open… asks the host for one multiple-file dialog, tagged",
)?;
d.check(
d.core.awaiting_files(),
"and the ask stays out until it is answered",
)?;
d.click_key(self, open);
d.frame(self);
let again = d.core.take_file_requests();
d.check(
again.is_empty(),
"a second click while it is up asks for nothing more",
)?;
d.input(
self,
kui_native::InputEvent::Files(vec!["/tmp/c.md".to_string()]),
);
d.check(
self.landed == ["/tmp/c.md"] && !d.core.awaiting_files(),
"the answer lands like a drop, and the ask is spent",
)?;
d.click_key(self, open);
d.frame(self);
d.core.take_file_requests();
d.input(self, kui_native::InputEvent::Files(Vec::new()));
d.check(
self.landed.len() == 1,
"a cancelled dialog answers with no paths, and nothing lands",
)
}
}
kui_devtools::main!(Drop::default());
features/enter_exit.rs
//! Entrance and exit transitions: `enter` says where a node starts the
//! first frame it is seen and `exit` where it ends the frame after the view
//! stops declaring it, so a toast slides in from off screen and back out
//! again without the view staging a frame to animate from — or keeping a
//! dead toast in its model to animate it away.
//!
//! Four things worth watching:
//! - a toast arrives from the right and fades up (`enter` with an offset
//! and a bg), and it does that on the very frame it appears;
//! - it leaves the same way (`exit`, which is an `enter` read the other
//! way): the app drops it from `self.toasts` the moment it expires, and
//! what slides back out is a *copy* the core kept — frozen where layout
//! left it, in its place, and inert. Nothing in this file waits for it;
//! - older toasts `slide` down as the newest one pushes into the stack,
//! and back up as one goes — position eases whenever layout moves them,
//! which is what `slide` buys on top of the entrance, and it happens
//! while the departing toast is still on screen beside them;
//! - "clear" drops the whole stack in one frame. Every toast departs at
//! once, and they all animate out, because `exit` is opt-in per node
//! and a handful of cards is nowhere near the store's budget. A list
//! that dropped a thousand rows would be, and would say so.
//!
//! Expiry needs a clock the core doesn't have, so the app keeps its own
//! `Instant`s and asks for the next frame while any toast is still due to
//! go. With none left it stops asking — and the window keeps drawing anyway
//! until the last exit finishes, because a departing subtree is mid-flight
//! and `animating()` says so.
//!
//! Run: cargo run -p kui-native --example enter_exit
use std::time::{Duration, Instant};
use kui_devtools::Example;
use kui_native::{
Align, App, Color, Easing, Enter, FloatConfig, NodeSpec, Sizing, TextStyle, Ui, UiEvent, Value,
widgets,
};
const LIFETIME: Duration = Duration::from_millis(3200);
/// The same color with nothing behind it — what a toast fades up from.
fn clear(c: Color) -> Color {
Color { a: 0.0, ..c }
}
struct Toast {
/// Stable across frames: the node key the entrance and the slide are
/// keyed by. Reusing an index here would make a dismissed toast hand
/// its tween to the one that shuffled into its place.
id: u64,
text: String,
born: Instant,
}
#[derive(Default)]
struct Toasts {
toasts: Vec<Toast>,
next_id: u64,
sent: u64,
panel: bool,
}
impl Toasts {
/// The stack: a float pinned to its parent's bottom-right corner, with
/// the toasts themselves in ordinary flow inside it.
fn stack(&self, ui: &mut Ui<'_>) {
// `t` is taken by the toast in the loop below; the palette is `th`.
let th = ui.theme();
ui.with(
NodeSpec::column()
.float(FloatConfig::parent().inside(Align::End, Align::End))
.gap(10.0)
.cross_align(Align::End),
|ui| {
for t in &self.toasts {
ui.with_keyed(
&format!("toast-{}", t.id),
NodeSpec::column()
.width(268.0)
.pad(14.0)
.gap(3.0)
.bg(th.raised)
.radius(10.0)
.border(1.0, th.border_strong)
.transition(260.0)
// In from beyond the right edge, fading up. The
// transparent part happens off screen, so what
// you see is a card that is already there.
.enter(Enter::from(340.0, 0.0).bg(clear(th.raised)))
// And out the same way. The app has already
// forgotten this toast by the time this runs:
// what leaves is the core's copy of it.
.exit(Enter::from(340.0, 0.0).bg(clear(th.raised)))
// And afterwards it keeps following layout, so
// the stack closes up when one of them goes.
.slide(),
|ui| {
ui.text(&t.text, TextStyle::new(13.0));
ui.text(
"clears itself in a moment",
TextStyle::new(11.0).color(th.muted),
);
},
);
}
},
);
}
/// The panel: same idea on the other axis, and with a spring, so it
/// overshoots its edge slightly on the way in. It is declared before
/// the latency HUD, so the HUD is painted over it — and its ghost
/// leaves under the HUD too, where the panel was.
fn side_panel(&self, ui: &mut Ui<'_>) {
if !self.panel {
return;
}
let t = ui.theme();
ui.with_keyed(
"panel",
NodeSpec::column()
.float(FloatConfig::parent().inside(Align::Start, Align::Start))
.width(240.0)
.height(Sizing::Percent(1.0))
.pad(20.0)
.gap(12.0)
.bg(t.surface)
.border(1.0, t.border)
.transition(420.0)
// A spring with no bounce: the panel is pinned to the
// window's edge, and an overshoot would pull it off the
// edge for a moment and show the gap behind it.
.easing(Easing::Smooth)
.enter(Enter::from(-240.0, 0.0))
// A spring on the way in; on the way out the ghost samples
// the spring as an ease-out, since nothing can retarget a
// node the view has stopped talking about.
.exit(Enter::from(-240.0, 0.0)),
|ui| {
ui.text("Panel", TextStyle::new(15.0));
ui.text(
"Entered from one width to the left, and it leaves the \
same way. Close and open it again and it enters again: \
the ghost is discarded the moment the key comes back, \
so the two never overlap.",
TextStyle::new(12.0).color(t.muted),
);
},
);
}
}
impl App for Toasts {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
let now = Instant::now();
self.toasts
.retain(|t| now.duration_since(t.born) < LIFETIME);
// Nothing else would wake the window on a toast's own deadline.
if !self.toasts.is_empty() {
ui.request_frame();
}
// The container both floats anchor to (`FloatConfig::parent`), so
// the stack and the panel stay in the example's own area rather
// than the window's corners.
ui.with(
NodeSpec::column().fill().center().gap(20.0).pad(24.0),
|ui| {
ui.text("enter and exit: where a node starts, and where it ends", {
TextStyle::new(20.0)
});
ui.text(
"A transition never animates in from nowhere, so a node's first \
sight snaps: `enter` gives it somewhere to come from. Nor out \
into nowhere — a node the view stops declaring is gone before \
the frame ends — so `exit` has the core keep a picture of it \
and play that out instead.",
TextStyle::new(13.0).color(t.muted),
);
ui.with(NodeSpec::row().gap(12.0).cross_align(Align::Center), |ui| {
widgets::button(ui, "notify", Value::map([("kind", "notify".into())]));
widgets::button(ui, "toggle panel", Value::map([("kind", "panel".into())]));
widgets::button(ui, "clear", Value::map([("kind", "clear".into())]));
});
self.side_panel(ui);
self.stack(ui);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.kind() {
Some("notify") => {
self.sent += 1;
self.next_id += 1;
self.toasts.push(Toast {
id: self.next_id,
text: format!("Saved change #{}", self.sent),
born: Instant::now(),
});
}
Some("panel") => self.panel = !self.panel,
Some("clear") => self.toasts.clear(),
_ => {}
}
}
}
impl Example for Toasts {}
kui_devtools::main!(Toasts::default());
features/exit_budget.rs
//! The exit budget at its boundary (`docs/adr/0012-the-exit-budget.md`).
//!
//! `exit` copies a departing subtree out of the last frame that had it and
//! replays the copy, and the store that holds those copies is bounded at
//! 4096 nodes (backlog DX23; 512 until a 1,800-node pane had to fade). A
//! toast or a pane never gets near it. This one does, on purpose, so the
//! three rules the budget follows are each a button:
//!
//! - **whole or not at all.** Two grids of 2100 cells, every cell with
//! its own `exit`. *clear A* drops 2100 nodes in one frame: under the
//! budget, so every cell slides out. *clear both* drops 4200 in one
//! frame: over it, so **nothing** animates — the grids vanish at once,
//! exactly as they would with no `exit` declared — and the runner
//! prints the `exit-budget` warning on stderr, naming the frame's count.
//! Before ADR 0012 that frame animated the budget's worth and blinked
//! the rest.
//! - **a new removal outranks the ghosts in flight.** *clear A*, then
//! *clear B* while A is still fading: B fits the budget but not the
//! room beside A's 2100 ghosts, so A's oldest give way and every cell
//! of B animates. The removal you just caused is the one you are
//! looking at.
//! - **put `exit` on the list, and make the list small.** The right-hand
//! column is a `uniform_list` of 5000 rows with the `exit` on the
//! container. *clear list* removes five thousand rows and the picture
//! the store keeps is the built slice — the rows on screen and two
//! spacers — so it slides out whole. A plain 5000-row column with the
//! same `exit` would be 5001 nodes and refused: the budget counts what
//! is copied, and virtualisation is what keeps that count small.
//!
//! Run: cargo run -p kui-native --example exit_budget
use kui_devtools::Example;
use kui_native::{Align, App, Color, Enter, NodeSpec, TextStyle, Ui, UiEvent, Value, widgets};
/// Cells per grid: 2100. One grid is under the budget (4096); both
/// together are over it.
const CELLS_PER_ROW: usize = 60;
const ROWS: usize = 35;
/// A cell and the gap after it, px: small, so 2100 fit on screen.
const CELL: f32 = 5.0;
const STEP: f32 = 6.0;
const LIST_ROWS: usize = 5000;
const ROW_H: f32 = 22.0;
struct BulkExit {
a: bool,
b: bool,
list: bool,
}
impl BulkExit {
/// A grid of cells that each carry their own `exit`. The rows are plain
/// containers that stay declared when the grid clears, so what departs
/// is the 2100 one-node cells inside them: an exit plays where its
/// parent is still declared, and a cell whose row went would go with it
/// at once (backlog DX19).
fn grid(&self, ui: &mut Ui<'_>, key: &str, filled: bool, color: Color) {
ui.with_keyed(
key,
NodeSpec::column()
.size(
CELLS_PER_ROW as f32 * STEP - (STEP - CELL),
ROWS as f32 * STEP - (STEP - CELL),
)
.gap(STEP - CELL),
|ui| {
for r in 0..ROWS {
ui.with_indexed(
r as u64,
NodeSpec::row().gap(STEP - CELL).height(CELL),
|ui| {
if !filled {
return;
}
for c in 0..CELLS_PER_ROW {
ui.leaf_indexed(
c as u64,
NodeSpec::column()
.size(CELL, CELL)
.bg(color)
.radius(1.0)
.transition(360.0)
.exit(Enter::from(0.0, 28.0).opacity(0.0)),
);
}
},
);
}
},
);
}
/// Five thousand rows, virtualised, with the `exit` on the container.
fn list(&self, ui: &mut Ui<'_>) {
if !self.list {
return;
}
let t = ui.theme();
widgets::uniform_list(
ui,
"list",
NodeSpec::column()
.size(200.0, ROWS as f32 * STEP - (STEP - CELL))
.bg(t.raised)
.radius(6.0)
.transition(360.0)
.exit(Enter::from(0.0, 40.0).opacity(0.0)),
LIST_ROWS,
ROW_H,
|ui, i| {
ui.text_in(
NodeSpec::row().pad(4.0).cross_align(Align::Center),
&format!("row {i}"),
TextStyle::new(12.0),
);
},
);
}
}
impl App for BulkExit {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.window_title("kui — the exit budget");
ui.with(NodeSpec::column().fill().pad(24.0).gap(16.0), |ui| {
ui.text("the exit budget: a removal animates whole or not at all", {
TextStyle::new(20.0)
});
ui.text(
"Two grids of 2100 cells, each cell with its own `exit`, and a \
5000-row list with the `exit` on the list. The store keeps \
4096 nodes of departing pictures. Clear A: 2100 fit, every cell \
slides out. Clear both: 4200 in one frame do not fit, so none of \
them animate — the warning is on stderr — where alpha.7 slid \
the budget's worth out and blinked the rest. Clear A and then B \
while A is still fading: B animates whole and A's oldest ghosts \
give way. Clear the list: five thousand rows go, but the picture \
is the built slice, so it slides out as one.",
TextStyle::new(13.0).color(t.muted),
);
ui.with(NodeSpec::row().gap(12.0).cross_align(Align::Center), |ui| {
widgets::button(ui, "fill", Value::map([("kind", "fill".into())]));
widgets::button(ui, "clear A", Value::map([("kind", "a".into())]));
widgets::button(ui, "clear B", Value::map([("kind", "b".into())]));
widgets::button(ui, "clear both", Value::map([("kind", "both".into())]));
widgets::button(ui, "clear list", Value::map([("kind", "list".into())]));
});
ui.with(NodeSpec::row().gap(24.0), |ui| {
self.grid(ui, "a", self.a, t.accent);
self.grid(ui, "b", self.b, t.success);
self.list(ui);
});
});
}
fn on_event(&mut self, ev: UiEvent) {
match ev.kind() {
Some("fill") => {
self.a = true;
self.b = true;
self.list = true;
}
Some("a") => self.a = false,
Some("b") => self.b = false,
Some("both") => {
self.a = false;
self.b = false;
}
Some("list") => self.list = false,
_ => {}
}
}
}
impl Example for BulkExit {
/// The three rules, by the warning each frame raises or does not.
fn headless(&mut self, core: &mut kui_native::Core) -> Result<(), String> {
let mut d = kui_devtools::Drive::new(core, 1200.0, 700.0);
let budget = |d: &mut kui_devtools::Drive<'_>| {
d.warnings().iter().any(|w| w.starts_with("exit-budget"))
};
d.frame(self);
d.frame(self);
d.warnings();
let step = |d: &mut kui_devtools::Drive<'_>, app: &mut BulkExit, kind: &str| {
app.on_event(UiEvent::on(
kui_native::OriginId::HOST,
kui_native::Key::ROOT,
Value::map([("kind", kind.into())]),
));
d.advance(0.016);
d.frame(app);
};
step(&mut d, self, "a");
let refused = budget(&mut d);
d.check(!refused, "2100 cells in one frame fit the budget")?;
step(&mut d, self, "fill");
d.advance(1.0);
d.frame(self);
step(&mut d, self, "both");
let refused = budget(&mut d);
d.check(refused, "4200 in one frame are refused whole")?;
step(&mut d, self, "fill");
step(&mut d, self, "list");
let refused = budget(&mut d);
d.check(!refused, "a 5000-row list leaves as its built slice")
}
}
kui_devtools::main!(BulkExit {
a: true,
b: true,
list: true,
});
features/focus.rs
//! Keyboard focus as data (`docs/adr/0002-keyboard-focus-as-data.md`).
//! One focus for the window; Tab walks every control in tree order and
//! wraps; Shift-Tab walks back; Enter and Space press the focused button;
//! the ring the core draws shows for keyboard focus and not for a click
//! (`focus_visible`), or the node's own `focus_bg` replaces it.
//!
//! Who is in the ring: a button, an editor, a key sink, a control role,
//! and a plain box declaring `focusable` — and not a `disabled` one, a
//! `role = none` one, or the root. A `modal` scope confines the ring while
//! it is up and its `initial_focus` says where it opens (`apps/counter`'s
//! menu is one). A `focus_region` is a ring of its own
//! (`docs/adr/0022-focus-regions.md`): the panel on the right is one — Tab
//! from the page never lands in it, Tab inside it never leaves, and the
//! `focus_region` verbs move the keyboard in and out. The devtools dock is
//! another, on Ctrl+Shift+I. The buttons at the bottom are the verbs —
//! `focus`, `blur`, `focus_next`, `focus_region` — for a view that wants
//! to move it itself.
//!
//! The dock's `focus` and `region` rows read the same facts this page
//! draws.
//!
//! Run: cargo run -p kui-native --example focus [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{Align, App, Core, Key, NodeSpec, Role, TextStyle, Ui, UiEvent, Value};
#[derive(Default)]
struct Focus {
pressed: Option<String>,
/// A verb the buttons asked for, applied on the next view.
verb: Option<&'static str>,
field: Option<Key>,
panel: Option<Key>,
}
impl App for Focus {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
// The verbs move focus from the view: `focus` by key, `blur`, and
// `focus_next` from wherever it is.
match self.verb.take() {
Some("field") => {
if let Some(k) = self.field {
ui.focus(k);
}
}
Some("blur") => ui.blur(),
Some("next") => ui.focus_next(),
// Into the panel's ring, or back to the page's; both resolve
// when this frame finishes, so the panel need not have been
// drawn yet — `self.panel` is last frame's key, and the same.
Some("panel") => ui.focus_region(self.panel),
Some("main") => ui.focus_region(None),
_ => {}
}
let focused = ui.focused();
let visible = ui.focus_visible();
// The ring in effect, by the region's label: the panel's, the
// devtools dock's, or the page's.
let region = ui.region().map(|k| {
ui.core()
.label_of(k)
.map_or_else(|| "a region".into(), |l| format!("the {l}'s"))
});
let name = |ui: &mut Ui<'_>| -> String {
focused
.and_then(|k| ui.core().label_of(k).map(str::to_string))
.unwrap_or_else(|| "nothing".into())
};
let readout = format!(
"focus: {}{} · ring: {}",
name(ui),
if visible {
" · ring shown (keyboard put it there)"
} else {
""
},
region.as_deref().unwrap_or("the page's")
);
ui.with(
NodeSpec::column()
.fill()
.pad(24.0)
.gap(16.0)
.cross_align(Align::Start),
|ui| {
ui.text(
"Tab walks the ring in tree order; Enter or Space presses",
TextStyle::new(12.0).color(t.muted),
);
ui.with(NodeSpec::row().gap(10.0).cross_align(Align::Center), |ui| {
widgets::button(ui, "one", Value::str("one"));
widgets::button(ui, "two", Value::str("two"));
// Disabled: drawn dimmed, inert, and not a stop.
widgets::button_with(
ui,
"three",
"three (disabled)",
widgets::button_spec(&ui.theme(), &ui.metrics())
.disabled(true)
.on_click("three"),
None,
);
widgets::button(ui, "four", Value::str("four"));
});
ui.text(
"an editor, a switch, a focusable row — all stops",
TextStyle::new(12.0).color(t.muted),
);
ui.with(NodeSpec::row().gap(10.0).cross_align(Align::Center), |ui| {
ui.with(NodeSpec::row().width(160.0), |ui| {
self.field = Some(widgets::text_input(ui, "field", ""));
});
// The stock switch: a control role, so in the ring
// with nothing declared, and pressed by Enter or Space.
let on = self.pressed.as_deref() == Some("mute");
widgets::switch(ui, "mute", on, Value::str("mute"));
// A plain row, in the ring because it says so, with a
// `focus_bg` instead of the ring.
ui.text_in_keyed(
"row",
NodeSpec::row()
.pad_xy(12.0, 8.0)
.radius(8.0)
.bg(t.surface)
.border(1.0, t.border)
.focusable()
.focus_bg(t.accent_soft)
.on_click("row"),
"focusable row · focus_bg",
TextStyle::new(13.0),
);
// Decoration: `role = none` keeps it out of the ring
// and the access tree, `on_click` or not.
ui.text_in_keyed(
"deco",
NodeSpec::row()
.pad_xy(12.0, 8.0)
.radius(8.0)
.bg(t.sunken)
.role(Role::None)
.on_click("deco"),
"role: none — skipped",
TextStyle::new(13.0).color(t.muted),
);
});
ui.text(
"a focus_region: a ring of its own, entered on purpose",
TextStyle::new(12.0).color(t.muted),
);
ui.with(NodeSpec::row().gap(10.0).cross_align(Align::Center), |ui| {
// Tab from the page skips the whole panel; inside it,
// Tab wraps over its two buttons. A click on either
// enters it too.
self.panel = Some(
ui.with_keyed(
"panel",
NodeSpec::row()
.pad_xy(12.0, 8.0)
.gap(8.0)
.radius(8.0)
.bg(t.sunken)
.border(1.0, t.border)
.focus_region()
.label("panel"),
|ui| {
widgets::button(ui, "panel a", Value::str("panel a"));
widgets::button(ui, "panel b", Value::str("panel b"));
},
),
);
widgets::button(ui, "focus_region(panel)", Value::str("verb:panel"));
widgets::button(ui, "focus_region(None)", Value::str("verb:main"));
});
ui.text(
"the verbs: focus moved from the view",
TextStyle::new(12.0).color(t.muted),
);
ui.with(NodeSpec::row().gap(10.0), |ui| {
widgets::button(ui, "focus the field", Value::str("verb:field"));
widgets::button(ui, "focus_next", Value::str("verb:next"));
widgets::button(ui, "blur", Value::str("verb:blur"));
});
ui.text(&readout, TextStyle::new(13.0).color(t.accent));
ui.text(
&format!(
"last pressed: {}",
self.pressed.as_deref().unwrap_or("nothing")
),
TextStyle::new(12.0).color(t.faint),
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.payload.as_str() {
Some("verb:field") => self.verb = Some("field"),
Some("verb:next") => self.verb = Some("next"),
Some("verb:blur") => self.verb = Some("blur"),
Some("verb:panel") => self.verb = Some("panel"),
Some("verb:main") => self.verb = Some("main"),
Some(s) => self.pressed = Some(s.to_string()),
None => {}
}
}
}
impl Example for Focus {
const KEYS: &'static [(&'static str, &'static str)] = &[
("Tab / Shift-Tab", "walk the ring"),
("Enter / Space", "press the focused control"),
("Ctrl+Shift+I", "into the devtools dock, a region too"),
];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(760.0, 420.0)
}
/// Tab walks one, two, four (three is disabled), the field, the
/// switch, the row, the two region verbs, the three verbs, and wraps
/// — never the panel; Enter presses; a click focuses without the ring
/// showing; the region verbs move the keyboard into the panel, where
/// Tab wraps over its two buttons, and back.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 760.0, 360.0);
d.frame(self);
let tab = |d: &mut Drive<'_>, app: &mut Focus, back: bool| {
d.input(
app,
kui_native::InputEvent::Key(
kui_native::EditKey::Tab,
kui_native::Mods {
shift: back,
..Default::default()
},
),
);
};
let label = |d: &Drive<'_>| -> String {
d.core
.focus()
.and_then(|k| d.core.label_of(k).map(str::to_string))
.unwrap_or_default()
};
let mut walked = Vec::new();
for _ in 0..11 {
tab(&mut d, self, false);
walked.push(label(&d));
}
d.check(
walked
== [
"one",
"two",
"four",
"field",
"mute",
"row",
"focus_region(panel)",
"focus_region(None)",
"focus the field",
"focus_next",
"blur",
],
"Tab walks every control in tree order and skips the disabled, the decorative and the region",
)?;
d.check(d.core.focus_visible(), "and the ring shows after a Tab")?;
tab(&mut d, self, false);
d.check(label(&d) == "one", "and wraps")?;
tab(&mut d, self, true);
d.check(label(&d) == "blur", "Shift-Tab walks back")?;
tab(&mut d, self, false);
d.key(self, "enter", Default::default());
d.check(
self.pressed.as_deref() == Some("one"),
"Enter presses the focused button",
)?;
let two = d.key_of("two").ok_or("no two")?;
d.click_key(self, two);
d.frame(self);
let mute = d.key_of("mute").ok_or("no switch")?;
let r = d.rect_of(mute).ok_or("no switch rect")?;
d.click(self, r.x + r.w / 2.0, r.y + r.h / 2.0);
d.frame(self);
d.check(
label(&d) == "mute" && !d.core.focus_visible(),
"a click focuses, and the ring does not show",
)?;
let verb = d.key_of("focus the field").ok_or("no verb")?;
d.click_key(self, verb);
d.frame(self);
d.check(label(&d) == "field", "the view's `focus` verb moves it")?;
let blur = d.key_of("blur").ok_or("no blur")?;
d.click_key(self, blur);
d.frame(self);
d.check(d.core.focus().is_none(), "and `blur` clears it")?;
// The region: entered by the verb, walked on its own, left by the
// other verb — back to what the page last held.
let panel = d.key_of("panel").ok_or("no panel")?;
let enter = d.key_of("focus_region(panel)").ok_or("no verb")?;
d.click_key(self, enter);
d.frame(self);
d.check(
d.core.region() == Some(panel) && label(&d) == "panel a" && d.core.focus_visible(),
"`focus_region(panel)` enters the panel on its first stop, ring shown",
)?;
tab(&mut d, self, false);
tab(&mut d, self, false);
d.check(
label(&d) == "panel a",
"inside the panel Tab wraps over its two buttons",
)?;
tab(&mut d, self, false);
let leave = d.key_of("focus_region(None)").ok_or("no verb")?;
d.click_key(self, leave);
d.frame(self);
// Back to what the page last held: the field, from the `focus`
// verb above — the blur after it remembered nothing, since a ring
// that held nothing has nowhere better to land than its last node.
d.check(
d.core.region().is_none() && label(&d) == "field",
"`focus_region(None)` comes back to what the page last held",
)?;
d.click_key(self, enter);
d.frame(self);
d.check(
label(&d) == "panel b",
"and the panel remembered where the user was",
)
}
}
kui_devtools::main!(Focus::default());
features/hover.rs
//! Hover, declared rather than tracked. Four ways a view says what the
//! pointer's arrival means, and none of them is state the app keeps:
//!
//! - `hover_bg`: the background while the pointer is over the node — the
//! core swaps it, the view declares both colours once;
//! - `hoverable` + `Ui::is_hovered`: a node the core tracks so the *view*
//! can branch on it — a tooltip, a reveal, a badge that reads its own
//! hover on the next frame;
//! - `hover_group`: siblings that light together — a row's icon and its
//! label are one hover, whichever of them the pointer is on, asked by
//! `is_group_hovered`;
//! - `on_hover`: the arrival and the leaving as events, `enter` and
//! `leave`, per node, for a model that wants to know — the count below.
//!
//! The pointer leaving the window leaves everything; a hover survives a
//! rebuild because the node's key does.
//!
//! Run: cargo run -p kui-native --example hover [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::{Align, App, Core, NodeSpec, TextStyle, Ui, UiEvent, Value};
const ROWS: [(&str, &str); 3] = [("◆", "inbox"), ("●", "drafts"), ("▲", "sent")];
#[derive(Default)]
struct Hover {
enters: u32,
leaves: u32,
/// Which row the pointer is over, from `on_hover`.
over: Option<String>,
}
impl App for Hover {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(24.0)
.gap(18.0)
.cross_align(Align::Start),
|ui| {
ui.text(
"hover_bg · the core swaps the background",
TextStyle::new(12.0).color(t.muted),
);
ui.with(NodeSpec::row().gap(10.0), |ui| {
for (name, bg, hover) in [
("surface", t.surface, t.hover),
("accent", t.accent, t.accent_hover),
("danger", t.danger, t.danger.with_alpha(0.7)),
] {
ui.text_in_keyed(
name,
NodeSpec::row()
.pad_xy(16.0, 10.0)
.radius(8.0)
.bg(bg)
.hover_bg(hover)
.border(1.0, t.border),
name,
TextStyle::new(13.0).color(kui_native::widgets::readable_on(bg)),
);
}
});
ui.text(
"hoverable + is_hovered · the view branches on its own hover",
TextStyle::new(12.0).color(t.muted),
);
let badge = ui.child_key("badge");
let hovered = ui.is_hovered(badge);
ui.text_in_keyed(
"badge",
NodeSpec::row()
.pad_xy(14.0, 8.0)
.radius(8.0)
.bg(t.raised)
.border(1.0, if hovered { t.accent } else { t.border })
.hoverable(),
if hovered {
"hovered — the border is the view's doing"
} else {
"hover me"
},
TextStyle::new(13.0),
);
ui.text(
"hover_group · an icon and its label are one hover",
TextStyle::new(12.0).color(t.muted),
);
ui.with(
NodeSpec::column()
.width(260.0)
.gap(2.0)
.pad(6.0)
.bg(t.surface)
.radius(8.0)
.border(1.0, t.border),
|ui| {
for (icon, label) in ROWS {
let lit = ui.is_group_hovered(NodeSpec::hover_group_id(label));
let bg = if lit { t.hover } else { t.surface };
// Two nodes, one group: whichever the pointer is
// on, both read `lit`. `on_hover` stays per node
// — the group is who lights together, not who
// reports — so the count below moves as the
// pointer crosses from the icon to the label.
ui.with(NodeSpec::row().grow_width().gap(0.0), |ui| {
ui.text_in_keyed(
&format!("{label}-icon"),
NodeSpec::row()
.width(36.0)
.pad_xy(0.0, 6.0)
.main_align(Align::Center)
.bg(bg)
.radius(4.0)
.hover_group(label)
.on_hover(Value::str(label)),
icon,
TextStyle::new(13.0).color(t.accent),
);
ui.text_in_keyed(
&format!("{label}-text"),
NodeSpec::row()
.grow_width()
.pad_xy(8.0, 6.0)
.bg(bg)
.radius(4.0)
.hover_group(label)
.on_hover(Value::str(label)),
label,
TextStyle::new(13.0),
);
});
}
},
);
ui.text(
&format!(
"on_hover · {} enters, {} leaves · over: {}",
self.enters,
self.leaves,
self.over.as_deref().unwrap_or("nothing")
),
TextStyle::new(12.0).color(t.faint),
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
if ev.kind() != Some("hover") {
return;
}
let tag = ev.payload.get_str("tag").map(str::to_string);
match ev.payload.get_str("phase") {
Some("enter") => {
self.enters += 1;
self.over = tag;
}
Some("leave") => {
self.leaves += 1;
self.over = None;
}
_ => {}
}
}
}
impl Example for Hover {
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(460.0, 420.0)
}
/// The pointer moves onto a badge, a group, and away: what the frame
/// reports and what the events say agree.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 460.0, 420.0);
d.frame(self);
let badge = d.key_of("badge").ok_or("no badge")?;
d.hover(self, badge);
d.check(
d.core.is_hovered(badge),
"a hoverable node reports the pointer",
)?;
d.frame(self);
let icon = d.key_of("drafts-icon").ok_or("no drafts icon")?;
let text = d.key_of("drafts-text").ok_or("no drafts label")?;
d.hover(self, icon);
d.check(
d.core.is_group_hovered(NodeSpec::hover_group_id("drafts")),
"the pointer on the icon lights the group",
)?;
d.check(
self.over.as_deref() == Some("drafts") && self.enters == 1,
"and `on_hover` reports one enter",
)?;
d.hover(self, text);
d.check(
self.enters == 2 && self.leaves == 1 && self.over.as_deref() == Some("drafts"),
"moving to the label leaves the icon and enters the label: on_hover is per node",
)?;
d.check(
d.core.is_group_hovered(NodeSpec::hover_group_id("drafts")),
"and the group stays lit across the move",
)?;
d.input(self, kui_native::InputEvent::CursorLeft);
d.check(
self.leaves == 2 && self.over.is_none(),
"the pointer leaving the window leaves the group",
)?;
d.frame(self);
d.check(!d.core.is_hovered(badge), "and nothing is hovered")
}
}
kui_devtools::main!(Hover::default());
features/metrics.rs
//! The sizes the stock widgets are built from, and an app's own controls
//! agreeing with them (`kui_native::Metrics`, backlog T2): the palette's other
//! axis. Three densities of the same page — the stock set, `compact`, and
//! the stock set scaled up — switched by the buttons at the top, and a
//! card of the app's own that reads `ui.metrics()` for its radius and its
//! padding, so it changes with the stock button beside it without a
//! number of its own.
//!
//! Nothing here scales by itself: `env.scale` is the renderer's and comes
//! after; a density is the app's choice, made here by a click.
//!
//! Run: cargo run -p kui-native --example metrics [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::menu::MenuItem;
use kui_native::widgets;
use kui_native::{Align, App, Core, Metrics, NodeSpec, TextStyle, Ui, UiEvent, Value};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Density {
Comfortable,
Compact,
Large,
}
impl Density {
fn metrics(self) -> Metrics {
match self {
Density::Comfortable => Metrics::comfortable(),
Density::Compact => Metrics::compact(),
Density::Large => Metrics::comfortable().scaled(1.4),
}
}
fn name(self) -> &'static str {
match self {
Density::Comfortable => "comfortable",
Density::Compact => "compact",
Density::Large => "large",
}
}
}
struct Page {
density: Density,
menu: Option<(f32, f32)>,
}
impl App for Page {
fn view(&mut self, ui: &mut Ui<'_>) {
// The choice is made before the frame's widgets are built, so
// every one of them — including the buttons that choose — is in
// the chosen density.
ui.core().set_metrics(self.density.metrics());
let t = ui.theme();
let m = ui.metrics();
ui.with(
NodeSpec::column()
.fill()
.pad(24.0)
.gap(20.0)
.bg(t.bg)
.on_context_menu(Value::map([("kind", "contextmenu".into())])),
|ui| {
ui.text("metrics", TextStyle::new(22.0).color(t.fg));
ui.with(NodeSpec::row().gap(8.0), |ui| {
for d in [Density::Comfortable, Density::Compact, Density::Large] {
// The chosen one in the accent, the others in the
// surface: `accent` asks for the whole family, so
// it is declared only where it is wanted.
let spec = widgets::button_spec(&t, &m).on_click(Value::str(d.name()));
let spec = if d == self.density {
spec.accent()
} else {
spec.bg(t.surface).hover_bg(t.hover).pressed_bg(t.pressed)
};
widgets::button_with(ui, d.name(), d.name(), spec, None);
}
});
ui.text(
&format!(
"control {}px in {}×{} · radius {} · menu {} wide · titlebar {}",
m.control_text,
m.control_pad_x,
m.control_pad_y,
m.radius,
m.menu_width,
m.titlebar_h
),
TextStyle::new(12.0).color(t.muted).mono(),
);
// The stock widgets, as the metrics build them.
ui.with(NodeSpec::row().gap(16.0).cross_align(Align::Center), |ui| {
let spec = widgets::button_spec(&t, &m).on_click("noop");
widgets::button_with(
ui,
"a stock button",
"a stock button",
spec,
Some("a stock tooltip, in the hint metrics"),
);
ui.with(NodeSpec::column().width(200.0), |ui| {
widgets::text_input(ui, "field", "a stock field");
});
});
// A control of the app's own, built from the same numbers:
// it agrees with the stock button on its corner and its
// padding at every density, and copies nothing.
ui.with_keyed(
"own",
NodeSpec::row()
.pad_xy(m.control_pad_x, m.control_pad_y)
.radius(m.radius)
.gap(m.control_pad_x)
.bg(t.surface)
.border(1.0, t.border)
.cross_align(Align::Center)
// Hoverable so the drive can read its box off the
// hit list; it lights on hover, which is harmless.
.hoverable()
.hover_bg(t.hover),
|ui| {
ui.text(
"a card of the app's own",
TextStyle::new(m.control_text).color(t.fg),
);
ui.text(
"same radius, same padding",
TextStyle::new(m.hint_text).color(t.muted),
);
},
);
ui.text(
"right-click for the menu; hover the button for a tooltip",
TextStyle::new(11.0).color(t.faint),
);
if let Some((x, y)) = self.menu {
widgets::context_menu(
ui,
kui_native::Vec2::new(x, y),
&[MenuItem::new("A row"), MenuItem::new("Another")],
);
}
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.payload.as_str() {
Some("comfortable") => self.density = Density::Comfortable,
Some("compact") => self.density = Density::Compact,
Some("large") => self.density = Density::Large,
_ => {}
}
match ev.kind() {
Some("contextmenu") => {
let at = |k| ev.payload.get(k).and_then(Value::as_float).unwrap_or(0.0) as f32;
self.menu = Some((at("x"), at("y")));
}
Some("dismiss") => self.menu = None,
_ => {}
}
}
}
impl Example for Page {
/// Compact shrinks the stock button and the app's own card together,
/// large grows them, and the corpus's stock set is what "comfortable"
/// draws.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 800.0, 500.0);
let boxes = |d: &mut Drive<'_>, app: &mut Page| {
d.frame(app);
let stock = d.key_of("a stock button").unwrap();
let own = d.key_of("own").unwrap();
(d.rect_of(stock).unwrap(), d.rect_of(own).unwrap())
};
let (stock, own) = boxes(&mut d, self);
d.check(
d.core.metrics() == &Metrics::default(),
"comfortable is the stock set",
)?;
let compact = d.key_of("compact").unwrap();
d.click_key(self, compact);
let (stock2, own2) = boxes(&mut d, self);
d.check(stock2.h < stock.h, "compact shrinks the stock button")?;
d.check(own2.h < own.h, "and the app's own card with it")?;
let large = d.key_of("large").unwrap();
d.click_key(self, large);
let (stock3, own3) = boxes(&mut d, self);
d.check(stock3.h > stock.h, "large grows the stock button")?;
d.check(own3.h > own.h, "and the card")?;
d.check(
d.core.metrics().titlebar_h == Metrics::default().titlebar_h,
"scaled multiplies every density and leaves the platform's titlebar",
)?;
d.check(
d.core.metrics().control_text == Metrics::default().control_text * 1.4,
"the rest scales",
)?;
Ok(())
}
}
kui_devtools::main!(Page {
density: Density::Comfortable,
menu: None,
});
features/modal.rs
//! `modal` (`docs/adr/0003-modal-surfaces.md`): a dialog over a form, and
//! a confirm over the dialog — the two things a page of its own can show
//! that a menu cannot.
//!
//! - **Entry.** The dialog opens with focus on its *Save* button, because
//! that button declares `initial_focus`; without it focus would land on
//! the first stop, the field. The ring is the dialog: Tab wraps inside
//! it and never reaches the form.
//! - **Nesting.** *Discard* opens a confirm *inside* the dialog. The last
//! `modal` declared wins, so the dialog goes as inert as the form under
//! it — a stack without a stack (decision 2).
//! - **Escape is the app's call.** On the confirm it closes the confirm;
//! on the dialog it closes the dialog when the field is untouched and
//! asks the confirm otherwise (decision 6: the core asks, the app
//! decides). A press outside — the pointer's, or a reader's click on
//! a node behind the modal — is the same `dismiss` with `reason:
//! "outside"`, answered the same way.
//! - **Restore.** When the dialog goes, focus returns to the button that
//! opened it (decision 4), so a keyboard user is where they were.
//!
//! Run: cargo run -p kui-native --example modal [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{Align, App, Core, FloatConfig, NodeSpec, TextStyle, Ui, UiEvent, Value};
#[derive(Default)]
struct Page {
dialog: bool,
confirm: bool,
/// What the last dialog saved, shown on the form.
saved: Option<String>,
/// The dialog field's text, read off the editor every frame (a
/// `changed` event says *that* it changed; the text is the core's to
/// read), which is what "untouched" is judged by.
draft: String,
}
impl App for Page {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column().fill().pad(24.0).gap(14.0).bg(t.bg),
|ui| {
ui.text("modal", TextStyle::new(22.0).color(t.fg));
ui.text(
"the form is the main ring; a dialog confines it, a confirm over the dialog confines it again",
TextStyle::new(12.0).color(t.muted),
);
ui.with(NodeSpec::row().gap(10.0), |ui| {
widgets::button(ui, "open dialog", Value::str("open"));
widgets::button(ui, "another button", Value::str("noop"));
});
ui.with(NodeSpec::column().width(260.0), |ui| {
widgets::text_input(ui, "form field", "");
});
if let Some(s) = &self.saved {
ui.text(&format!("saved: {s:?}"), TextStyle::new(12.0).color(t.muted));
}
},
);
if self.dialog {
let mut draft = None;
ui.with_keyed(
"dialog",
NodeSpec::column()
.float(FloatConfig::viewport().inside(Align::Center, Align::Center))
.modal("dialog")
.role(kui_native::Role::Dialog)
.label("Edit")
.width(320.0)
.pad(18.0)
.gap(12.0)
.bg(t.raised)
.border(1.0, t.border_strong)
.radius(10.0),
|ui| {
ui.text("edit", TextStyle::new(16.0).color(t.fg));
let name = widgets::text_input(ui, "name", "");
draft = ui.edit_text(name);
ui.with(
NodeSpec::row().grow_width().gap(8.0).main_align(Align::End),
|ui| {
widgets::button(ui, "discard", Value::str("discard"));
// `initial_focus`: the dialog opens here, not on the
// field — the entry precedence of ADR 0003 / 0007.
let spec = widgets::button_spec(&ui.theme(), &ui.metrics())
.accent()
.on_click("save")
.initial_focus();
widgets::button_with(ui, "save", "save", spec, None);
},
);
// The confirm, declared inside the dialog: the later
// `modal` is the one in effect.
if self.confirm {
ui.with_keyed(
"confirm",
NodeSpec::column()
.float(FloatConfig::viewport().inside(Align::Center, Align::Center))
.modal("confirm")
.role(kui_native::Role::Dialog)
.label("Discard changes?")
.width(240.0)
.pad(16.0)
.gap(10.0)
.bg(t.raised)
.border(1.0, t.danger)
.radius(8.0),
|ui| {
ui.text("discard changes?", TextStyle::new(14.0).color(t.fg));
ui.with(
NodeSpec::row().grow_width().gap(8.0).main_align(Align::End),
|ui| {
widgets::button(ui, "keep editing", Value::str("keep"));
widgets::button(ui, "discard them", Value::str("really"));
},
);
},
);
}
},
);
if let Some(text) = draft {
self.draft = text;
}
}
}
fn on_event(&mut self, ev: UiEvent) {
let kind = ev.kind();
if kind == Some("dismiss") {
// Which modal asked: the tag is the node's.
match ev.payload.get_str("tag") {
Some("confirm") => self.confirm = false,
Some("dialog") if self.draft.is_empty() => self.dialog = false,
Some("dialog") => self.confirm = true,
_ => {}
}
return;
}
match ev.payload.as_str() {
Some("open") => {
self.dialog = true;
self.confirm = false;
self.draft.clear();
}
Some("save") => {
self.saved = Some(self.draft.clone());
self.dialog = false;
}
Some("discard") => self.confirm = true,
Some("keep") => self.confirm = false,
Some("really") => {
self.confirm = false;
self.dialog = false;
}
_ => {}
}
}
}
impl Example for Page {
/// Entry on `initial_focus`, the ring confined, the confirm over the
/// dialog making it inert, Escape routed by tag, and focus restored to
/// the opener.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 700.0, 500.0);
d.frame(self);
let open = d.key_of("open dialog").unwrap();
let other = d.key_of("another button").unwrap();
d.focus(self, open);
d.frame(self);
d.click_key(self, open);
d.frame(self);
let save = d.key_of("save").unwrap();
let discard = d.key_of("discard").unwrap();
let name = d.key_of("name").unwrap();
d.check(
d.core.focus() == Some(save),
"the dialog opens on save (initial_focus)",
)?;
// Tab wraps inside the dialog: name → discard → save → name.
d.key(self, "tab", Default::default());
d.check(d.core.focus() == Some(name), "Tab wraps to the field")?;
d.key(self, "tab", Default::default());
d.check(d.core.focus() == Some(discard), "then discard")?;
d.key(self, "tab", Default::default());
d.check(
d.core.focus() == Some(save),
"then save again, never the form",
)?;
// The form is inert: a reader's click on its other button is the
// press outside (decision 6, backlog RG13) — one `dismiss` on the
// dialog with `reason: "outside"`, nothing on the button — which
// this app answers as it answers Escape: an untouched dialog
// closes, and focus is back on the opener.
let evs = d.click_key(self, other);
let outside = evs.len() == 1
&& evs[0].key != other
&& evs[0].kind() == Some("dismiss")
&& evs[0].payload.get_str("tag") == Some("dialog")
&& evs[0].payload.get_str("reason") == Some("outside");
d.check(
outside,
"the form under the dialog is inert: the click is the press outside",
)?;
d.frame(self);
d.check(
!self.dialog,
"an untouched dialog closes on the press outside",
)?;
d.check(
d.core.focus() == Some(open),
"and focus is back on the opener",
)?;
d.click_key(self, open);
d.frame(self);
// Escape on an untouched dialog closes it, and focus comes back
// to the button that opened it.
d.key(self, "escape", Default::default());
d.frame(self);
d.check(!self.dialog, "Escape closes an untouched dialog")?;
d.check(d.core.focus() == Some(open), "focus is back on the opener")?;
// Open again, type, then Escape asks the confirm instead.
d.click_key(self, open);
d.frame(self);
let name = d.key_of("name").unwrap();
d.focus(self, name);
d.text(self, "hi");
d.frame(self);
d.key(self, "escape", Default::default());
d.frame(self);
d.check(
self.dialog && self.confirm,
"Escape on a dirty dialog asks the confirm",
)?;
let keep = d.key_of("keep editing").unwrap();
let really = d.key_of("discard them").unwrap();
d.check(
d.core.focus() == Some(keep),
"the confirm opens on its first stop, and holds focus",
)?;
// The dialog is now as inert as the form: a reader's click on its
// save button is the press outside the *confirm* — the modal in
// effect — and nothing on save; this app closes the confirm on
// any dismiss, so the dialog stands, dirty, and the field has
// focus again. Escape then asks the confirm once more.
let save = d.key_of("save").unwrap();
let evs = d.click_key(self, save);
let outside = evs.len() == 1
&& evs[0].key != save
&& evs[0].kind() == Some("dismiss")
&& evs[0].payload.get_str("tag") == Some("confirm");
d.check(
outside,
"the dialog under the confirm is inert: the click asks the confirm",
)?;
d.frame(self);
d.check(
self.dialog && !self.confirm,
"the press outside closes the confirm, and the dialog stands",
)?;
d.check(
d.core.focus() == Some(name),
"focus returns to the field the confirm displaced",
)?;
d.key(self, "escape", Default::default());
d.frame(self);
d.check(self.confirm, "Escape asks the confirm again")?;
d.key(self, "escape", Default::default());
d.frame(self);
d.check(
self.dialog && !self.confirm,
"Escape on the confirm closes only it",
)?;
d.check(
d.core.focus() == Some(name),
"and focus returns to the field the confirm displaced",
)?;
// Discard for real: both go, focus back on the opener.
d.click_key(self, discard);
d.frame(self);
d.click_key(self, really);
d.frame(self);
d.check(!self.dialog && !self.confirm, "discarding closes both")?;
d.check(
d.core.focus() == Some(open),
"focus is back on the opener again",
)?;
Ok(())
}
}
kui_devtools::main!(Page::default());
features/popup.rs
//! A combobox whose list is taller than the window: the case
//! `FloatConfig::fit` cannot place, and so the reason `WindowKind::Popup`
//! exists (`docs/adr/0004-multi-window.md`, decision 9). The window is 360
//! x 150; the list is twelve rows and 300 tall, so opening it visibly
//! extends past the frame instead of being clamped inside it.
//!
//! Everything here is the declaration. The field carries `on_layout`, so
//! the app is told the rect it occupies — that rect *is* the anchor, which
//! is why a popup needs no new geometry query. While `open` is true the
//! view declares a second window, `"menu"`, of kind popup; when it is false
//! the declaration stops and the window closes. Nothing calls a "close
//! popup" function, because there isn't one.
//!
//! Four things to watch with it running:
//!
//! - The **field keeps its focus ring** while the list is up, and the arrow
//! keys walk the list. A popup does not take OS focus, so the owner still
//! believes it has the keyboard; the runner routes the keys on.
//! - **Escape and a press outside** produce `{kind:"dismiss"}` — the same
//! event a `modal` node gets — and close nothing by themselves. This app
//! answers by clearing `open`, which is the whole of "closing" a popup.
//! - The **list draws past the bottom edge** of the main window, which an
//! in-window float cannot do. Shrink the window and it still does.
//! - **Press the field, drag into the list, release on a row** — the native
//! select gesture, in one gesture with no second click
//! (`docs/adr/0009-press-drag-release-into-a-popup.md`). None of it is in
//! this file: the OS gives the whole drag to the window the press landed
//! in, and the runner translates the moves into the popup's coordinates
//! and synthesises the press-and-release the popup never saw. All this
//! app does is open on `on_drag`'s `start` phase instead of waiting for a
//! click, and **set** rather than toggle — which is safe because a press
//! that dismisses a popup is consumed rather than passed through, so a
//! press on the open field closes the menu and cannot reopen it.
//!
//! Run: cargo run --example popup
use kui_devtools::Example;
use kui_native::{
App, Color, CursorShape, NodeSpec, Rect, TextStyle, Ui, UiEvent, Value, WindowConfig,
};
/// The list is twelve rows of 24 plus the panel's padding — deliberately
/// twice the window's height, so "taller than the window" is not a detail
/// of how it was resized.
const ITEMS: [&str; 12] = [
"Aluminium",
"Beryllium",
"Cadmium",
"Chromium",
"Iridium",
"Lithium",
"Magnesium",
"Osmium",
"Palladium",
"Rhodium",
"Titanium",
"Vanadium",
];
const ROW_H: f32 = 24.0;
const MENU_W: f32 = 200.0;
const MENU_H: f32 = ITEMS.len() as f32 * ROW_H + 12.0;
#[derive(Default)]
struct Combo {
/// Which item is chosen, and which the arrows are sitting on. Both are
/// app state: the core stores no selection for a list an app draws.
chosen: usize,
cursor: usize,
/// Whether the list is declared this frame. The one flag that opens and
/// closes an OS window.
open: bool,
/// The field's rect, as `on_layout` last reported it — the anchor the
/// popup is placed against, in this window's own coordinates.
field: Rect,
}
impl App for Combo {
fn view(&mut self, ui: &mut Ui<'_>) {
// Every window of the app runs this same `view`; `window_name`
// says which one is being drawn (ADR 0004 decision 12).
if &*ui.window_name() == "menu" {
self.list(ui);
return;
}
// The list exists exactly while the field says so. The config —
// kind, size and anchor — is read on the frame it opens and never
// again, so a popup that must follow a moving anchor stops being
// declared and starts again.
if self.open {
ui.window("menu", WindowConfig::popup(self.field, MENU_W, MENU_H));
}
let t = ui.theme();
ui.with(
NodeSpec::column()
.pad(16.0)
.gap(10.0)
// The window's whole surface, not just the content box: a
// `Fit` column would paint its background around the text
// and leave the rest of the window whatever the renderer
// cleared it to.
.fill()
.bg(t.bg),
|ui| {
ui.text("Alloy", TextStyle::new(12.0).color(t.muted));
// The field. `on_layout` is the only thing here that has
// anything to do with the popup: it reports this rect, and
// the rect is the anchor.
ui.with_keyed(
"field",
NodeSpec::row()
.pad_xy(10.0, 6.0)
.gap(8.0)
.width(MENU_W)
.bg(t.sunken)
.hover_bg(t.sunken.mix(t.accent, 0.10))
.radius(5.0)
.focusable()
.label("Alloy")
.on_layout("field")
// Two ways in, and both of them *open*. `on_drag`
// is the press: its `start` phase arrives on
// mouse-down, before any slop, which is what makes
// press-drag-release one gesture. `on_click` is the
// release that never moved, and the keyboard, and
// assistive technology — idempotent after a `start`
// that already opened the list.
.on_drag(Value::Null)
.on_click(Value::map([("kind", Value::str("open"))]))
// The hand is declared, never derived: a field
// that can be pressed says so with `cursor`, and
// nothing about its `on_drag` makes it a grab.
.cursor(CursorShape::Pointer),
|ui| {
ui.text(ITEMS[self.chosen], TextStyle::new(14.0).color(t.fg));
ui.leaf(NodeSpec::row().grow_width());
ui.text("v", TextStyle::new(11.0).color(t.muted));
},
);
ui.text(
if self.open {
"Release on a row, or press outside to dismiss"
} else {
"Press and drag into the list, or Tab here and press Space"
},
TextStyle::new(11.0).color(t.faint),
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
let kind = ev.kind();
match kind {
// The field's rect, every time layout changes it. Stored, not
// acted on: it is what the *next* declaration will carry.
Some("layout") => {
let n = |k: &str| ev.payload.get(k).and_then(Value::as_float).unwrap_or(0.0) as f32;
self.field = Rect::new(n("x"), n("y"), n("w"), n("h"));
}
// Everything opens; only a dismissal or a choice closes. The
// press (`drag`'s `start`) and the click that follows a
// stationary release both land here, and assigning rather than
// toggling is what lets them: neither has to know which press
// it is answering.
Some("drag") if ev.payload.get_str("phase") == Some("start") => {
self.open = true;
self.cursor = self.chosen;
}
Some("open") => {
self.open = true;
self.cursor = self.chosen;
}
// The same event a `modal` float would have given, on the
// window instead of on a node — which is the whole of ADR 0004
// decision 9's "changes its declaration and not its handler".
// The core closed nothing; this line is what closes it.
Some("dismiss") => self.open = false,
Some("choose") => {
if let Some(i) = ev.payload.get_int("i") {
self.chosen = i as usize;
}
self.open = false;
}
// The arrows arrive on the popup's core, because the runner
// routes the owner's keyboard there while a non-activating
// popup is up. The field keeps its ring throughout.
// Presses only: the list's sink never asked for releases.
Some("key") => match ev.payload.get_str("code") {
Some("down") => self.cursor = (self.cursor + 1) % ITEMS.len(),
Some("up") => self.cursor = (self.cursor + ITEMS.len() - 1) % ITEMS.len(),
Some("enter") => {
self.chosen = self.cursor;
self.open = false;
}
_ => {}
},
_ => {}
}
}
}
impl Combo {
/// The popup window's own frame: a plain list, drawn by the same
/// `view`. Nothing about it says "popup" — the declaration did that.
fn list(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
let root = ui.with(
NodeSpec::column()
.pad(6.0)
// The popup's window *is* the panel, so its root fills it:
// the rows grow across, and there is nothing else to share
// the height with.
.fill()
// A popup window is a float that got its own surface, so
// it takes the role a menu or a tooltip takes.
.bg(t.raised)
.border(1.0, t.border_strong)
// The list owns its keyboard: `on_key` makes it a sink, so
// the arrows walk the list rather than moving a focus ring
// the user cannot see — the visible ring is over in the
// window that opened this one.
.on_key(Value::map([("kind", Value::str("key"))])),
|ui| {
for (i, item) in ITEMS.iter().enumerate() {
let on = i == self.cursor;
ui.with_keyed(
item,
NodeSpec::row()
.pad_xy(10.0, 4.0)
.height(ROW_H)
.grow_width()
// The cursor row is a wash, not a fill, for
// the reason the stock menu's is: the label's
// colour is chosen before the core resolves a
// `hover_bg`, so a fill would be unreadable on
// a light base for a frame.
.bg(if on {
t.accent_soft
} else {
Color::TRANSPARENT
})
.hover_bg(t.raised.mix(t.accent, 0.10))
.radius(4.0)
.selected(i == self.chosen)
// The rows are a hand too, each for itself.
.cursor(CursorShape::Pointer)
.on_click(Value::map([
("kind", Value::str("choose")),
("i", Value::Int(i as i64)),
])),
|ui| {
ui.text(
item,
TextStyle::new(13.0).color(if on { t.fg } else { t.muted }),
);
},
);
}
},
);
// The sink has to hold key focus to be handed anything, and this
// window's own ring is invisible to the user — the ring they see
// belongs to the field. Edge-triggered, so asking every frame is
// asking once.
ui.take_key_focus(root);
}
}
impl Example for Combo {
const KEYS: &'static [(&'static str, &'static str)] = &[
("Enter / ↓", "open the list"),
("↑ ↓", "walk it"),
("Esc", "close it"),
];
/// Small on purpose: the list is twice this tall, so it cannot be an
/// in-window float however `fit` is asked to place it.
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(360.0, 150.0)
}
/// No dock unless asked: the point is a list taller than the frame it
/// opens from, and a dock beside it would make the frame tall enough
/// to hold it. `--dock side` still works for the readout.
fn dock(&self) -> kui_devtools::Dock {
kui_devtools::Dock::Off
}
}
kui_devtools::main!(Combo::default());
features/selection.rs
//! Selection as a scope (`docs/adr/0017-selection-as-a-scope.md`), over
//! every kind of text it touches. One `selectable` row on a container
//! makes everything inside it one selection — plain `text`, a rich
//! paragraph of spans, and whatever else is in the box — so a drag across
//! the card takes the heading, both paragraphs and the footer as one run;
//! double-click takes a word, triple-click a line; Cmd/Ctrl-C copies the
//! plain text with its bold and italic carried beside it. A `cells` grid
//! is a scope too, selecting in cells rather than bytes; an `edit` has a
//! selection of its own, the caret's, which the same keys and drags move.
//!
//! There is one selection per window: starting one in any scope clears
//! the others, and `Ui::selection_text` / `Ui::cell_selection` read back
//! whichever it is. The readouts sit *outside* the scopes on purpose:
//! inside, a label that reports the selection's length would be part of
//! what Select All selects, and the number would chase itself.
//!
//! Force-clicking a word (a Force Touch trackpad) selects it and opens
//! the system's Look Up panel.
//!
//! Run: cargo run -p kui-native --example selection [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::{
Align, App, Cell, CellGrid, Core, EditOptions, FontFamily, NodeSpec, Span, TextStyle, Theme, Ui,
};
const LINES: [&str; 4] = [
"~/kui $ cargo test -p kui-core",
"running 23 tests ..............",
"test result: ok. 23 passed; 0 failed",
"~/kui $ ",
];
const COLS: usize = 40;
struct Selection;
fn card(t: &Theme) -> NodeSpec {
NodeSpec::column()
.grow_width()
.max_width(560.0)
.pad(20.0)
.gap(10.0)
.bg(t.surface)
.radius(12.0)
.border(1.0, t.border)
}
impl App for Selection {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
// What is selected, whichever scope holds it.
let readout = match ui.cell_selection() {
Some(sel) => {
let (a, b) = sel.ordered();
format!("cells: lines {}–{} selected", a.line, b.line)
}
None => ui
.selection_text()
.filter(|s| !s.is_empty())
.map(|s| format!("text: {} characters selected", s.chars().count()))
.unwrap_or_else(|| "drag across anything below".into()),
};
ui.with(
NodeSpec::column()
.fill()
.cross_align(Align::Center)
.pad(24.0)
.gap(12.0)
.scroll_y(),
|ui| {
// One scope over a heading, a rich paragraph and a plain
// line: the row is on the container, and every run inside
// selects as one.
ui.with_keyed("article", card(&t).selectable(), |ui| {
ui.text("Selectable article", TextStyle::new(22.0));
ui.rich_text(
&[
Span::new("Drag across this paragraph and the whole card selects as "),
Span::new("one run of text").bold().color(t.accent),
Span::new(
" — three labels, one selection. Double-click a word, \
triple-click a line; Cmd-C copies, with the ",
),
Span::new("bold").bold(),
Span::new(" and the "),
Span::new("italic").italic(),
Span::new(" carried beside the plain text."),
],
TextStyle::new(15.0).line_height(24.0),
);
ui.text(
"The footer is inside the scope too, so Select All takes it.",
TextStyle::new(13.0).color(t.muted),
);
});
// A grid is its own scope, in cells.
let mut cells = vec![Cell::new(' ', t.muted.to_hex(), 0); LINES.len() * COLS];
for (r, line) in LINES.iter().enumerate() {
for (c, ch) in line.chars().take(COLS).enumerate() {
cells[r * COLS + c] = Cell::new(ch, t.fg.to_hex(), 0);
}
}
let grid = CellGrid {
rows: LINES.len(),
cols: COLS,
cells: &cells,
style: TextStyle::new(13.0).family(FontFamily::Mono).color(t.fg),
cursor: None,
origin_line: 100,
};
ui.with(card(&t).pad(12.0), |ui| {
ui.text(
"A terminal selects in cells (Alt-drag a rectangle)",
TextStyle::new(12.0).color(t.muted),
);
ui.cells_keyed(
"term",
&grid,
NodeSpec::column()
.grow_width()
.pad(10.0)
.radius(6.0)
.bg(t.sunken)
.selectable(),
);
});
// An editor's selection is the caret's: the same drag, the
// same double-click, and Shift with the arrows.
ui.with(card(&t).pad(12.0), |ui| {
ui.text(
"An editor's selection is its own",
TextStyle::new(12.0).color(t.muted),
);
ui.text_edit(
"note",
"Select in here with a drag, a double-click, or Shift and the arrows.",
&EditOptions::default(),
NodeSpec::column()
.grow_width()
.pad(10.0)
.radius(6.0)
.bg(t.bg)
.border(1.0, t.border)
.label("note"),
);
});
ui.text_in(
NodeSpec::row()
.grow_width()
.max_width(560.0)
.main_align(Align::End),
&readout,
TextStyle::new(12.0).color(t.accent),
);
},
);
}
}
impl Example for Selection {
const KEYS: &'static [(&'static str, &'static str)] = &[
("drag", "select"),
("double / triple click", "a word / a line"),
("⌘A", "everything in the scope"),
("⌘C", "copy"),
];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(620.0, 620.0)
}
/// Select All in the article takes all three runs as one; a drag in
/// the grid clears it and selects cells instead — one selection per
/// window.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 620.0, 620.0);
d.frame(self);
let article = d.key_of("article").ok_or("no article")?;
d.core.select_all_in(article);
d.frame(self);
let text = d.core.selection_text().unwrap_or_default();
d.check(
text.contains("Selectable article") && text.contains("Select All takes it"),
"Select All takes the heading, the paragraph and the footer as one",
)?;
let html = d.core.selection_html().unwrap_or_default();
d.check(
html.contains("<b>") || html.contains("bold"),
"and the copy carries the bold",
)?;
let term = d.key_of("term").ok_or("no grid")?;
let r = d.rect_of(term).ok_or("the grid has no rect")?;
d.input(
self,
kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(r.x + 20.0, r.y + 14.0)),
);
d.input(self, kui_native::InputEvent::mouse_down(1));
d.input(
self,
kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(r.x + 200.0, r.y + 40.0)),
);
d.input(self, kui_native::InputEvent::mouse_up());
d.frame(self);
d.check(
d.core.cell_selection().is_some(),
"a drag in the grid selects cells",
)?;
d.check(
d.core.selection_text().is_none_or(|s| s.is_empty()),
"and the article's selection is gone: one selection per window",
)
}
}
kui_devtools::main!(Selection);
features/spring.rs
//! A spring, tuned by eye: the two numbers a person means when they say a
//! motion feels right, and nothing from the physics lesson.
//!
//! - `transition(ms)`: how long the spring takes to get there;
//! - `bounce`: how far it overshoots — 0 glides in, 0.5 visibly bounces,
//! 0.9 rings a while (the most there is: a spring at 1 never settles).
//!
//! Two sliders set them, and four buttons set the bounce each spring
//! easing is named for — `smooth` 0, `snappy` 0.15, `spring` 0.25,
//! `bouncy` 0.5. Click the stage and a bar and a racer spring across it
//! with those numbers; click again mid-flight and they turn around with
//! the momentum they had, which is what a spring is for.
//!
//! Nothing here computes a curve: the view declares a width and an anchor
//! and the spring's two numbers, and the core is between the last frame
//! and this one.
//!
//! Run: cargo run -p kui-native --example spring [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{
Align, App, Core, Easing, FloatConfig, KeyMods, NodeSpec, Role, TextStyle, Ui, UiEvent, Value,
};
/// The spring easings, each a named bounce.
const PRESETS: [(&str, Easing); 4] = [
("smooth", Easing::Smooth),
("snappy", Easing::Snappy),
("spring", Easing::Spring),
("bouncy", Easing::Bouncy),
];
/// The bar's two widths, and the lane both springs cross.
const NARROW: f32 = 80.0;
const WIDE: f32 = 360.0;
const LANE: f32 = 420.0;
struct Spring {
duration_ms: f32,
bounce: f32,
far: bool,
}
impl Default for Spring {
fn default() -> Self {
Self {
duration_ms: 500.0,
bounce: Easing::Spring.bounce().unwrap_or(0.0),
far: false,
}
}
}
fn tag(kind: &str) -> Value {
Value::map([("kind", Value::str(kind))])
}
impl App for Spring {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
let m = ui.metrics();
let heading = |ui: &mut Ui<'_>, s: &str| {
ui.text(s, TextStyle::new(12.0).color(t.muted));
};
// Every spring on the page is this one: a duration and a bounce.
let spring = |spec: NodeSpec| {
spec.transition(self.duration_ms)
.easing(Easing::Spring)
.bounce(self.bounce)
};
ui.with(
NodeSpec::column()
.fill()
.pad(24.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg),
|ui| {
heading(ui, "how long it takes to get there");
ui.with(NodeSpec::row().gap(12.0).cross_align(Align::Center), |ui| {
widgets::slider_with(
ui,
"Duration",
widgets::slider_spec(&m)
.width(240.0)
.value_now(self.duration_ms)
.value_min(100.0)
.value_max(1500.0)
.value_step(50.0)
.value_text(format!("{:.0} ms", self.duration_ms))
.on_change(tag("duration")),
None,
);
ui.text(
&format!("{:.0} ms", self.duration_ms),
TextStyle::new(13.0).color(t.fg),
);
});
heading(ui, "how far it overshoots");
ui.with(NodeSpec::row().gap(12.0).cross_align(Align::Center), |ui| {
widgets::slider_with(
ui,
"Bounce",
widgets::slider_spec(&m)
.width(240.0)
.value_now(self.bounce)
.value_min(0.0)
.value_max(kui_native::MAX_BOUNCE)
.value_step(0.05)
.value_text(format!("bounce {:.2}", self.bounce))
.on_change(tag("bounce")),
None,
);
ui.text(
&format!("{:.2}", self.bounce),
TextStyle::new(13.0).color(t.fg),
);
});
ui.with(NodeSpec::row().gap(8.0), |ui| {
for (name, _) in PRESETS {
widgets::button(ui, name, tag(name));
}
});
heading(
ui,
"click the stage — and again mid-flight: it turns around with its momentum",
);
ui.with_keyed(
"stage",
NodeSpec::column()
.width(LANE + 16.0)
.pad(8.0)
.gap(12.0)
.bg(t.sunken)
.radius(8.0)
.on_click(tag("go"))
.label("Stage: spring the bar and the racer across"),
|ui| {
// The width is a layout input, so it springs: past
// its target and back, by as much as the bounce.
ui.leaf_keyed(
"bar",
spring(
NodeSpec::row()
.width(if self.far { WIDE } else { NARROW })
.height(20.0)
.radius(4.0)
.bg(t.accent),
),
);
// A float between two anchors, its position eased
// with `slide` — the same spring.
ui.with(NodeSpec::row().width(LANE).height(24.0), |ui| {
let side = if self.far { Align::End } else { Align::Start };
ui.leaf_keyed(
"racer",
spring(
NodeSpec::row()
.float(FloatConfig::parent().inside(side, Align::Center))
.size(24.0, 24.0)
.radius(12.0)
.bg(t.success),
)
.slide(),
);
});
},
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
let tag = ev
.payload
.get("tag")
.and_then(|t| t.get("kind"))
.and_then(Value::as_str);
match (ev.kind(), tag) {
// A slider's proposal: store it, and the next frame draws it.
(Some("change"), Some("duration")) => {
self.duration_ms = ev.payload.get_float("value").unwrap_or(500.0) as f32;
}
(Some("change"), Some("bounce")) => {
self.bounce = ev.payload.get_float("value").unwrap_or(0.0) as f32;
}
(Some("go"), _) => self.far = !self.far,
(Some(kind), _) => {
if let Some((_, e)) = PRESETS.iter().find(|(name, _)| *name == kind) {
self.bounce = e.bounce().unwrap_or(0.0);
}
}
_ => {}
}
}
}
impl Example for Spring {
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(520.0, 420.0)
}
/// The bounce is what the eye reads: `smooth` carries the bar to its
/// width and no further, `bouncy` carries it past and back, and a
/// retarget mid-flight keeps moving the way it was going before it
/// turns. The sliders store what they propose.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 520.0, 420.0);
d.frame(self);
d.frame(self);
let press = |d: &mut Drive<'_>, app: &mut Spring, label: &str| -> Result<(), String> {
let key = d.key_of(label).ok_or(format!("no {label}"))?;
d.click_key(app, key);
d.frame(app);
Ok(())
};
let bar = d.key_of("bar").ok_or("no bar")?;
let width = |d: &Drive<'_>| d.rect_of(bar).map_or(0.0, |r| r.w);
// The widest the bar gets over `secs` of 60 Hz frames.
let widest = |d: &mut Drive<'_>, app: &mut Spring, secs: f64| {
let mut max = 0.0f32;
for _ in 0..(secs * 60.0) as usize {
d.advance(1.0 / 60.0);
d.frame(app);
max = max.max(width(d));
}
max
};
press(&mut d, self, "smooth")?;
d.check(self.bounce == 0.0, "smooth is no bounce")?;
press(&mut d, self, "stage")?;
let smooth = widest(&mut d, self, 2.0);
d.check(
smooth <= WIDE + 0.5 && smooth > WIDE - 0.5,
&format!("smooth reaches {WIDE} and stops there: {smooth}"),
)?;
d.check(!d.core.animating(), "and comes to rest")?;
press(&mut d, self, "bouncy")?;
press(&mut d, self, "stage")?;
widest(&mut d, self, 2.0);
press(&mut d, self, "stage")?;
let bouncy = widest(&mut d, self, 2.0);
d.check(
bouncy > WIDE + 20.0,
&format!("bouncy carries it past {WIDE}: {bouncy}"),
)?;
// Mid-flight, turned around: the bar keeps growing for a moment.
press(&mut d, self, "stage")?;
widest(&mut d, self, 2.0);
press(&mut d, self, "stage")?;
d.advance(0.1);
d.frame(self);
let going = width(&d);
press(&mut d, self, "stage")?;
d.advance(1.0 / 60.0);
d.frame(self);
d.check(
width(&d) > going,
"a retarget keeps the momentum it had before turning",
)?;
let node = |d: &mut Drive<'_>, name: &str| {
d.core
.access_tree()
.nodes
.iter()
.find(|n| n.role == Role::Slider && n.name.as_deref() == Some(name))
.map(|n| n.key)
.ok_or(format!("no {name} slider"))
};
let b = node(&mut d, "Bounce")?;
d.focus(self, b);
d.key(self, "end", KeyMods::default());
d.frame(self);
d.check(
self.bounce == kui_native::MAX_BOUNCE,
"the bounce slider tops out at the most a spring takes",
)?;
let dur = node(&mut d, "Duration")?;
d.focus(self, dur);
d.key(self, "right", KeyMods::default());
d.frame(self);
d.check(self.duration_ms == 550.0, "Right is one 50 ms step")?;
let warned = d.warnings();
d.check(warned.is_empty(), &format!("nothing warned: {warned:?}"))
}
}
kui_devtools::main!(Spring::default());
features/theme.rs
//! The token reference: every colour a [`kui_native::Theme`] names, drawn in the
//! theme that names it, over every stock widget that reads it
//! (`docs/adr/0019-a-theme-derived-from-appearance-and-accent.md`).
//!
//! It is the page you check a palette against — the swatches say what the
//! roles *are*, and the widgets below them say what the roles *do*, so a
//! value that reads fine as a chip and badly as a menu row has nowhere to
//! hide. Nothing here paints a literal colour: every box on the screen is
//! a token, which is also the point.
//!
//! The base and the accent are the harness's to change — the dock's
//! `base` and `accent` rows, or `Ctrl+Shift+T` / `Ctrl+Shift+A` — which is
//! what every example gets, and this page is where to look while doing
//! it. Right-click for the menu: this page asks for the core's *drawn*
//! one, since on macOS the platform's own would be showing what AppKit
//! paints instead; the dock's `menus` row switches it back.
//!
//! Run: cargo run -p kui-native --example theme
use kui_devtools::Example;
use kui_native::widgets;
use kui_native::{Align, App, Color, NodeSpec, TextStyle, Theme, Ui, UiEvent, Value};
/// What the demo buttons post: they are here to be looked at, not to say
/// anything, and the menu's rows post their own text.
const NOOP: &str = "noop";
#[derive(Default)]
struct Gallery {
menu: Option<(f32, f32)>,
said: Option<String>,
}
/// One token: a chip of the colour, its name, and the hex it resolved to.
/// The label sits *outside* the chip, because half of these are
/// translucent washes with nothing legible to write on them.
fn swatch(ui: &mut Ui<'_>, name: &str, c: Color, t: &Theme) {
ui.with(NodeSpec::row().gap(8.0).cross_align(Align::Center), |ui| {
ui.leaf(
NodeSpec::row()
.size(34.0, 20.0)
.bg(c)
.radius(4.0)
.border(1.0, t.border),
);
ui.with(NodeSpec::column().width(112.0), |ui| {
ui.text(name, TextStyle::new(12.0).color(t.fg));
ui.text(
&format!("#{:08x}", c.to_hex()),
TextStyle::new(10.0).color(t.faint),
);
});
});
}
/// A titled card, in the theme's own card colour.
fn card(ui: &mut Ui<'_>, title: &str, body: impl FnOnce(&mut Ui<'_>)) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.gap(12.0)
.pad(16.0)
.bg(t.surface)
.radius(10.0)
.border(1.0, t.border),
|ui| {
ui.text(title, TextStyle::new(11.0).color(t.muted));
body(ui);
},
);
}
impl App for Gallery {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.open_keyed(
"page",
NodeSpec::column()
.fill()
.gap(16.0)
.pad(20.0)
.bg(t.bg)
// A window shorter than the swatches scrolls rather than
// over-committing its column: without this the two rows of
// cards shrink into each other.
.scroll_y()
// A secondary press asks the *topmost* node under the
// pointer and stops there — it does not bubble the way a
// key does — and this column covers the page.
.on_context_menu(Value::map([("kind", "menu".into())])),
);
ui.text("design tokens", TextStyle::new(22.0));
ui.text(
"the dock's base and accent rows change the palette · right-click for the drawn menu",
TextStyle::new(12.0).color(t.muted),
);
ui.with(NodeSpec::row().gap(16.0).grow_width(), |ui| {
card(ui, "SURFACES", |ui| {
for (n, c) in [
("bg", t.bg),
("surface", t.surface),
("raised", t.raised),
("sunken", t.sunken),
("border", t.border),
("border_strong", t.border_strong),
] {
swatch(ui, n, c, &t);
}
});
card(ui, "TEXT & ACCENT", |ui| {
for (n, c) in [
("fg", t.fg),
("muted", t.muted),
("faint", t.faint),
("accent", t.accent),
("accent_soft", t.accent_soft),
("on_accent", t.on_accent),
] {
swatch(ui, n, c, &t);
}
});
card(ui, "STATE & STATUS", |ui| {
for (n, c) in [
("focus_ring", t.focus_ring),
("selection", t.selection),
("hover", t.hover),
("success", t.success),
("warning", t.warning),
("danger", t.danger),
] {
swatch(ui, n, c, &t);
}
});
});
ui.with(NodeSpec::row().gap(16.0).grow_width(), |ui| {
card(ui, "BUTTONS", |ui| {
ui.with(NodeSpec::row().gap(10.0).cross_align(Align::Center), |ui| {
widgets::button(ui, "stock", Value::str(NOOP));
widgets::button_with(
ui,
"accented",
"accent",
widgets::button_spec(&ui.theme(), &ui.metrics())
.accent()
.on_click(Value::str(NOOP)),
Some("bg, hover and pressed all come off the accent"),
);
widgets::button_with(
ui,
"off",
"disabled",
widgets::button_spec(&ui.theme(), &ui.metrics()).disabled(true),
None,
);
});
ui.text(
"Tab to see the focus ring; hover the middle one for a tooltip.",
TextStyle::new(11.0).color(t.faint),
);
});
card(ui, "TEXT & FIELDS", |ui| {
widgets::text_input(ui, "search", "select me for the tint");
ui.text("body text — theme.fg", TextStyle::default());
ui.text(
"secondary — theme.muted",
TextStyle::new(13.0).color(t.muted),
);
ui.text(
"tertiary — theme.faint",
TextStyle::new(13.0).color(t.faint),
);
ui.with(NodeSpec::row().gap(10.0), |ui| {
for (n, c) in [("ok", t.success), ("warn", t.warning), ("fail", t.danger)] {
ui.text(n, TextStyle::new(13.0).color(c));
}
});
});
});
// Always declared, never conditional: a line that appears when a
// menu row is chosen would reflow everything above it, and a
// reference page that jumps while you use it is its own bug report.
ui.text(
&match &self.said {
Some(said) => format!("last menu choice — {said}"),
None => "last menu choice — none yet".to_string(),
},
TextStyle::new(12.0).color(if self.said.is_some() {
t.accent
} else {
t.faint
}),
);
ui.close();
// The stock menu, drawn by `widgets::context_menu` in the theme's
// own colours — the float that made `raised` a role of its own.
if let Some((x, y)) = self.menu {
widgets::context_menu(
ui,
kui_native::Vec2::new(x, y),
&[
kui_native::MenuItem::new("Copy").accel("⌘C"),
kui_native::MenuItem::new("Paste"),
kui_native::MenuItem::separator(),
kui_native::MenuItem::new("Nothing doing").enabled(false),
],
);
}
}
fn on_event(&mut self, ev: UiEvent) {
match ev.kind() {
Some("contextmenu") => {
let at = |k| ev.payload.get(k).and_then(Value::as_float).unwrap_or(0.0) as f32;
self.menu = Some((at("x"), at("y")));
}
Some("dismiss") => self.menu = None,
// Every other string payload here is a menu row's own id,
// which the stock menu posts as the row's text — except the
// one the demo buttons post, which is not a menu choice and
// must not be reported as the last one.
_ => match ev.payload.as_str() {
Some(NOOP) | None => {}
Some(s) => {
self.said = Some(s.to_string());
self.menu = None;
}
},
}
}
}
impl Example for Gallery {
const KEYS: &'static [(&'static str, &'static str)] = &[("right-click", "the drawn menu")];
/// The drawn menu, not the platform's: this page is showing what the
/// theme paints, and on macOS the platform's own menu would be
/// showing what AppKit paints instead.
fn native_menus(&self) -> Option<bool> {
Some(false)
}
}
kui_devtools::main!(Gallery::default());
features/transition.rs
//! Motion as data: a node declares what it eases and how, and the core
//! tweens whatever changes between frames. Six things, each one prop:
//!
//! - `transition(ms)`: a value that changes eases to its new one over that
//! long — the bar's width follows the buttons;
//! - `easing`: the curve it takes, one racer per `Easing`, racing on a
//! click — the four springs are named bounces, from `smooth`, which
//! glides in, to `bouncy`, which overshoots most, and the last racer
//! sets its own with `bounce`;
//! - `slide`: a float whose *position* eases too, so a card jumps between
//! two anchors along a path rather than appearing at the other;
//! - `keyframes`: a cycle of stops the node walks by itself — width, colour,
//! radius, opacity — with `repeat` saying which way (normal, reverse,
//! alternate) and `delay` holding siblings out of phase, which is what a
//! chase light is;
//!
//! Nothing here calls an animation; each frame declares the value it wants
//! and the core is between the last frame and this one.
//!
//! Run: cargo run -p kui-native --example transition [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::{
Align, App, Core, Easing, FloatConfig, Keyframe, NodeSpec, Repeat, Sizing, TextStyle, Ui,
UiEvent, Value,
};
/// Each racer's easing, and the bounce it gives the spring in place of the
/// easing's own.
const EASINGS: [(&str, Easing, Option<f32>); 9] = [
("linear", Easing::Linear, None),
("ease-out", Easing::EaseOut, None),
("ease-in", Easing::EaseIn, None),
("ease-in-out", Easing::EaseInOut, None),
("smooth", Easing::Smooth, None),
("snappy", Easing::Snappy, None),
("spring", Easing::Spring, None),
("bouncy", Easing::Bouncy, None),
("bounce 0.7", Easing::Spring, Some(0.7)),
];
#[derive(Default)]
struct Motion {
/// The bar's width, as a fraction: what the buttons set and the
/// transition follows.
level: f32,
/// Whether the racers are at the far end.
far: bool,
/// Which anchor the sliding card sits at.
right: bool,
}
impl App for Motion {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
ui.with(
NodeSpec::column()
.fill()
.pad(24.0)
.gap(18.0)
.cross_align(Align::Start)
.scroll_y(),
|ui| {
// transition: the width follows the level, eased.
ui.text("transition · the bar eases to whatever the buttons set", TextStyle::new(12.0).color(t.muted));
ui.with(NodeSpec::row().gap(8.0).cross_align(Align::Center), |ui| {
for (label, level) in [("0%", 0.0), ("40%", 0.4), ("100%", 1.0)] {
kui_native::widgets::button(
ui,
label,
Value::map([("kind", Value::str("level")), ("to", Value::Float(level))]),
);
}
});
ui.with(
NodeSpec::row()
.size(420.0, 14.0)
.bg(t.sunken)
.radius(7.0),
|ui| {
ui.leaf_keyed(
"bar",
NodeSpec::row()
.width(Sizing::Percent(self.level))
.grow_height()
.bg(t.accent)
.radius(7.0)
.transition(600.0));
},
);
// easing: the same travel on five curves.
ui.text("easing · click a lane and the six race on their own curves", TextStyle::new(12.0).color(t.muted));
ui.with_keyed(
"lanes",
NodeSpec::column()
.width(420.0)
.gap(6.0)
.pad(8.0)
.bg(t.surface)
.radius(8.0)
.border(1.0, t.border)
.on_click("race"),
|ui| {
for (name, easing, bounce) in EASINGS {
ui.with(NodeSpec::row().grow_width().gap(8.0).cross_align(Align::Center), |ui| {
ui.text_in(NodeSpec::row().width(80.0), name, TextStyle::new(11.0).color(t.muted));
ui.with(
NodeSpec::row().grow_width().height(16.0),
|ui| {
// The racer floats inside its lane; `slide`
// is what makes its *position* ease.
let racer = NodeSpec::row()
.float(
FloatConfig::parent()
.inside(if self.far { Align::End } else { Align::Start }, Align::Center),
)
.size(16.0, 16.0)
.radius(8.0)
.bg(t.accent)
.transition(900.0)
.easing(easing)
.slide();
ui.leaf_keyed(
name,
match bounce {
Some(b) => racer.bounce(b),
None => racer,
},
);
},
);
});
}
},
);
// slide: a float that eases between two anchors.
ui.text("slide · the card eases between its two anchors instead of appearing at the other", TextStyle::new(12.0).color(t.muted));
ui.with_keyed(
"stage",
NodeSpec::row()
.size(420.0, 64.0)
.bg(t.sunken)
.radius(8.0)
.on_click("flip"),
|ui| {
let side = if self.right { Align::End } else { Align::Start };
ui.text_in_keyed("card", NodeSpec::column()
.float(FloatConfig::parent().inside(side, Align::Center).offset(0.0, 0.0))
.pad_xy(14.0, 10.0)
.bg(t.raised)
.radius(8.0)
.border(1.0, t.border)
.transition(500.0)
.easing(Easing::EaseInOut)
.slide(), "click the stage", TextStyle::new(13.0));
},
);
// keyframes, repeat, delay: a cycle each node walks alone.
ui.text("keyframes · repeat · delay — a cycle, a direction, and siblings out of phase", TextStyle::new(12.0).color(t.muted));
ui.with(NodeSpec::row().gap(10.0).cross_align(Align::End), |ui| {
for (name, repeat) in [
("normal", Repeat::Normal),
("reverse", Repeat::Reverse),
("alternate", Repeat::Alternate),
("alt-reverse", Repeat::AlternateReverse),
]
.into_iter()
{
ui.with(NodeSpec::column().gap(4.0).cross_align(Align::Center), |ui| {
ui.leaf_keyed(
name,
NodeSpec::row()
.size(48.0, 48.0)
.bg(t.accent)
.radius(6.0)
.transition(1200.0)
.repeat(repeat)
.keyframes(vec![
Keyframe::default().at(0.0).bg(t.accent).radius(6.0).opacity(1.0),
Keyframe::default().at(0.5).bg(t.success).radius(24.0).opacity(0.6),
Keyframe::default().at(1.0).bg(t.danger).radius(6.0).opacity(1.0),
]));
ui.text(name, TextStyle::new(10.0).color(t.muted));
});
}
// The chase: one cycle, five delays.
ui.with(NodeSpec::row().gap(4.0).cross_align(Align::Center), |ui| {
for i in 0..5 {
ui.leaf_keyed(
&format!("chase{i}"),
NodeSpec::row()
.size(12.0, 12.0)
.radius(6.0)
.bg(t.faint)
.transition(800.0)
.delay(i as f32 * 160.0)
.keyframes(vec![
Keyframe::default().at(0.0).bg(t.faint).opacity(0.4),
Keyframe::default().at(0.5).bg(t.accent).opacity(1.0),
Keyframe::default().at(1.0).bg(t.faint).opacity(0.4),
]));
}
});
});
ui.text(
"every value above is what this frame declared; the core is between the last frame and this one",
TextStyle::new(12.0).color(t.faint),
);
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.payload.as_str() {
Some("race") => self.far = !self.far,
Some("flip") => self.right = !self.right,
_ => {
if let Some(to) = ev.payload.get_float("to") {
self.level = to as f32;
}
}
}
}
}
impl Example for Motion {
const KEYS: &'static [(&'static str, &'static str)] = &[
("click a lane", "race the easings"),
("click the stage", "slide the card"),
];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(520.0, 630.0)
}
/// The bar's width is tweened: one frame after the level changes the
/// core is animating on its account, and a click that changes nothing
/// starts nothing. (The keyframe cycles run forever, so "settled" is
/// not a state this frame has; a tween's end is checked in
/// `kui-core`'s own tests.)
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 520.0, 630.0);
d.frame(self);
d.frame(self);
let full = d.key_of("100%").ok_or("no 100% button")?;
d.click_key(self, full);
d.check(self.level == 1.0, "the click sets the level")?;
d.advance(0.016);
d.frame(self);
d.check(d.core.animating(), "one frame in, the bar is still moving")?;
let stage = d.key_of("stage").ok_or("no stage")?;
d.click_key(self, stage);
d.frame(self);
d.check(self.right, "the stage flips the card's anchor")?;
d.check(
d.core.animating(),
"and the float slides rather than jumping",
)
}
}
kui_devtools::main!(Motion::default());
features/waker.rs
//! Data arriving off the loop's thread: a "PTY" that produces a line every
//! 40 ms on a thread of its own, and a view that shows the last ten. The
//! loop parks between events (`ControlFlow::Wait`), so without a
//! [`kui_native::Waker`] the window would show whatever it drew at the last
//! key press. `setup` hands the app the waker once; the thread clones it
//! and calls `wake()` after every line, and the loop draws. Under
//! `KUI_WAKER_LINES=n` the app closes after `n` lines and prints how many
//! frames it drew for them, which is what the by-hand check reads; and
//! `teardown` prints once more as the window goes — closed by the app,
//! by its button or by Quit alike — which is where an app saves what it
//! would lose (backlog F74).
//!
//! Run: cargo run -p kui-native --example waker
use std::sync::{Arc, Mutex};
use std::time::Duration;
use kui_devtools::Example;
use kui_native::{App, NodeSpec, TextStyle, Ui, Waker, WindowCommand};
struct Feed {
lines: Arc<Mutex<Vec<String>>>,
frames: usize,
limit: Option<usize>,
}
impl App for Feed {
fn setup(&mut self, waker: Waker) {
let lines = Arc::clone(&self.lines);
let limit = self.limit;
std::thread::spawn(move || {
let mut n = 0usize;
loop {
std::thread::sleep(Duration::from_millis(40));
n += 1;
lines
.lock()
.unwrap()
.push(format!("line {n}: {:>6} bytes", n * 137 % 4096));
waker.wake();
if limit.is_some_and(|l| n >= l) {
break;
}
}
});
}
fn teardown(&mut self) {
println!("teardown after {} frames", self.frames);
}
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
self.frames += 1;
let lines = self.lines.lock().unwrap().clone();
if let Some(limit) = self.limit
&& lines.len() >= limit
{
println!(
"{} lines arrived, {} frames drawn",
lines.len(),
self.frames
);
ui.window_command(WindowCommand::Close(ui.env().window.id));
}
ui.with(
NodeSpec::column().fill().pad(16.0).gap(4.0).bg(t.bg),
|ui| {
ui.text(
&format!(
"{} lines, {} frames — no input, a thread woke the loop",
lines.len(),
self.frames
),
TextStyle::new(13.0).mono().color(t.muted),
);
for line in lines.iter().rev().take(10).rev() {
ui.text_in(
NodeSpec::row().height(18.0),
line,
TextStyle::new(13.0).mono().color(t.fg),
);
}
},
);
}
}
impl Example for Feed {
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(480.0, 260.0)
}
}
kui_devtools::main!(Feed {
lines: Arc::new(Mutex::new(Vec::new())),
frames: 0,
limit: std::env::var("KUI_WAKER_LINES")
.ok()
.and_then(|s| s.parse().ok()),
});
tools/
Registered as an example for want of a better slot, and not one: a corpus dump.
tools/conformance-dump.rs
//! Dumps the scene corpus's reference report (see `kui_core::conformance`).
//!
//! cargo run -p kui-core --features conformance --example conformance-dump -- target/conformance.txt
//!
//! The other bindings rebuild the same scenes and diff their own report
//! against this file. It is generated, never checked in: the quad digests
//! cover real glyph geometry, so they hold only for the machine and fonts
//! that produced them.
fn main() {
let text = kui_core::conformance::reference_report();
match std::env::args().nth(1) {
Some(path) => std::fs::write(&path, text).unwrap_or_else(|e| {
eprintln!("conformance-dump: {path}: {e}");
std::process::exit(1);
}),
None => print!("{text}"),
}
}
tools/schema-dump.rs
//! Dumps the prop schema (`kui_core::schema`) as JSON, for a binding
//! generated outside Rust.
//!
//! cargo run -p kui-core --example schema-dump -- target/odin/schema.json
//!
//! The Odin binding's generator reads it (`nu scripts/odin.nu gen`): the
//! `PROPS` and `CUSTOM` rows become its `Spec`, the enum name lists its
//! enums, the `DOORS` rows its verbs, and the element, event, theme, metric
//! and env tables are what it checks its hand-written half against. The
//! keys are the ones kui-node's `protocol()` hands the Node generator,
//! where the two carry the same table.
use kui_core::schema::{self, Cell, Kind, Target};
use serde_json::{Map, Value as Json, json};
fn names(list: &[&str]) -> Json {
Json::Array(list.iter().map(|n| Json::String((*n).into())).collect())
}
fn cell(c: Cell) -> Json {
let (cell, text) = match c {
Cell::Is(t) => ("is", t),
Cell::As(t) => ("as", t),
Cell::No(t) => ("no", t),
};
json!({ "cell": cell, "text": text })
}
fn props() -> Json {
let rows = schema::PROPS.iter().map(|def| {
let (kind, values): (&str, Option<&[&str]>) = match &def.kind {
Kind::F32 => ("f32", None),
Kind::Color => ("color", None),
Kind::Flag => ("flag", None),
Kind::Enum(names) => ("enum", Some(names)),
Kind::Sizing => ("sizing", None),
Kind::Min => ("min", None),
Kind::Max => ("max", None),
Kind::Msg => ("msg", None),
Kind::Tag => ("tag", None),
Kind::Str => ("str", None),
Kind::Family => ("family", Some(schema::FAMILIES)),
Kind::Resource => ("resource", None),
Kind::Keyframes => ("keyframes", None),
Kind::Enter => ("enter", None),
Kind::Gradient => ("gradient", None),
};
let mut p = Map::new();
p.insert("name".into(), def.name.into());
p.insert("snake".into(), def.snake_name().into());
p.insert("id".into(), def.id.into());
p.insert("kind".into(), kind.into());
if let Some(values) = values {
p.insert("values".into(), names(values));
}
let target = match def.target() {
Target::Spec => "spec",
Target::Style => "style",
};
p.insert("target".into(), target.into());
p.insert("c".into(), schema::c_field(def).into());
p.insert("odin".into(), schema::odin_field(def).into());
p.insert("doc".into(), def.doc.into());
Json::Object(p)
});
Json::Array(rows.collect())
}
fn main() {
let custom = schema::CUSTOM.iter().map(|c| {
json!({
"name": c.name, "id": c.id, "jsx_names": names(c.jsx_names),
"lua_names": names(c.lua_names), "c": c.c, "odin": c.odin, "doc": c.doc,
})
});
let elements = schema::ELEMENTS
.iter()
.map(|e| json!({ "name": e.name, "c": e.c, "odin": e.odin, "doc": e.doc }));
let events = schema::EVENTS
.iter()
.map(|e| json!({ "kind": e.kind, "payload": e.payload, "doc": e.doc }));
let doors = schema::DOORS.iter().map(|d| {
json!({
"rust": d.rust, "c": cell(d.c), "odin": cell(d.odin), "node": cell(d.node), "lua": cell(d.lua),
"doc": d.doc,
})
});
let theme = schema::THEME_ROLES
.iter()
.map(|r| json!({ "name": r.name, "doc": r.doc }));
let metrics = schema::METRIC_ROLES
.iter()
.map(|r| json!({ "name": r.name, "doc": r.doc }));
let env = schema::ENV_FIELDS.iter().map(
|f| json!({ "name": f.name, "c": f.c, "odin": schema::odin_from_c(f.c), "doc": f.doc }),
);
let resources = schema::RESOURCES
.iter()
.map(|r| json!({ "name": r.what, "c": r.c, "odin": schema::odin_from_c(r.c) }));
let all =
|it: &mut dyn Iterator<Item = &'static str>| Json::Array(it.map(Json::from).collect());
let out = json!({
"props": props(),
"custom": Json::Array(custom.collect()),
"elements": Json::Array(elements.collect()),
"events": Json::Array(events.collect()),
"doors": Json::Array(doors.collect()),
"theme": Json::Array(theme.collect()),
"metrics": Json::Array(metrics.collect()),
"env": Json::Array(env.collect()),
"resources": Json::Array(resources.collect()),
// The name lists no prop row carries, for the enums of the verbs
// and readings: what an env setter takes, what a tree reports.
"lists": {
"appearances": names(schema::APPEARANCES),
"motions": names(schema::MOTIONS),
"assistive": names(schema::ASSISTIVE),
"audioDevices": names(schema::AUDIO_DEVICES),
"orientations": names(schema::ORIENTATIONS),
"live": names(schema::LIVE),
"accessRoles": all(&mut kui_core::Role::ALL.iter().map(|r| r.name())),
"accessActions": all(&mut kui_core::AccessAction::ALL.iter().map(|a| a.name())),
"menuRoles": all(&mut kui_core::MenuRole::ALL.iter().map(|r| r.name())),
"editKeys": all(&mut kui_core::EditKey::ALL.iter().map(|k| k.name())),
"windowKinds": all(&mut kui_core::WindowKind::ALL.iter().map(|k| k.name())),
"mouseButtons": all(&mut kui_core::MouseButton::NAMED.iter().filter_map(|b| b.name())),
"imageSampling": all(&mut kui_core::Sampling::ALL.iter().map(|s| s.name())),
"imageFit": all(&mut kui_core::ImageFit::ALL.iter().map(|f| f.name())),
},
});
let text = serde_json::to_string_pretty(&out).expect("the schema is plain data");
match std::env::args().nth(1) {
Some(path) => std::fs::create_dir_all(
std::path::Path::new(&path)
.parent()
.unwrap_or(std::path::Path::new(".")),
)
.and_then(|()| std::fs::write(&path, text))
.unwrap_or_else(|e| {
eprintln!("schema-dump: {path}: {e}");
std::process::exit(1);
}),
None => println!("{text}"),
}
}