diff --git a/Cargo.lock b/Cargo.lock
index 5919254f084fdbe059e9f8fec367a7f257e2eed4..6bf20d2837fa08a080aa0d3d68222c77f9fa7e13 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3073,6 +3073,9 @@ version = "0.1.0"
dependencies = [
"draw",
"parley",
+ "png",
+ "pollster",
+ "wgpu",
"winit",
]
diff --git a/crates/draw/src/render.rs b/crates/draw/src/render.rs
index c4d023177bb4c0d9145e39743948b58e7bc04c72..971d6cbc7e800a57586783a48343c09833503271 100644
--- a/crates/draw/src/render.rs
+++ b/crates/draw/src/render.rs
@@ -1098,7 +1098,9 @@ impl Renderer {
rect[3] * space.scale + space.origin[1],
];
let start = self.vertices.len();
- self.quad(space, rect, [0.5 / ATLAS_SIZE as f32; 4], colors[0])?;
+ // A pixel of margin holds the antialiased fringe outside the edge.
+ let fringe = [rect[0] - 1.0, rect[1] - 1.0, rect[2] + 1.0, rect[3] + 1.0];
+ self.quad(space, fringe, [0.5 / ATLAS_SIZE as f32; 4], colors[0])?;
let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5];
if !(4.0 * (half[0] + half[1])).is_finite() || !colors[1].iter().all(|v| v.is_finite()) {
return Err(RenderError::InvalidPrimitive);
diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs
index a99bda00c032d80a4e466e05401298201bb1bd92..800243936cd08da41faf7f6f77dac394971da25a 100644
--- a/crates/snowbound/src/main.rs
+++ b/crates/snowbound/src/main.rs
@@ -21,7 +21,7 @@ use std::{
sync::Arc,
time::Instant,
};
-use ui::{Axis, Flags, Id, Shape, Spec, Theme, Ui, children, fill, fit, px};
+use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px};
use winit::{
application::ApplicationHandler,
dpi::{LogicalSize, PhysicalPosition, PhysicalSize},
@@ -727,7 +727,10 @@ impl State {
paper,
|_| strip,
);
- let [foot, toe] = self.ui.rect(open_tab).map_or([end; 2], ui::shell::tab_base);
+ let [foot, toe] = self
+ .ui
+ .rect(open_tab)
+ .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW));
for edge in [
[
([start, frame[1] + outer], 0.0),
@@ -1089,28 +1092,41 @@ impl State {
pad: [10.0 + 16.0 * level.saturating_sub(1) as f32, 0.0],
..Spec::default()
};
- let spec = if selected {
+ let clicked = if selected {
open = Some(self.ui.id(space));
- Spec {
+ let spec = Spec {
color: Some(theme.paper_ink),
fill: Some(theme.paper),
..spec
- }
+ };
+ self.ui.leaf(space, spec).clicked
} else {
- Spec {
+ // Tabs not open float free of the page as pills; only the open one joins it.
+ self.ui.open(
+ space,
+ Spec {
+ size: [px(PAGE_LIST), px(26.0)],
+ pad: [6.0, 0.0],
+ ..Spec::default()
+ },
+ );
+ let spec = Spec {
+ size: [fill(), px(26.0)],
color: Some(if title.is_empty() {
ui::mix(theme.ink, section.tab, 0.5)
} else {
theme.ink
}),
fill: Some(section.tab),
- hover_fill: Some(section.frame[0]),
- radius: ROUNDING,
- shape: Shape::Trailing,
+ hover_fill: Some(ui::mix(section.tab, theme.ink, 0.12)),
+ radius: 13.0,
..spec
- }
+ };
+ let clicked = self.ui.leaf("pill", spec).clicked;
+ self.ui.close();
+ clicked
};
- if self.ui.leaf(space, spec).clicked && !selected {
+ if clicked && !selected {
self.commands.push(Command::OpenPage(*space));
}
}
diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml
index 5d212cb1cb07cff348a8afdd258ab9978808aba8..d5d829440da7f6d6da08d3391b228ba04b413acf 100644
--- a/crates/ui/Cargo.toml
+++ b/crates/ui/Cargo.toml
@@ -8,3 +8,8 @@ publish = false
draw = { path = "../draw" }
parley.workspace = true
winit = "0.30.13"
+
+[dev-dependencies]
+pollster = "0.4"
+png = "0.18"
+wgpu.workspace = true
diff --git a/crates/ui/assets/check.svg b/crates/ui/assets/check.svg
new file mode 100644
index 0000000000000000000000000000000000000000..716c888dcde2534cf51f3d4da0b8db529ca0b289
--- /dev/null
+++ b/crates/ui/assets/check.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/ui/examples/popups.rs b/crates/ui/examples/popups.rs
new file mode 100644
index 0000000000000000000000000000000000000000..26b86679a1437c4bdb55d7c5f8f0a6d090ba1fb1
--- /dev/null
+++ b/crates/ui/examples/popups.rs
@@ -0,0 +1,675 @@
+//! Renders each popup control open, in both themes, to `/tmp/ui-popups-{dark,light}.png`,
+//! and a combo's list reordering after a keystroke to `/tmp/ui-list-{dark,light}.png`, for
+//! review; then times a list of 200 000 rows.
+
+use std::{
+ collections::HashMap,
+ time::{Duration, Instant},
+};
+use ui::{Anchor, Event, Id, List, Rows, Spec, Theme, Ui, fill, popup::Item, px, shell};
+use winit::keyboard::{Key, NamedKey};
+
+const PANEL: [f32; 2] = [420.0, 340.0];
+const SCALE: f32 = 2.0;
+
+macro_rules! art {
+ ($path:literal) => {
+ &[include_str!(concat!(
+ "../../snowbound/assets/",
+ $path,
+ ".svg"
+ ))]
+ };
+}
+
+fn main() {
+ let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
+ let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
+ let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
+ let mut renderer = draw::Renderer::new(
+ device.clone(),
+ queue.clone(),
+ wgpu::TextureFormat::Rgba8UnormSrgb,
+ );
+ for (name, theme) in [("dark", Theme::dark()), ("light", Theme::light())] {
+ let scenes: [fn(&mut Scene); 6] = [menu, fonts, colors, palette, tags, highlight];
+ let uis: Vec = scenes
+ .iter()
+ .map(|build| {
+ let mut scene = Scene {
+ ui: Ui::new(theme.clone(), Duration::from_millis(500)),
+ start: Instant::now(),
+ frames: 0,
+ };
+ build(&mut scene);
+ scene.ui
+ })
+ .collect();
+ let panels = uis
+ .iter()
+ .map(|ui| paint(&device, &queue, &mut renderer, ui))
+ .collect();
+ let path = format!("/tmp/ui-popups-{name}.png");
+ render(panels, 3, &path);
+ println!("{path}");
+ let path = format!("/tmp/ui-list-{name}.png");
+ render(keystroke(&theme, &device, &queue, &mut renderer), 4, &path);
+ println!("{path}");
+ }
+ measure();
+}
+
+/// A font combo's list before `c` is typed into its filter and at frames after.
+fn keystroke(
+ theme: &Theme,
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ renderer: &mut draw::Renderer,
+) -> Vec> {
+ let mut scene = Scene {
+ ui: Ui::new(theme.clone(), Duration::from_millis(500)),
+ start: Instant::now(),
+ frames: 0,
+ };
+ let items = font_items();
+ let id = Id::ROOT.child("fonts");
+ let build = |ui: &mut Ui, [combo, _]: [Id; 2]| {
+ let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());
+ ui::popup::menu(ui, id, anchor, &items, Some("Calibri"));
+ };
+ scene.open(id, Vec::new(), build);
+ let mut panels = vec![paint(device, queue, renderer, &scene.ui)];
+ for event in typed("c") {
+ scene.ui.event(event);
+ }
+ for frame in 1..=20 {
+ scene.frame(build);
+ if [1, 2, 3, 4, 6, 9, 20].contains(&frame) {
+ panels.push(paint(device, queue, renderer, &scene.ui));
+ }
+ }
+ panels
+}
+
+/// Keys in order, and where each is.
+struct Keyed {
+ keys: Vec,
+ at: HashMap,
+}
+
+impl Keyed {
+ fn new(keys: Vec) -> Self {
+ let at = keys
+ .iter()
+ .enumerate()
+ .map(|(index, key)| (*key, index))
+ .collect();
+ Self { keys, at }
+ }
+}
+
+impl Rows for Keyed {
+ fn count(&self) -> usize {
+ self.keys.len()
+ }
+
+ fn key(&self, index: usize) -> u64 {
+ self.keys[index]
+ }
+
+ fn find(&self, key: u64) -> Option {
+ self.at.get(&key).copied()
+ }
+}
+
+/// Prints how long a frame of a list takes to build and lay out: with 20 rows, with
+/// 200 000, after a keystroke filters those, and while 10 000 arrive each frame.
+fn measure() {
+ let names: Vec = (0..200_000u64)
+ .map(|key| format!("Page {:06}", key.wrapping_mul(2_654_435_761) % 1_000_000))
+ .collect();
+ let mut ui = Ui::new(Theme::dark(), Duration::from_millis(500));
+ let start = Instant::now();
+ let mut frames = 0;
+ let mut selected = Some(100_000);
+ let mut frame = |ui: &mut Ui, rows: &Keyed, selected: &mut Option| {
+ frames += 1;
+ let began = Instant::now();
+ ui.begin(
+ [400.0, 600.0],
+ 2.0,
+ start + Duration::from_millis(16) * frames,
+ );
+ let list = List {
+ rows,
+ row: 24.0,
+ keys: &[],
+ hover_selects: false,
+ };
+ let spec = Spec {
+ size: [fill(), fill()],
+ ..Spec::default()
+ };
+ ui::list(
+ ui,
+ Id::ROOT.child("pages"),
+ spec,
+ list,
+ selected,
+ |ui, row| {
+ let text = &names[row.key as usize];
+ ui.leaf(
+ "name",
+ Spec {
+ size: [fill(), fill()],
+ text: Some(text),
+ fill: row.selected.then_some([0.2, 0.3, 0.4, 1.0]),
+ pad: [8.0, 0.0],
+ ..Spec::default()
+ },
+ );
+ },
+ );
+ ui.end();
+ drop(ui.layers());
+ began.elapsed()
+ };
+ let average = |times: Vec| times.iter().sum::() / times.len() as u32;
+ for count in [20, 200_000] {
+ let rows = Keyed::new((0..count).collect());
+ let times: Vec<_> = (0..60)
+ .map(|_| frame(&mut ui, &rows, &mut selected))
+ .collect();
+ println!("{count} rows: {:?} a frame", average(times[30..].to_vec()));
+ }
+ let filtering = Instant::now();
+ let rows = Keyed::new(
+ (0..200_000)
+ .filter(|key| names[*key as usize].contains('7'))
+ .collect(),
+ );
+ let filtering = filtering.elapsed();
+ let first = frame(&mut ui, &rows, &mut selected);
+ let times: Vec<_> = (0..12)
+ .map(|_| frame(&mut ui, &rows, &mut selected))
+ .collect();
+ println!(
+ "a keystroke filtering 200 000 to {}: {filtering:?} to filter, then {first:?} and {:?} a frame as rows ease",
+ rows.count(),
+ average(times)
+ );
+ let mut keys: Vec = (0..100_000).collect();
+ let (mut list, mut rebuild) = (Vec::new(), Vec::new());
+ for wave in 0..10u64 {
+ let rebuilding = Instant::now();
+ // Arrivals interleave with the rows already listed, above and below the view.
+ let arriving = (0..10_000).map(|index| 100_000 + wave * 10_000 + index);
+ keys = keys
+ .chunks(10)
+ .zip(arriving)
+ .flat_map(|(chunk, key)| chunk.iter().copied().chain([key]))
+ .collect();
+ let rows = Keyed::new(keys.clone());
+ rebuild.push(rebuilding.elapsed());
+ list.push(frame(&mut ui, &rows, &mut selected));
+ }
+ println!(
+ "10 000 arriving a frame, up to 200 000: {:?} a frame for the list, {:?} to rebuild the rows",
+ average(list),
+ average(rebuild)
+ );
+}
+
+struct Scene {
+ ui: Ui,
+ start: Instant,
+ frames: u32,
+}
+
+impl Scene {
+ /// Builds a frame over the panel's backdrop and toolbar, handing `build` the ids of the
+ /// combo and the split button's arrow.
+ fn frame(&mut self, build: impl FnOnce(&mut Ui, [Id; 2])) {
+ self.frames += 1;
+ let now = self.start + Duration::from_millis(16) * self.frames;
+ let ui = &mut self.ui;
+ let theme = ui.theme.clone();
+ ui.begin(PANEL, SCALE, now);
+ ui.open(
+ "backdrop",
+ Spec {
+ size: [fill(), fill()],
+ fill: Some(theme.base),
+ ..Spec::default()
+ },
+ );
+ ui.open(
+ "toolbar",
+ Spec {
+ size: [fill(), px(30.0)],
+ fill: Some(theme.strip),
+ pad: [8.0, 4.0],
+ gap: 6.0,
+ ..Spec::default()
+ },
+ );
+ let combo = ui.id("font");
+ shell::combo(ui, "font", "Calibri", 120.0);
+ let split = ui.id("color");
+ shell::split_button(
+ ui,
+ "color",
+ art!("icons/font-color"),
+ Some(draw::srgb(0xe8, 0x3a, 0x30)),
+ );
+ ui.close();
+ ui.close();
+ build(ui, [combo, split.child("menu")]);
+ ui.end();
+ }
+
+ /// Opens `id` and runs frames until it has faded in, feeding `events` once it shows.
+ fn open(&mut self, id: Id, events: Vec, build: impl Fn(&mut Ui, [Id; 2])) {
+ self.frame(|_, _| {});
+ self.frame(|ui, ids| {
+ ui.open_popup(id);
+ build(ui, ids);
+ });
+ for event in events {
+ self.ui.event(event);
+ }
+ for _ in 0..30 {
+ self.frame(&build);
+ }
+ }
+}
+
+fn below(ui: &Ui, id: Id) -> Anchor {
+ Anchor::Below(ui.rect(id).unwrap_or_default())
+}
+
+fn key(named: NamedKey) -> Event {
+ Event::Key {
+ key: Key::Named(named),
+ text: None,
+ }
+}
+
+fn typed(text: &str) -> Vec {
+ text.chars()
+ .map(|character| Event::Key {
+ key: Key::Character(character.to_string().into()),
+ text: Some(character.to_string()),
+ })
+ .collect()
+}
+
+fn menu(scene: &mut Scene) {
+ let items = [
+ Item {
+ text: "Cut",
+ shortcut: "⌘X",
+ ..Item::default()
+ },
+ Item {
+ text: "Copy",
+ shortcut: "⌘C",
+ ..Item::default()
+ },
+ Item {
+ text: "Paste",
+ shortcut: "⌘V",
+ ..Item::default()
+ },
+ Item {
+ text: "Paste as plain text",
+ shortcut: "⌥⇧⌘V",
+ disabled: true,
+ ..Item::default()
+ },
+ Item {
+ text: "Bold",
+ icon: Some(art!("icons/bold")),
+ shortcut: "⌘B",
+ checked: true,
+ separated: true,
+ ..Item::default()
+ },
+ Item {
+ text: "Italic",
+ icon: Some(art!("icons/italic")),
+ shortcut: "⌘I",
+ ..Item::default()
+ },
+ Item {
+ text: "Link…",
+ icon: Some(art!("icons/link")),
+ shortcut: "⌘K",
+ ..Item::default()
+ },
+ Item {
+ text: "Select all",
+ shortcut: "⌘A",
+ separated: true,
+ ..Item::default()
+ },
+ ];
+ let id = Id::ROOT.child("context");
+ let events = vec![key(NamedKey::ArrowDown), key(NamedKey::ArrowDown)];
+ scene.open(id, events, |ui, _| {
+ ui::popup::menu(ui, id, Anchor::Point([140.0, 70.0]), &items, None);
+ });
+}
+
+const FONTS: &[&str] = &[
+ "Aptos",
+ "Arial",
+ "Arial Black",
+ "Bahnschrift",
+ "Calibri",
+ "Cambria",
+ "Candara",
+ "Comic Sans MS",
+ "Consolas",
+ "Constantia",
+ "Corbel",
+ "Courier New",
+ "Georgia",
+ "Segoe UI",
+ "Tahoma",
+ "Times New Roman",
+ "Trebuchet MS",
+ "Verdana",
+];
+
+fn font_items() -> Vec- > {
+ FONTS
+ .iter()
+ .map(|text| Item {
+ text,
+ checked: *text == "Calibri",
+ ..Item::default()
+ })
+ .collect()
+}
+
+fn fonts(scene: &mut Scene) {
+ let items = font_items();
+ let id = Id::ROOT.child("fonts");
+ scene.open(id, typed("ca"), |ui, [combo, _]| {
+ let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());
+ ui::popup::menu(ui, id, anchor, &items, Some("Calibri"));
+ });
+}
+
+fn office_colors() -> Vec<[f32; 4]> {
+ let theme = [
+ (0xff, 0xff, 0xff),
+ (0x00, 0x00, 0x00),
+ (0xee, 0xec, 0xe1),
+ (0x1f, 0x49, 0x7d),
+ (0x4f, 0x81, 0xbd),
+ (0xc0, 0x50, 0x4d),
+ (0x9b, 0xbb, 0x59),
+ (0x80, 0x64, 0xa2),
+ (0x4b, 0xac, 0xc6),
+ (0xf7, 0x96, 0x46),
+ ]
+ .map(|(r, g, b)| draw::srgb(r, g, b));
+ let standard = [
+ (0xc0, 0x00, 0x00),
+ (0xff, 0x00, 0x00),
+ (0xff, 0xc0, 0x00),
+ (0xff, 0xff, 0x00),
+ (0x92, 0xd0, 0x50),
+ (0x00, 0xb0, 0x50),
+ (0x00, 0xb0, 0xf0),
+ (0x00, 0x70, 0xc0),
+ (0x00, 0x20, 0x60),
+ (0x70, 0x30, 0xa0),
+ ]
+ .map(|(r, g, b)| draw::srgb(r, g, b));
+ let mut colors = theme.to_vec();
+ for step in [0.15, 0.35, 0.6, -0.3, -0.6] {
+ colors.extend(theme.iter().map(|color| {
+ let toward = if step > 0.0 {
+ [1.0; 4]
+ } else {
+ [0.0, 0.0, 0.0, 1.0]
+ };
+ ui::mix(*color, toward, f32::abs(step))
+ }));
+ }
+ colors.extend(standard);
+ colors
+}
+
+fn colors(scene: &mut Scene) {
+ let swatches = office_colors();
+ let id = Id::ROOT.child("font color");
+ scene.open(id, Vec::new(), |ui, [_, split]| {
+ let anchor = below(ui, split);
+ ui::popup::colors(ui, id, anchor, "Automatic", &swatches, 10);
+ });
+ let cell = scene.ui.rect(id.child(("cell", 15_usize))).unwrap();
+ scene
+ .ui
+ .event(Event::PointerMoved([cell[0] + 5.0, cell[1] + 5.0]));
+ for _ in 0..30 {
+ scene.frame(|ui, [_, split]| {
+ let anchor = below(ui, split);
+ ui::popup::colors(ui, id, anchor, "Automatic", &swatches, 10);
+ });
+ }
+}
+
+fn highlight(scene: &mut Scene) {
+ let swatches = [
+ (0xff, 0xff, 0x00),
+ (0x00, 0xff, 0x00),
+ (0x00, 0xff, 0xff),
+ (0xff, 0x00, 0xff),
+ (0x00, 0x00, 0xff),
+ (0xff, 0x00, 0x00),
+ (0x00, 0x00, 0x80),
+ (0x00, 0x80, 0x80),
+ (0x00, 0x80, 0x00),
+ (0x80, 0x00, 0x80),
+ (0x80, 0x00, 0x00),
+ (0x80, 0x80, 0x00),
+ (0x80, 0x80, 0x80),
+ (0xc0, 0xc0, 0xc0),
+ (0x00, 0x00, 0x00),
+ ]
+ .map(|(r, g, b)| draw::srgb(r, g, b));
+ let id = Id::ROOT.child("highlight");
+ scene.open(id, vec![key(NamedKey::ArrowDown)], |ui, [_, split]| {
+ let anchor = below(ui, split);
+ ui::popup::colors(ui, id, anchor, "No colour", &swatches, 5);
+ });
+}
+
+fn palette(scene: &mut Scene) {
+ let commands = [
+ ("New page", "⌘N"),
+ ("New section", "⌘T"),
+ ("New notebook", ""),
+ ("Open notebook…", "⌘O"),
+ ("Go to page…", "⌘G"),
+ ("Insert table", "⌥⌘T"),
+ ("Insert date", "⇧⌘D"),
+ ("Numbered list", "⌘/"),
+ ("Bulleted list", "⌘."),
+ ("Toggle page list", ""),
+ ("Zoom in", "⌘="),
+ ("Zoom out", "⌘-"),
+ ]
+ .map(|(text, shortcut)| Item {
+ text,
+ shortcut,
+ ..Item::default()
+ });
+ let id = Id::ROOT.child("palette");
+ scene.open(id, typed("ne"), |ui, _| {
+ ui::popup::palette(ui, id, &commands);
+ });
+}
+
+fn tags(scene: &mut Scene) {
+ let tags = [
+ ("To Do", None::<&'static [&'static str]>, "⌘1"),
+ ("Important", Some(art!("tags/star")), "⌘2"),
+ ("Question", None, "⌘3"),
+ ("Remember for later", Some(art!("tags/remember")), "⌘4"),
+ ("Definition", Some(art!("tags/definition")), "⌘5"),
+ ("Highlight", Some(art!("tags/highlight")), "⌘6"),
+ ("Contact", Some(art!("tags/contact")), "⌘7"),
+ ("Address", Some(art!("tags/address")), "⌘8"),
+ ("Phone number", Some(art!("tags/phone")), "⌘9"),
+ ("Web site to visit", None, ""),
+ ("Idea", None, ""),
+ ("Password", None, ""),
+ ("Critical", None, ""),
+ ("Project A", None, ""),
+ ("Project B", None, ""),
+ ]
+ .map(|(text, icon, shortcut)| Item {
+ text,
+ icon,
+ colored: true,
+ shortcut,
+ ..Item::default()
+ });
+ let id = Id::ROOT.child("tags");
+ scene.open(id, Vec::new(), |ui, [combo, _]| {
+ let rect = ui.rect(combo).unwrap_or_default();
+ let anchor = Anchor::Below([rect[0] + 200.0, rect[1], rect[0] + 222.0, rect[3]]);
+ ui::popup::menu(ui, id, anchor, &tags, Some("Filter tags"));
+ });
+ let row = scene.ui.rect(id.child("rows").child(3_usize)).unwrap();
+ scene
+ .ui
+ .event(Event::PointerMoved([row[0] + 40.0, row[1] + 8.0]));
+ for _ in 0..3 {
+ scene.frame(|ui, [combo, _]| {
+ let rect = ui.rect(combo).unwrap_or_default();
+ let anchor = Anchor::Below([rect[0] + 200.0, rect[1], rect[0] + 222.0, rect[3]]);
+ ui::popup::menu(ui, id, anchor, &tags, Some("Filter tags"));
+ });
+ }
+}
+
+/// Lays out panels of `PANEL` in rows of `columns` and writes them to `path`.
+fn render(panels: Vec>, columns: usize, path: &str) {
+ let panel = PANEL.map(|length| (length * SCALE) as u32);
+ let gap = 16;
+ let rows = panels.len().div_ceil(columns) as u32;
+ let size = [
+ (panel[0] + gap) * columns as u32 + gap,
+ (panel[1] + gap) * rows + gap,
+ ];
+ let mut image = vec![0x80; (size[0] * size[1] * 4) as usize];
+ for (index, pixels) in panels.iter().enumerate() {
+ let [left, top] = [
+ gap + (index % columns) as u32 * (panel[0] + gap),
+ gap + (index / columns) as u32 * (panel[1] + gap),
+ ];
+ for (y, line) in pixels.chunks((panel[0] * 4) as usize).enumerate() {
+ let start = (((top + y as u32) * size[0] + left) * 4) as usize;
+ image[start..start + line.len()].copy_from_slice(line);
+ }
+ }
+ let file = std::fs::File::create(path).unwrap();
+ let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), size[0], size[1]);
+ encoder.set_color(png::ColorType::Rgba);
+ encoder.set_depth(png::BitDepth::Eight);
+ encoder
+ .write_header()
+ .unwrap()
+ .write_image_data(&image)
+ .unwrap();
+}
+
+/// Paints `ui`'s frame, each on its own so the atlas holds one popup as the app's would,
+/// and reads it back.
+fn paint(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ renderer: &mut draw::Renderer,
+ ui: &Ui,
+) -> Vec {
+ let size = PANEL.map(|length| (length * SCALE) as u32);
+ let interface = ui.layers();
+ let layers: Vec<_> = interface
+ .iter()
+ .filter_map(|layer| match layer {
+ ui::Layer::Primitives { clip, primitives } => Some(draw::Layer {
+ scale: SCALE,
+ origin: [0.0; 2],
+ clip: clip.map(|clip| clip.map(|value| value * SCALE)),
+ primitives,
+ }),
+ ui::Layer::Custom { .. } => None,
+ })
+ .collect();
+ let texture = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("Popups"),
+ size: wgpu::Extent3d {
+ width: size[0],
+ height: size[1],
+ depth_or_array_layers: 1,
+ },
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: wgpu::TextureFormat::Rgba8UnormSrgb,
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
+ view_formats: &[],
+ });
+ renderer
+ .draw(
+ &texture.create_view(&Default::default()),
+ size,
+ ui.theme.base,
+ &layers,
+ )
+ .unwrap();
+ let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
+ let buffer = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("Popups readback"),
+ size: u64::from(row) * u64::from(size[1]),
+ usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
+ mapped_at_creation: false,
+ });
+ let mut encoder = device.create_command_encoder(&Default::default());
+ encoder.copy_texture_to_buffer(
+ texture.as_image_copy(),
+ wgpu::TexelCopyBufferInfo {
+ buffer: &buffer,
+ layout: wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(row),
+ rows_per_image: Some(size[1]),
+ },
+ },
+ wgpu::Extent3d {
+ width: size[0],
+ height: size[1],
+ depth_or_array_layers: 1,
+ },
+ );
+ queue.submit([encoder.finish()]);
+ buffer.map_async(wgpu::MapMode::Read, .., |result| result.unwrap());
+ device
+ .poll(wgpu::PollType::Wait {
+ submission_index: None,
+ timeout: Some(Duration::from_secs(5)),
+ })
+ .unwrap();
+ let mapped = buffer.get_mapped_range(..).unwrap();
+ mapped
+ .chunks(row as usize)
+ .flat_map(|line| &line[..size[0] as usize * 4])
+ .copied()
+ .collect()
+}
diff --git a/crates/ui/src/layout.rs b/crates/ui/src/layout.rs
index affbac0084f784b134c6677508c7656152e39260..a72bf0f164fb090ab640ebb6017ce94020d5b7c5 100644
--- a/crates/ui/src/layout.rs
+++ b/crates/ui/src/layout.rs
@@ -74,10 +74,14 @@ pub(crate) fn solve(nodes: &mut [Built], states: &HashMap, scale: f32
}
}
}
+ let window = nodes[0].computed[axis];
for index in 0..nodes.len() {
let mut cursor = nodes[index].pad[axis];
for child in nodes[index].children.clone() {
- nodes[child].relative[axis] = if nodes[child].flags.contains(Flags::FLOAT) {
+ nodes[child].relative[axis] = if let Some(anchor) = nodes[child].anchor {
+ let slide = (1.0 - nodes[child].alpha) * crate::SLIDE;
+ anchor.place(axis, nodes[child].computed[axis], window, slide)
+ } else if nodes[child].flags.contains(Flags::FLOAT) {
nodes[child].position[axis]
} else if along(&nodes[index], axis) {
let at = cursor;
@@ -127,11 +131,9 @@ fn strictness(nodes: &[Built], index: usize, axis: usize) -> f32 {
}
fn in_flow(nodes: &[Built], index: usize) -> impl Iterator
- + '_ {
- nodes[index]
- .children
- .iter()
- .copied()
- .filter(|child| !nodes[*child].flags.contains(Flags::FLOAT))
+ nodes[index].children.iter().copied().filter(|child| {
+ !nodes[*child].flags.contains(Flags::FLOAT) && nodes[*child].anchor.is_none()
+ })
}
/// The children's extent on `axis`: summed with gaps along the flow, otherwise the largest.
diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs
index 32816b763ce83cd7856a2170812f833371b0c88a..1b65b932106cd8134d8f4b715f48b81906cb7ec6 100644
--- a/crates/ui/src/lib.rs
+++ b/crates/ui/src/lib.rs
@@ -4,11 +4,14 @@
//! events are routed before building and its layout is solved after.
mod layout;
+mod list;
+pub mod popup;
pub mod shell;
mod text;
mod theme;
mod widgets;
+pub use list::{List, Row, Rows, list};
pub use theme::{Section, Shades, Theme};
pub use widgets::{button, edit_key, edit_modifiers, scrollbar, text_field};
@@ -27,14 +30,18 @@ use std::{
use text::{Label, Texts};
use winit::{
event::{Ime, MouseButton},
- keyboard::{Key, ModifiersState},
+ keyboard::{Key, ModifiersState, NamedKey},
window::CursorIcon,
};
/// Seconds for an animated value to close half of its remaining distance.
const HALF_LIFE: f32 = 0.03;
-/// How far a box's shadow spreads, in logical pixels.
-const SHADOW: f32 = 3.0;
+/// How far a box's shadow spreads, in logical pixels, and how far below the box it
+/// falls; a popup's, floating higher, spreads and falls further.
+const SHADOW: [f32; 2] = [3.0, 0.0];
+const POPUP_SHADOW: [f32; 2] = [12.0, 4.0];
+/// How far a popup slides in from its anchor as it fades in.
+const SLIDE: f32 = 6.0;
/// Logical size of a box's icon, and its distance from the label.
const ICON: f32 = 16.0;
const ICON_GAP: f32 = 6.0;
@@ -154,11 +161,50 @@ impl BitOr for Flags {
pub enum Shape {
#[default]
Rounded,
- /// A section tab: the top leading corner rounded and the trailing edge leaning out
- /// `slant` pixels from top to bottom, centred on the box's edge so neighbours overlap.
- Tab { slant: f32 },
- /// Only the trailing corners rounded, so the leading edge joins what it sits against.
- Trailing,
+ /// A section tab: the top leading corner rounded and the top trailing corner `lean`
+ /// pixels inside the box's width, from where the trailing edge leans out at 45°, so
+ /// neighbours overlap and a taller tab only reaches further along the bottom.
+ Tab { lean: f32 },
+}
+
+/// Where a popup opens, flipping to the far side of its anchor where the window ends first.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum Anchor {
+ /// Under the rectangle, from its leading edge, as a drop-down opens.
+ Below([f32; 4]),
+ /// Past the rectangle's trailing edge, from its top, as a submenu opens.
+ Right([f32; 4]),
+ /// Over the rectangle from its corner, as a combo box opens into its own list.
+ Over([f32; 4]),
+ /// At a point, as a context menu opens: at once, without fading in.
+ Point([f32; 2]),
+}
+
+impl Anchor {
+ /// Where a popup `size` long on `axis` starts in a window `room` long: past the
+ /// anchor on the axis it opens along, level with it otherwise, `slide` nearer to it
+ /// along the way it opens.
+ fn place(self, axis: usize, size: f32, room: f32, slide: f32) -> f32 {
+ let (rect, along) = match self {
+ Anchor::Below(rect) => (rect, Some(1)),
+ Anchor::Right(rect) => (rect, Some(0)),
+ Anchor::Over(rect) => (rect, None),
+ Anchor::Point([x, y]) => ([x, y, x, y], Some(1)),
+ };
+ let [low, high] = [rect[axis], rect[axis + 2]];
+ let (first, second, slide) = if along == Some(axis) {
+ (high, low - size, slide)
+ } else {
+ (low, high - size, 0.0)
+ };
+ if first + size <= room {
+ first - slide
+ } else if second >= 0.0 {
+ second + slide
+ } else {
+ first.min(room - size).max(0.0)
+ }
+ }
}
/// What a box is this frame. Colours are linear RGBA.
@@ -194,6 +240,11 @@ pub struct Spec<'a> {
/// For floating boxes, the offset from the parent's corner.
pub position: [f32; 2],
pub cursor: Option,
+ /// Floats the box over all others beside a rectangle in the window, as a popup
+ /// `Ui::open_popup` opened; boxes beneath take no input while one is open.
+ pub anchor: Option,
+ /// How transparent the box and its contents are, from 0 to 1.
+ pub fade: f32,
}
/// Input the host forwards; positions and wheel distances are logical pixels.
@@ -229,7 +280,7 @@ pub struct Signal {
pub dragging: bool,
pub focused: bool,
/// For custom boxes: every event routed to the box, in order. For focused boxes:
- /// keys, text and composition.
+ /// keys, text and composition. For scrolling boxes: the wheel.
pub events: Vec,
}
@@ -271,6 +322,9 @@ struct Built {
center: bool,
position: [f32; 2],
cursor: Option,
+ anchor: Option,
+ /// The opacity of the box and its contents; a popup's rises as it opens.
+ alpha: f32,
/// Rectangles relative to the box, painted over its fill.
marks: Vec<([f32; 4], [f32; 4])>,
computed: [f32; 2],
@@ -299,6 +353,16 @@ struct State {
tween: Option<[f32; 2]>,
}
+/// An open popup, above the one opened before it.
+struct Popup {
+ id: Id,
+ /// The focus before it opened, restored when it closes.
+ focus: Option,
+ /// Its filter field's text, and the key of the row the keyboard or pointer last chose.
+ query: String,
+ highlight: Option,
+}
+
struct Hit {
id: Id,
rect: [f32; 4],
@@ -351,6 +415,28 @@ enum Display {
},
}
+impl Display {
+ /// Multiplies the item's opacity by `alpha`.
+ fn fade(&mut self, alpha: f32) {
+ let fade = |color: &mut [f32; 4]| color[3] *= alpha;
+ match self {
+ Display::Rect {
+ fill,
+ shade,
+ border,
+ ..
+ } => [Some(fill), shade.as_mut(), border.as_mut()]
+ .into_iter()
+ .flatten()
+ .for_each(fade),
+ Display::Segment { color, .. } | Display::Text { color, .. } => fade(color),
+ Display::Path { colors, .. } => colors.iter_mut().for_each(fade),
+ Display::Icon { tint, .. } => fade(tint),
+ Display::Clip(_) | Display::Image { .. } | Display::Custom { .. } => {}
+ }
+ }
+}
+
pub struct Ui {
pub theme: Theme,
frame: u64,
@@ -364,7 +450,15 @@ pub struct Ui {
signals: HashMap,
/// The previous frame's interactive boxes in paint order.
hits: Vec,
+ /// Hits before this one lie beneath an open popup.
+ modal: usize,
+ popups: Vec,
pointer: Option<[f32; 2]>,
+ /// The pointer moved this frame.
+ moved: bool,
+ /// The share of its remaining distance an animated value closes this frame.
+ rate: f32,
+ lists: HashMap,
hover: Option,
active: Option,
focus: Option,
@@ -389,7 +483,12 @@ impl Ui {
queue: Vec::new(),
signals: HashMap::new(),
hits: Vec::new(),
+ modal: 0,
+ popups: Vec::new(),
pointer: None,
+ moved: false,
+ rate: 1.0,
+ lists: HashMap::new(),
hover: None,
active: None,
focus: None,
@@ -477,6 +576,7 @@ impl Ui {
self.stack.clear();
self.stack.push(0);
self.signals.clear();
+ self.moved = false;
for event in std::mem::take(&mut self.queue) {
self.route(event);
}
@@ -487,6 +587,7 @@ impl Ui {
match event {
Event::PointerMoved(point) => {
self.pointer = Some(point);
+ self.moved = true;
let hover = self.hit(point, Flags::CLICKABLE | Flags::CUSTOM);
if hover != self.hover
&& self.active.is_none_or(|active| Some(active) != self.hover)
@@ -511,6 +612,18 @@ impl Ui {
let Some(point) = self.pointer else {
return;
};
+ if !self.popups.is_empty() {
+ let under = self.popups.iter().rposition(|popup| {
+ self.rect(popup.id)
+ .is_some_and(|rect| contains(rect, point))
+ });
+ // A press outside every popup only dismisses them.
+ let Some(under) = under else {
+ self.close_from(0);
+ return;
+ };
+ self.close_from(under + 1);
+ }
let target = self.hit(point, Flags::CLICKABLE | Flags::CUSTOM);
if let Some(id) = target
&& button == MouseButton::Left
@@ -559,10 +672,15 @@ impl Ui {
let state = self.states.entry(id).or_default();
let most = (state.content - (state.rect[3] - state.rect[1])).max(0.0);
state.scroll_target = (state.scroll_target - delta[1]).clamp(0.0, most);
+ self.signals.entry(id).or_default().events.push(event);
}
None => {}
}
}
+ Event::Key {
+ key: Key::Named(NamedKey::Escape),
+ ..
+ } if !self.popups.is_empty() => self.close_from(self.popups.len() - 1),
Event::Key { .. } | Event::Ime(_) => {
if let Some(focus) = self.focus {
self.signals.entry(focus).or_default().events.push(event);
@@ -604,21 +722,62 @@ impl Ui {
/// The topmost box under `point` with any of `flags`.
fn hit(&self, point: [f32; 2], flags: Flags) -> Option {
- self.hits
+ self.hits[self.modal..]
.iter()
.rev()
- .find(|hit| {
- hit.flags.intersects(flags)
- && point[0] >= hit.rect[0]
- && point[0] < hit.rect[2]
- && point[1] >= hit.rect[1]
- && point[1] < hit.rect[3]
- })
+ .find(|hit| hit.flags.intersects(flags) && contains(hit.rect, point))
.map(|hit| hit.id)
}
+ /// Opens popup `id`, which shows while built with its `Spec::anchor` each frame, and
+ /// takes the keyboard. Popups open from within another stay above it; any other
+ /// closes those open.
+ pub fn open_popup(&mut self, id: Id) {
+ if self.popup_open(id) {
+ return;
+ }
+ let within = self
+ .stack
+ .iter()
+ .filter_map(|index| {
+ let id = self.nodes[*index].id;
+ self.popups.iter().position(|popup| popup.id == id)
+ })
+ .max();
+ self.close_from(within.map_or(0, |within| within + 1));
+ self.popups.push(Popup {
+ id,
+ focus: self.focus,
+ query: String::new(),
+ highlight: None,
+ });
+ self.focus = Some(id);
+ let state = self.states.entry(id).or_default();
+ state.touched = self.frame;
+ state.tween = Some([0.0, 1.0]);
+ }
+
+ pub fn popup_open(&self, id: Id) -> bool {
+ self.popups.iter().any(|popup| popup.id == id)
+ }
+
+ /// Closes popup `id` and those opened from it, returning the focus it took.
+ pub fn close_popup(&mut self, id: Id) {
+ if let Some(index) = self.popups.iter().position(|popup| popup.id == id) {
+ self.close_from(index);
+ }
+ }
+
+ fn close_from(&mut self, index: usize) {
+ if let Some(popup) = self.popups.get(index) {
+ self.focus = popup.focus;
+ self.popups.truncate(index);
+ }
+ }
+
fn ease(&mut self, dt: f32) {
let rate = 1.0 - 0.5_f32.powf(dt / HALF_LIFE);
+ self.rate = rate;
let mut animating = false;
for (id, state) in &mut self.states {
let hot = self.hover == Some(*id) && self.active.is_none_or(|active| active == *id);
@@ -667,13 +826,24 @@ impl Ui {
let label = spec
.text
.map(|text| self.texts.label(text, self.theme.font_size, self.frame));
- let parent = *self.stack.last().unwrap();
+ // Popups hang from the root, outside the clips and flow of where they are built.
+ let parent = if spec.anchor.is_some() {
+ 0
+ } else {
+ *self.stack.last().unwrap()
+ };
let index = self.nodes.len();
- self.nodes
- .push(Built::new(id, parent, spec, label, self.theme.text));
+ let state = self.states.entry(id).or_default();
+ state.touched = self.frame;
+ let reveal = match spec.anchor {
+ Some(Anchor::Point(_)) | None => 1.0,
+ Some(_) => state.tween.map_or(1.0, |tween| tween[0]),
+ };
+ let mut built = Built::new(id, parent, spec, label, self.theme.text);
+ built.alpha *= reveal;
+ self.nodes.push(built);
self.nodes[parent].children.push(index);
self.stack.push(index);
- self.states.entry(id).or_default().touched = self.frame;
id
}
@@ -745,9 +915,24 @@ impl Ui {
self.states
.retain(|id, state| state.touched == self.frame || *id == Id::ROOT);
self.texts.prune(self.frame);
+ self.lists.retain(|id, _| self.states.contains_key(id));
+ if let Some(gone) = self
+ .popups
+ .iter()
+ .position(|popup| !self.states.contains_key(&popup.id))
+ {
+ self.close_from(gone);
+ }
self.display.clear();
self.hits.clear();
self.paint(0, None);
+ let beneath = self.hits.len();
+ for index in self.nodes[0].children.clone() {
+ if self.nodes[index].anchor.is_some() {
+ self.paint(index, None);
+ }
+ }
+ self.modal = if self.popups.is_empty() { 0 } else { beneath };
for id in [&mut self.hover, &mut self.active, &mut self.focus] {
if id.is_some_and(|id| !self.states.contains_key(&id)) {
*id = None;
@@ -756,6 +941,7 @@ impl Ui {
}
fn paint(&mut self, index: usize, clip: Option<[f32; 4]>) {
+ let start = self.display.len();
let node = &self.nodes[index];
let state = &self.states[&node.id];
let rect = node.rect;
@@ -781,10 +967,15 @@ impl Ui {
let border = blend(node.border, node.hover_border);
let size = [rect[2] - rect[0], rect[3] - rect[1]];
if let Some(shadow) = node.shadow {
+ let [spread, drop] = if node.anchor.is_some() {
+ POPUP_SHADOW
+ } else {
+ SHADOW
+ };
self.display.push(Display::Path {
data: outline(node.shape, size, node.radius),
- origin: [rect[0], rect[1]],
- style: PathStyle::Shadow(SHADOW),
+ origin: [rect[0], rect[1] + drop],
+ style: PathStyle::Shadow(spread),
colors: [shadow; 2],
});
}
@@ -889,11 +1080,19 @@ impl Ui {
self.display.push(Display::Clip(inner_clip));
}
for child in node.children.clone() {
- self.paint(child, inner_clip);
+ if self.nodes[child].anchor.is_none() {
+ self.paint(child, inner_clip);
+ }
}
if inner_clip != clip {
self.display.push(Display::Clip(clip));
}
+ let alpha = self.nodes[index].alpha;
+ if alpha < 1.0 {
+ for item in &mut self.display[start..] {
+ item.fade(alpha);
+ }
+ }
}
/// This frame's painting in order, in logical pixels.
@@ -1067,6 +1266,8 @@ impl Built {
center: spec.center,
position: spec.position,
cursor: spec.cursor,
+ anchor: spec.anchor,
+ alpha: 1.0 - spec.fade,
marks: Vec::new(),
computed: [0.0; 2],
relative: [0.0; 2],
@@ -1080,17 +1281,11 @@ impl Built {
/// A tab's outline stays open along its bottom, so a border leaves the edge it stands on.
fn outline(shape: Shape, [width, height]: [f32; 2], radius: f32) -> String {
let corners = match shape {
- Shape::Tab { slant } => [
+ Shape::Tab { lean } => [
([0.0, height], 0.0),
([0.0, 0.0], radius),
- ([width - slant / 2.0, 0.0], radius / 2.0),
- ([width + slant / 2.0, height], 0.0),
- ],
- Shape::Trailing => [
- ([0.0, height], 0.0),
- ([0.0, 0.0], 0.0),
- ([width, 0.0], radius),
- ([width, height], radius),
+ ([width - lean, 0.0], radius / 2.0),
+ ([width - lean + height, height], 0.0),
],
Shape::Rounded => [
([0.0, height], radius),
@@ -1174,6 +1369,10 @@ pub fn mix(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {
std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t)
}
+fn contains(rect: [f32; 4], point: [f32; 2]) -> bool {
+ point[0] >= rect[0] && point[0] < rect[2] && point[1] >= rect[1] && point[1] < rect[3]
+}
+
fn intersect(a: [f32; 4], b: [f32; 4]) -> Option<[f32; 4]> {
let rect = [
a[0].max(b[0]),
diff --git a/crates/ui/src/list.rs b/crates/ui/src/list.rs
new file mode 100644
index 0000000000000000000000000000000000000000..0ad3800391e08ef3a2ab4b561c97a0ec7843a6cb
--- /dev/null
+++ b/crates/ui/src/list.rs
@@ -0,0 +1,314 @@
+//! A virtualized list of equal rows keyed by their items: only rows in view are built,
+//! rows ease to where their items move, and the view holds its place on the selection
+//! while items arrive and leave above it.
+
+use crate::{Axis, Event, Flags, Id, SLIDE, Size, Spec, Ui, fill, mix, px, scrollbar};
+use std::collections::HashMap;
+use winit::keyboard::NamedKey;
+
+/// Room beside a scrolling list's rows for its scrollbar.
+pub(crate) const GUTTER: f32 = 12.0;
+
+/// Items a list shows, in order.
+pub trait Rows {
+ /// How many items are listed.
+ fn count(&self) -> usize;
+ /// The key naming the item at `index`, the same in every frame the item is listed.
+ fn key(&self, index: usize) -> u64;
+ /// Where the item named `key` is listed now.
+ fn find(&self, key: u64) -> Option;
+ /// Whether the selection may rest on row `index`.
+ fn selectable(&self, _index: usize) -> bool {
+ true
+ }
+ /// Extra room above row `index`, summed over it and the rows before; `index` may be
+ /// the length.
+ fn space_before(&self, _index: usize) -> f32 {
+ 0.0
+ }
+}
+
+/// How a list shows and moves through its rows.
+pub struct List<'a, R> {
+ pub rows: &'a R,
+ /// Every row's height.
+ pub row: f32,
+ /// Arrow, page, Home and End presses meant for the list this frame; others are ignored.
+ pub keys: &'a [NamedKey],
+ /// The selection follows the pointer, as in a menu.
+ pub hover_selects: bool,
+}
+
+/// A row to build: its item's key, its index while listed or None as it fades out after
+/// leaving, and whether it is selected.
+pub struct Row {
+ pub key: u64,
+ pub index: Option,
+ pub selected: bool,
+}
+
+/// A list's view across frames, in logical pixels.
+#[derive(Default)]
+pub(crate) struct State {
+ scroll: f64,
+ target: f64,
+ /// Rows built last frame, from the top of the view down.
+ shown: Vec,
+ /// The selection last frame, so a new one scrolls into view.
+ selected: Option,
+}
+
+#[derive(Clone, Copy)]
+struct Shown {
+ key: u64,
+ /// Where its row lies, from the top of the view.
+ place: f64,
+ /// How far from its place the row is drawn, easing to nothing.
+ offset: f64,
+ alpha: f32,
+ index: Option,
+}
+
+/// Builds `list` into a box of `spec`, calling `build` inside each row in view, and moves
+/// `selected` with the list's keys. Returns the index of the row clicked.
+pub fn list(
+ ui: &mut Ui,
+ id: Id,
+ spec: Spec<'_>,
+ list: List<'_, R>,
+ selected: &mut Option,
+ mut build: impl FnMut(&mut Ui, Row),
+) -> Option {
+ let List {
+ rows,
+ row,
+ keys,
+ hover_selects,
+ } = list;
+ let height = f64::from(row);
+ let len = rows.count();
+ let top = |index: usize| index as f64 * height + f64::from(rows.space_before(index));
+ // The first row whose bottom lies below `y`.
+ let row_at = |y: f64| {
+ let [mut low, mut high] = [0, len];
+ while low < high {
+ let middle = (low + high) / 2;
+ if top(middle) + height <= y {
+ low = middle + 1;
+ } else {
+ high = middle;
+ }
+ }
+ low
+ };
+ let rect = ui.rect(id);
+ let view = f64::from(match spec.size[1].size {
+ Size::Pixels(pixels) => pixels,
+ _ => rect.map_or(0.0, |rect| rect[3] - rect[1]),
+ });
+ let most = (top(len) - view).max(0.0);
+ let fresh = !ui.lists.contains_key(&id);
+ let mut state = ui.lists.remove(&id).unwrap_or_default();
+
+ // Hold the view on the selection, or the first row showing, wherever it now lies.
+ let listed = || state.shown.iter().filter(|shown| shown.index.is_some());
+ let held = state.selected.and_then(|key| {
+ listed().find(|shown| shown.key == key && shown.place >= 0.0 && shown.place < view)
+ });
+ // At the top, the view stays there to show what arrives.
+ let anchor = held
+ .into_iter()
+ .chain(listed().filter(|shown| state.scroll > 0.0 && shown.place + height > 0.0))
+ .find_map(|shown| Some(top(rows.find(shown.key)?) - shown.place));
+ if let Some(scroll) = anchor {
+ state.target += scroll - state.scroll;
+ state.scroll = scroll;
+ }
+ state.scroll = state.scroll.clamp(0.0, most);
+ state.target = state.target.clamp(0.0, most);
+ let anchored = state.scroll;
+
+ for event in ui.signal(id).events {
+ if let Event::Wheel(delta) = event {
+ state.target = (state.target - f64::from(delta[1])).clamp(0.0, most);
+ }
+ }
+ let mut clicked = None;
+ for shown in &state.shown {
+ let signal = ui.signal(id.child(shown.key));
+ let Some(index) = rows.find(shown.key).filter(|index| rows.selectable(*index)) else {
+ continue;
+ };
+ if hover_selects && signal.hovered && ui.moved {
+ *selected = Some(shown.key);
+ state.selected = *selected;
+ }
+ if signal.clicked {
+ *selected = Some(shown.key);
+ clicked = Some(index);
+ }
+ }
+ let forward = |from: usize| (from..len).find(|index| rows.selectable(*index));
+ let backward = |from: usize| {
+ (0..=from.min(len.saturating_sub(1)))
+ .rev()
+ .find(|index| rows.selectable(*index))
+ };
+ let page = ((view / height) as usize).max(1);
+ let mut current = selected.and_then(|key| rows.find(key));
+ for key in keys.iter().filter(|_| len > 0) {
+ let next = match (key, current) {
+ (NamedKey::ArrowDown, None) => forward(row_at(anchored)),
+ (NamedKey::ArrowDown, Some(at)) => forward(at + 1),
+ (NamedKey::ArrowUp, None) => backward(row_at(anchored + view).saturating_sub(1)),
+ (NamedKey::ArrowUp, Some(at)) => at.checked_sub(1).and_then(backward),
+ (NamedKey::PageDown, at) => {
+ let to = at.map_or(0, |at| (at + page).min(len - 1));
+ forward(to).or_else(|| backward(to))
+ }
+ (NamedKey::PageUp, at) => {
+ let to = at.map_or(0, |at| at.saturating_sub(page));
+ backward(to).or_else(|| forward(to))
+ }
+ (NamedKey::Home, _) => forward(0),
+ (NamedKey::End, _) => backward(len - 1),
+ _ => None,
+ };
+ if next.is_some() {
+ current = next;
+ *selected = current.map(|index| rows.key(index));
+ }
+ }
+ if *selected != state.selected {
+ if let Some(at) = selected.and_then(|key| rows.find(key)) {
+ let lowest = (top(at) + height - view).min(top(at));
+ state.target = state.target.clamp(lowest, top(at)).clamp(0.0, most);
+ }
+ state.selected = *selected;
+ }
+
+ let rate = f64::from(ui.rate);
+ let settle = |value: f64, target: f64, close: f64| {
+ if fresh || (target - value).abs() < close {
+ target
+ } else {
+ value + (target - value) * rate
+ }
+ };
+ state.scroll = settle(state.scroll, state.target, 0.25);
+ let scroll = state.scroll;
+ let step = scroll - anchored;
+
+ // Where each row built last frame was drawn, in the view it was drawn in.
+ let before: HashMap = state
+ .shown
+ .iter()
+ .map(|shown| (shown.key, *shown))
+ .collect();
+ let place = |index: usize| {
+ let key = rows.key(index);
+ let was = top(index) - anchored;
+ let (offset, alpha) = match before.get(&key) {
+ Some(old) => (old.place + old.offset - was, old.alpha),
+ None if !fresh && was + height > 0.0 && was < view => (-SLIDE as f64, 0.0),
+ None => (0.0, 1.0),
+ };
+ Shown {
+ key,
+ place: top(index) - scroll,
+ offset: settle(offset, 0.0, 0.5),
+ alpha: settle(f64::from(alpha), 1.0, 0.01) as f32,
+ index: Some(index),
+ }
+ };
+ let in_view = row_at(scroll)..row_at(scroll + view + height).min(len);
+ let mut shown: Vec = in_view.clone().map(place).collect();
+ let mut leaving = Vec::new();
+ for old in before.values() {
+ match rows.find(old.key) {
+ Some(index) if !in_view.contains(&index) => {
+ let moving = place(index);
+ let drawn = moving.place + moving.offset;
+ if drawn + height > 0.0 && drawn < view {
+ shown.push(moving);
+ }
+ }
+ Some(_) => {}
+ None => {
+ let alpha = settle(f64::from(old.alpha), 0.0, 0.02) as f32;
+ if alpha > 0.0 {
+ leaving.push(Shown {
+ place: old.place + old.offset - step,
+ offset: 0.0,
+ alpha,
+ index: None,
+ ..*old
+ });
+ }
+ }
+ }
+ }
+ shown.sort_by(|a, b| a.place.total_cmp(&b.place));
+ ui.animating |= state.scroll != state.target
+ || !leaving.is_empty()
+ || shown
+ .iter()
+ .any(|shown| shown.offset != 0.0 || shown.alpha < 1.0);
+
+ let theme = ui.theme.clone();
+ ui.open_as(
+ id,
+ Spec {
+ flags: spec.flags | Flags::SCROLL | Flags::CLIP,
+ ..spec
+ },
+ );
+ // Rows leave the scrollbar a gutter.
+ let width = match rect {
+ Some(rect) if most > 0.0 => px(rect[2] - rect[0] - GUTTER),
+ _ => fill(),
+ };
+ for shown in leaving.iter().chain(&shown) {
+ let flags = match shown.index {
+ Some(index) if rows.selectable(index) => Flags::FLOAT | Flags::CLICKABLE,
+ _ => Flags::FLOAT,
+ };
+ ui.open(
+ shown.key,
+ Spec {
+ flags,
+ size: [width, px(row)],
+ position: [0.0, (shown.place + shown.offset) as f32],
+ fade: 1.0 - shown.alpha,
+ ..Spec::default()
+ },
+ );
+ build(
+ ui,
+ Row {
+ key: shown.key,
+ index: shown.index,
+ selected: shown.index.is_some() && *selected == Some(shown.key),
+ },
+ );
+ ui.close();
+ }
+ let thumb = mix(theme.text_dim, theme.chip, 0.5);
+ if let Some(offset) = scrollbar(
+ ui,
+ "bar",
+ Axis::Y,
+ scroll as f32,
+ [0.0, most as f32],
+ view as f32,
+ thumb,
+ ) {
+ state.scroll = f64::from(offset);
+ state.target = state.scroll;
+ }
+ ui.close();
+ shown.extend(leaving);
+ state.shown = shown;
+ ui.lists.insert(id, state);
+ clicked
+}
diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f6d78c84fa90b3f63b72cf358da63a059ce37329
--- /dev/null
+++ b/crates/ui/src/popup.rs
@@ -0,0 +1,559 @@
+//! Controls built on the popup layer: menus and filterable lists, a colour grid and a
+//! command palette. Each builds while its popup is open and returns what was chosen the
+//! frame it is, closing the popup.
+
+use crate::{
+ Anchor, Axis, Event, Flags, ICON, ICON_GAP, Id, List, Popup, Row, Rows, Spec, Ui, children,
+ fill, fit, list::GUTTER, mix, px, text_field,
+};
+use winit::keyboard::{Key, NamedKey};
+
+const CHECK: &[&str] = &[include_str!("../assets/check.svg")];
+/// Inset of a popup's contents, and its distance from what it drops down from.
+const PAD: f32 = 4.0;
+const ROW: f32 = 26.0;
+/// Rows a list shows before it scrolls.
+const ROWS: f32 = 12.0;
+/// Height of the rule between groups.
+const RULE: f32 = 9.0;
+const NARROWEST: f32 = 140.0;
+const PALETTE: f32 = 560.0;
+/// A colour grid's cell, around its swatch.
+const CELL: f32 = 22.0;
+/// The keys a menu's list takes, and Enter to choose.
+const KEYS: [NamedKey; 7] = [
+ NamedKey::ArrowUp,
+ NamedKey::ArrowDown,
+ NamedKey::PageUp,
+ NamedKey::PageDown,
+ NamedKey::Home,
+ NamedKey::End,
+ NamedKey::Enter,
+];
+
+/// A row of a menu or palette.
+#[derive(Clone, Copy, Debug, Default)]
+pub struct Item<'a> {
+ pub text: &'a str,
+ pub icon: Option<&'static [&'static str]>,
+ /// The icon has colours of its own, so the text's colour does not tint it.
+ pub colored: bool,
+ /// Keys that run the item, shown dim at the trailing edge.
+ pub shortcut: &'a str,
+ /// Marked with a check in place of its icon.
+ pub checked: bool,
+ pub disabled: bool,
+ /// Starts a group, ruled off from the one above while unfiltered.
+ pub separated: bool,
+}
+
+/// Builds popup `id` as a menu of `items` beside `anchor` while it is open, under a
+/// filter field showing `filter` while empty when given. Returns the index of the item
+/// chosen.
+pub fn menu(
+ ui: &mut Ui,
+ id: Id,
+ anchor: Anchor,
+ items: &[Item],
+ filter: Option<&str>,
+) -> Option {
+ if !ui.popup_open(id) {
+ return None;
+ }
+ let [text, shortcut] = items.iter().fold([0.0_f32; 2], |[text, shortcut], item| {
+ [
+ text.max(ui.measure(item.text)[0]),
+ shortcut.max(ui.measure(item.shortcut)[0]),
+ ]
+ });
+ let icons = items.iter().any(|item| item.icon.is_some() || item.checked);
+ let width =
+ text + if shortcut > 0.0 {
+ 3.0 * ICON_GAP + shortcut
+ } else {
+ 0.0
+ } + if icons { ICON + ICON_GAP } else { 0.0 }
+ + if items.len() as f32 > ROWS {
+ GUTTER
+ } else {
+ 0.0
+ }
+ + 2.0 * (PAD + 8.0);
+ let least = match anchor {
+ Anchor::Below(rect) => rect[2] - rect[0],
+ Anchor::Over(rect) => rect[2] - rect[0] + 2.0 * PAD,
+ _ => 0.0,
+ };
+ choose(
+ ui,
+ id,
+ anchor,
+ items,
+ filter,
+ width.max(least).max(NARROWEST),
+ )
+}
+
+/// Builds popup `id` as a command palette across the top of the window while it is open,
+/// listing `commands` that match what is typed, most relevant first. Returns the index of
+/// the command chosen.
+pub fn palette(ui: &mut Ui, id: Id, commands: &[Item]) -> Option {
+ if !ui.popup_open(id) {
+ return None;
+ }
+ let window = ui.rect(Id::ROOT).unwrap_or_default();
+ let width = PALETTE.min(window[2] - 8.0 * PAD).max(NARROWEST);
+ let [left, top] = [(window[2] - width) / 2.0, window[3] / 8.0];
+ choose(
+ ui,
+ id,
+ Anchor::Below([left, top, left, top]),
+ commands,
+ Some("Search commands"),
+ width,
+ )
+}
+
+fn choose(
+ ui: &mut Ui,
+ id: Id,
+ anchor: Anchor,
+ items: &[Item],
+ filter: Option<&str>,
+ width: f32,
+) -> Option {
+ let field = id.child("filter");
+ if filter.is_some() && ui.focus == Some(id) {
+ ui.focus = Some(field);
+ }
+ let keys = navigation(ui, &[id, field], &KEYS);
+ let popup = state(ui, id);
+ let mut query = std::mem::take(&mut popup.query);
+ let mut highlight = popup.highlight;
+ let theme = ui.theme.clone();
+ surface(ui, id, anchor, width);
+ if let Some(placeholder) = filter {
+ let before = query.clone();
+ // Over a combo box, the field takes the box's place.
+ let height = match anchor {
+ Anchor::Over(rect) => rect[3] - rect[1],
+ _ => ROW,
+ };
+ text_field(
+ ui,
+ field,
+ &mut query,
+ placeholder,
+ Spec {
+ size: [fill(), px(height)],
+ fill: Some(theme.base),
+ border: Some(theme.accent),
+ radius: 4.0,
+ pad: [8.0, 0.0],
+ ..Spec::default()
+ },
+ );
+ if query != before {
+ highlight = None;
+ }
+ }
+ let matches = Matches::new(items, &query);
+ // Unfiltered, the checked item starts highlighted; filtered, the best match.
+ highlight = highlight.or_else(|| {
+ let first = if query.is_empty() {
+ matches.order.iter().position(|index| items[*index].checked)
+ } else {
+ (0..matches.count()).find(|row| matches.selectable(*row))
+ };
+ first.map(|row| matches.key(row))
+ });
+ let chosen = if matches.count() == 0 {
+ ui.leaf(
+ "empty",
+ Spec {
+ size: [fill(), px(ROW)],
+ text: Some("No matches"),
+ color: Some(theme.text_dim),
+ pad: [8.0, 0.0],
+ ..Spec::default()
+ },
+ );
+ None
+ } else {
+ let window = ui.rect(Id::ROOT).map_or(0.0, |window| window[3]);
+ let field = if filter.is_some() { ROW + PAD } else { 0.0 };
+ let content = matches.count() as f32 * ROW + matches.space_before(matches.count());
+ let view = content.min((ROWS * ROW).min(window - 4.0 * PAD - field).max(ROW));
+ let list = List {
+ rows: &matches,
+ row: ROW,
+ keys: &keys,
+ hover_selects: true,
+ };
+ let clicked = crate::list(
+ ui,
+ id.child("rows"),
+ Spec {
+ size: [fill(), px(view)],
+ ..Spec::default()
+ },
+ list,
+ &mut highlight,
+ |ui, row| menu_row(ui, &theme, &matches, row),
+ );
+ let entered = keys.contains(&NamedKey::Enter);
+ clicked.or_else(|| {
+ highlight
+ .and_then(|key| matches.find(key))
+ .filter(|row| entered && matches.selectable(*row))
+ })
+ };
+ ui.close();
+ if let Some(row) = chosen {
+ ui.close_popup(id);
+ return Some(matches.order[row]);
+ }
+ let popup = state(ui, id);
+ popup.query = query;
+ popup.highlight = highlight;
+ None
+}
+
+/// Builds a menu's row: the item's icon or check, its text and its shortcut, on the
+/// highlight when selected, under a rule when it starts a group.
+fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
+ let item = &matches.items[row.key as usize];
+ if row
+ .index
+ .is_some_and(|index| index > 0 && matches.ruled && item.separated)
+ {
+ ui.leaf(
+ "rule",
+ Spec {
+ flags: Flags::FLOAT,
+ size: [fill(), px(1.0)],
+ position: [0.0, -(RULE + 1.0) / 2.0],
+ fill: Some(theme.chip),
+ ..Spec::default()
+ },
+ );
+ }
+ let color = if item.disabled {
+ theme.text_dim
+ } else {
+ theme.text
+ };
+ ui.open(
+ "item",
+ Spec {
+ size: [fill(), fill()],
+ fill: row.selected.then(|| theme.hover()),
+ radius: 4.0,
+ pad: [8.0, 0.0],
+ gap: ICON_GAP,
+ ..Spec::default()
+ },
+ );
+ if matches.icons {
+ let tint = if item.colored && !item.checked {
+ [1.0, 1.0, 1.0, color[3]]
+ } else {
+ color
+ };
+ ui.leaf(
+ "icon",
+ Spec {
+ size: [px(ICON), px(ROW)],
+ icon: if item.checked { Some(CHECK) } else { item.icon },
+ color: Some(if item.disabled {
+ mix(tint, theme.popup, 0.5)
+ } else {
+ tint
+ }),
+ ..Spec::default()
+ },
+ );
+ }
+ ui.leaf(
+ "text",
+ Spec {
+ size: [fill(), px(ROW)],
+ text: Some(item.text),
+ color: Some(color),
+ ..Spec::default()
+ },
+ );
+ if !item.shortcut.is_empty() {
+ ui.leaf(
+ "shortcut",
+ Spec {
+ size: [fit(), px(ROW)],
+ text: Some(item.shortcut),
+ color: Some(theme.text_dim),
+ ..Spec::default()
+ },
+ );
+ }
+ ui.close();
+}
+
+/// Builds popup `id` as a grid of `swatches` in rows of `columns` beside `anchor` while it
+/// is open, under a button labelled `none` for no colour of its own. Returns the swatch
+/// chosen, or None for the button.
+pub fn colors(
+ ui: &mut Ui,
+ id: Id,
+ anchor: Anchor,
+ none: &str,
+ swatches: &[[f32; 4]],
+ columns: usize,
+) -> Option