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> { + if !ui.popup_open(id) { + return None; + } + // The button is cell 0 and the swatches follow it. + let cell = |index: usize| id.child(("cell", index)); + let count = swatches.len() + 1; + let keys = navigation( + ui, + &[id], + &[ + NamedKey::ArrowLeft, + NamedKey::ArrowRight, + NamedKey::ArrowUp, + NamedKey::ArrowDown, + NamedKey::Enter, + ], + ); + let mut highlight = state(ui, id).highlight.map(|cell| cell as usize); + let mut chosen = None; + for index in 0..count { + let signal = ui.signal(cell(index)); + if signal.hovered && ui.moved { + highlight = Some(index); + } + if signal.clicked { + chosen = Some(index); + } + } + for key in keys { + highlight = Some(match (key, highlight) { + (NamedKey::Enter, _) => { + chosen = chosen.or(highlight); + continue; + } + (_, None) => 0, + (NamedKey::ArrowLeft, Some(at)) => at.saturating_sub(1), + (NamedKey::ArrowRight, Some(at)) => (at + 1).min(count - 1), + (NamedKey::ArrowUp, Some(at)) => at.saturating_sub(columns), + (_, Some(0)) => 1, + (_, Some(at)) if at + columns < count => at + columns, + (_, Some(at)) => at, + }); + } + if let Some(index) = chosen { + ui.close_popup(id); + return Some((index > 0).then(|| swatches[index - 1])); + } + + let theme = ui.theme.clone(); + let lit = |index: usize| (highlight == Some(index)).then(|| theme.hover()); + surface(ui, id, anchor, columns as f32 * CELL + 2.0 * PAD); + ui.open_as( + cell(0), + Spec { + flags: Flags::CLICKABLE, + size: [fill(), px(ROW)], + text: Some(none), + fill: lit(0), + radius: 4.0, + pad: [8.0, 0.0], + ..Spec::default() + }, + ); + ui.close(); + for (row, colors) in swatches.chunks(columns).enumerate() { + ui.open( + ("row", row), + Spec { + size: [fill(), px(CELL)], + ..Spec::default() + }, + ); + for (column, color) in colors.iter().enumerate() { + let index = 1 + row * columns + column; + ui.open_as( + cell(index), + Spec { + flags: Flags::CLICKABLE, + size: [px(CELL), px(CELL)], + fill: lit(index), + radius: 4.0, + pad: [3.0, 3.0], + ..Spec::default() + }, + ); + ui.leaf( + "swatch", + Spec { + size: [fill(), fill()], + fill: Some(*color), + border: Some(mix(*color, theme.text, 0.25)), + radius: 2.0, + ..Spec::default() + }, + ); + ui.close(); + } + ui.close(); + } + ui.close(); + state(ui, id).highlight = highlight.map(|cell| cell as u64); + None +} + +/// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it. +fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) { + let anchor = match anchor { + Anchor::Below([left, top, right, bottom]) => { + Anchor::Below([left, top - PAD, right, bottom + PAD]) + } + // Level with the row it opens from, or with its contents over the box. + Anchor::Right([left, top, right, bottom]) => { + Anchor::Right([left, top - PAD, right, bottom + PAD]) + } + Anchor::Over([left, top, right, bottom]) => { + Anchor::Over([left - PAD, top - PAD, right + PAD, bottom + PAD]) + } + point => point, + }; + let theme = &ui.theme; + let spec = Spec { + axis: Axis::Y, + size: [px(width), children()], + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 6.0, + pad: [PAD; 2], + gap: PAD, + anchor: Some(anchor), + ..Spec::default() + }; + ui.open_as(id, spec); +} + +fn state(ui: &mut Ui, id: Id) -> &mut Popup { + ui.popups + .iter_mut() + .find(|popup| popup.id == id) + .expect("the popup is open") +} + +/// Takes the presses of `keys` routed this frame to the focus when it is one of `owners`. +fn navigation(ui: &mut Ui, owners: &[Id], keys: &[NamedKey]) -> Vec { + let Some(signal) = ui + .focus + .filter(|focus| owners.contains(focus)) + .and_then(|focus| ui.signals.get_mut(&focus)) + else { + return Vec::new(); + }; + let mut taken = Vec::new(); + signal.events.retain(|event| match event { + Event::Key { + key: Key::Named(key), + .. + } if keys.contains(key) => { + taken.push(*key); + false + } + _ => true, + }); + taken +} + +/// The items matching a query, most relevant first, keyed by their index in `items`. +struct Matches<'a> { + items: &'a [Item<'a>], + order: Vec, + /// Each item's row, or None when it does not match. + rows: Vec>, + /// Rules above each row and the end, counted from the top; only while unfiltered. + rules: Vec, + ruled: bool, + icons: bool, +} + +impl<'a> Matches<'a> { + fn new(items: &'a [Item<'a>], query: &str) -> Self { + let mut ranked: Vec<_> = items + .iter() + .enumerate() + .filter_map(|(index, item)| Some((score(query, item.text)?, index))) + .collect(); + ranked.sort_by_key(|(score, _)| std::cmp::Reverse(*score)); + let order: Vec = ranked.into_iter().map(|(_, index)| index).collect(); + let mut rows = vec![None; items.len()]; + for (row, index) in order.iter().enumerate() { + rows[*index] = Some(row); + } + let ruled = query.is_empty(); + let mut rules = vec![0]; + for (row, index) in order.iter().enumerate().skip(1) { + let rule = u32::from(ruled && items[*index].separated); + rules.push(rules[row - 1] + rule); + } + rules.push(*rules.last().unwrap()); + Self { + items, + order, + rows, + rules, + ruled, + icons: items.iter().any(|item| item.icon.is_some() || item.checked), + } + } +} + +impl Rows for Matches<'_> { + fn count(&self) -> usize { + self.order.len() + } + + fn key(&self, index: usize) -> u64 { + self.order[index] as u64 + } + + fn find(&self, key: u64) -> Option { + *self.rows.get(key as usize)? + } + + fn selectable(&self, index: usize) -> bool { + !self.items[self.order[index]].disabled + } + + fn space_before(&self, index: usize) -> f32 { + self.rules[index] as f32 * RULE + } +} + +/// How well `text` matches `query`, ignoring case: 3 at its start, 2 at a word's, 1 +/// anywhere, 0 with the query's characters in order among others; None otherwise. +fn score(query: &str, text: &str) -> Option { + let [query, text] = [query, text].map(str::to_lowercase); + let at_word = |(at, _): (usize, &str)| text[..at].ends_with(|c: char| !c.is_alphanumeric()); + if text.starts_with(&query) { + Some(3) + } else if text.match_indices(&query).any(at_word) { + Some(2) + } else if text.contains(&query) { + Some(1) + } else { + let mut chars = text.chars(); + query + .chars() + .all(|wanted| chars.any(|c| c == wanted)) + .then_some(0) + } +} diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 6334a000b38accc4534e1ce4afcd32f53b06aedf..6c63c6fe9b818c0878cda721fd292364603d8466 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -41,7 +41,7 @@ pub fn section_tabs( let placed: Vec<_> = tabs .iter() .map(|(name, _)| { - let width = 10.0 + ui.measure(name)[0] + 4.0 + tallest / 2.0; + let width = 10.0 + ui.measure(name)[0] + 4.0 + lean(height); left += width; (left - width, width) }) @@ -75,7 +75,7 @@ pub fn section_tabs( border: Some(fade(section.edge, open)), shadow: (open > 0.0).then(|| fade([0.0, 0.0, 0.0, 0.35], open)), radius: TAB_ROUNDING, - shape: Shape::Tab { slant: tall }, + shape: Shape::Tab { lean: lean(height) }, pad: [10.0, 0.0], ..Spec::default() }, @@ -89,10 +89,15 @@ pub fn section_tabs( (clicked, open_tab) } -/// Where the tab laid out at `tab` meets the edge below it: from its leading edge to the -/// foot of its slant. -pub fn tab_base(tab: [f32; 4]) -> [f32; 2] { - [tab[0], tab[2] + (tab[3] - tab[1]) / 2.0] +/// Where the tab laid out at `tab` in a row `height` tall meets the edge below it: from its +/// leading edge to the foot of its slant. +pub fn tab_base(tab: [f32; 4], height: f32) -> [f32; 2] { + [tab[0], tab[2] - lean(height) + (tab[3] - tab[1])] +} + +/// How far inside a tab's box its slant starts: half the open tab's height. +fn lean(height: f32) -> f32 { + (height - 2.0) / 2.0 } /// A square button showing `icon` tinted by `tint`; artwork in its own colours takes white. diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs index 46f704b32a1a7df55b219c7bf80c8dd56cf4d508..0a769da996ac239e03965106c2b0cf551a6eeff6 100644 --- a/crates/ui/src/tests.rs +++ b/crates/ui/src/tests.rs @@ -524,3 +524,568 @@ fn scrollbar_thumbs_track_the_offset_and_drags_reach_both_ends() { build(&mut ui, &mut offset); assert_eq!(offset, 0.0); } + +fn items() -> Vec> { + let item = |text| popup::Item { + text, + ..popup::Item::default() + }; + vec![ + item("Cut"), + item("Copy"), + popup::Item { + disabled: true, + ..item("Paste") + }, + popup::Item { + separated: true, + ..item("Select all") + }, + ] +} + +fn menu_id() -> Id { + Id::ROOT.child("menu") +} + +/// Builds a frame with a button filling the window under the menu, and returns the +/// button's signal and the item chosen. +fn menu_frame(ui: &mut Ui, anchor: Anchor, filter: Option<&str>) -> (Signal, Option) { + let mut result = (Signal::default(), None); + frame(ui, |ui| { + let under = ui.leaf( + "under", + Spec { + flags: Flags::CLICKABLE, + size: [fill(), fill()], + ..Spec::default() + }, + ); + let chosen = popup::menu(ui, menu_id(), anchor, &items(), filter); + result = (under, chosen); + }); + result +} + +const BELOW: Anchor = Anchor::Below([20.0, 20.0, 100.0, 40.0]); + +/// A window with the menu open and faded in below `BELOW`. +fn open_menu(filter: Option<&str>) -> Ui { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + menu_frame(&mut ui, BELOW, filter); + ui.open_popup(menu_id()); + for _ in 0..40 { + menu_frame(&mut ui, BELOW, filter); + } + ui +} + +/// The centre of the menu's row for `item`, from the latest layout. +fn row(ui: &Ui, item: usize) -> [f32; 2] { + let rect = ui.rect(menu_id().child("rows").child(item)).unwrap(); + [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0] +} + +fn click(ui: &mut Ui, point: [f32; 2]) { + let at = Instant::now(); + ui.event(Event::PointerMoved(point)); + press(ui, at, true); + press(ui, at, false); +} + +#[test] +fn popups_open_beside_their_anchor_and_flip_to_stay_in_the_window() { + let mut ui = open_menu(None); + let rect = ui.rect(menu_id()).unwrap(); + assert_eq!( + [rect[0], rect[1]], + [20.0, 44.0], + "under the anchor, a gap below" + ); + assert_eq!(rect[2] - rect[0], 140.0, "no narrower than a menu"); + let height = rect[3] - rect[1]; + menu_frame(&mut ui, Anchor::Below([300.0, 260.0, 380.0, 280.0]), None); + menu_frame(&mut ui, Anchor::Below([300.0, 260.0, 380.0, 280.0]), None); + assert_eq!( + ui.rect(menu_id()), + Some([240.0, 256.0 - height, 380.0, 256.0]), + "above, level with the anchor's far edge" + ); + menu_frame(&mut ui, Anchor::Right([350.0, 100.0, 390.0, 120.0]), None); + menu_frame(&mut ui, Anchor::Right([350.0, 100.0, 390.0, 120.0]), None); + assert_eq!( + ui.rect(menu_id()), + Some([210.0, 96.0, 350.0, 96.0 + height]), + "a submenu that cannot fit to the right opens to the left, its first row level" + ); +} + +#[test] +fn popups_fade_in_except_at_a_point() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + menu_frame(&mut ui, BELOW, None); + ui.open_popup(menu_id()); + menu_frame(&mut ui, BELOW, None); + menu_frame(&mut ui, BELOW, None); + let reveal = |ui: &Ui| { + ui.nodes + .iter() + .find(|node| node.id == menu_id()) + .unwrap() + .alpha + }; + assert!(reveal(&ui) > 0.0 && reveal(&ui) < 1.0); + assert!( + ui.rect(menu_id()).unwrap()[1] < 44.0, + "and slide from the anchor" + ); + assert!(ui.wants_frame()); + for _ in 0..10 { + menu_frame(&mut ui, BELOW, None); + } + assert!(reveal(&ui) > 0.95, "within about 150 ms"); + ui.close_popup(menu_id()); + menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None); + ui.open_popup(menu_id()); + menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None); + menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None); + assert_eq!(reveal(&ui), 1.0, "a context menu shows at once"); + assert_eq!(ui.rect(menu_id()).unwrap()[..2], [50.0, 50.0]); +} + +#[test] +fn a_press_outside_dismisses_without_reaching_what_is_beneath() { + let mut ui = open_menu(None); + click(&mut ui, [300.0, 250.0]); + let (under, chosen) = menu_frame(&mut ui, BELOW, None); + assert!(!ui.popup_open(menu_id()) && chosen.is_none()); + assert!(!under.pressed && !under.clicked); + click(&mut ui, [300.0, 250.0]); + let (under, _) = menu_frame(&mut ui, BELOW, None); + assert!(under.clicked, "once closed, beneath takes presses again"); +} + +#[test] +fn escape_dismisses_and_returns_the_focus() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let page = Id::ROOT.child("under"); + menu_frame(&mut ui, BELOW, None); + ui.set_focus(Some(page)); + ui.open_popup(menu_id()); + menu_frame(&mut ui, BELOW, None); + assert_eq!(ui.focused(), Some(menu_id())); + ui.event(key(NamedKey::Escape)); + menu_frame(&mut ui, BELOW, None); + assert!(!ui.popup_open(menu_id())); + assert_eq!(ui.focused(), Some(page)); +} + +#[test] +fn input_over_a_popup_stays_with_it() { + let mut ui = open_menu(None); + ui.event(Event::PointerMoved(row(&ui, 1))); + let (under, _) = menu_frame(&mut ui, BELOW, None); + assert!(!under.hovered); + let point = row(&ui, 1); + click(&mut ui, point); + let (under, chosen) = menu_frame(&mut ui, BELOW, None); + assert_eq!(chosen, Some(1)); + assert!(!under.pressed && !under.clicked); + assert!(!ui.popup_open(menu_id()), "choosing closes it"); +} + +#[test] +fn disabled_items_ignore_the_pointer() { + let mut ui = open_menu(None); + let point = row(&ui, 2); + click(&mut ui, point); + let (_, chosen) = menu_frame(&mut ui, BELOW, None); + assert_eq!(chosen, None); + assert!(ui.popup_open(menu_id())); +} + +#[test] +fn keys_move_the_highlight_past_disabled_items() { + let mut ui = open_menu(None); + for (keys, highlight) in [ + (vec![NamedKey::ArrowDown], 0), + (vec![NamedKey::ArrowDown, NamedKey::ArrowDown], 3), + (vec![NamedKey::ArrowDown], 3), + (vec![NamedKey::ArrowUp], 1), + (vec![NamedKey::End], 3), + (vec![NamedKey::Home], 0), + (vec![NamedKey::PageDown], 3), + ] { + for named in keys { + ui.event(key(named)); + } + menu_frame(&mut ui, BELOW, None); + assert_eq!(ui.popups[0].highlight, Some(highlight)); + } + ui.event(key(NamedKey::Enter)); + assert_eq!(menu_frame(&mut ui, BELOW, None).1, Some(3)); +} + +#[test] +fn the_pointer_moves_the_highlight_only_when_it_moves() { + let mut ui = open_menu(None); + ui.event(Event::PointerMoved(row(&ui, 1))); + menu_frame(&mut ui, BELOW, None); + ui.event(key(NamedKey::ArrowDown)); + menu_frame(&mut ui, BELOW, None); + menu_frame(&mut ui, BELOW, None); + assert_eq!( + ui.popups[0].highlight, + Some(3), + "a still pointer keeps its row" + ); +} + +#[test] +fn typing_filters_to_the_best_matches_first() { + let mut ui = open_menu(Some("Filter")); + assert_eq!(ui.focused(), Some(menu_id().child("filter"))); + for text in ["c", "t"] { + ui.event(typed(text)); + } + let settle = |ui: &mut Ui| { + for _ in 0..30 { + menu_frame(ui, BELOW, Some("Filter")); + } + }; + settle(&mut ui); + assert_eq!(ui.popups[0].query, "ct"); + let shown = |ui: &Ui| { + let mut shown: Vec<_> = (0..4_usize) + .filter_map(|item| { + let rect = ui.rect(menu_id().child("rows").child(item))?; + Some((rect[1] as i32, item)) + }) + .collect(); + shown.sort(); + shown.into_iter().map(|(_, item)| item).collect::>() + }; + assert_eq!(shown(&ui), [3, 0], "letters together before letters apart"); + ui.event(key(NamedKey::Backspace)); + settle(&mut ui); + assert_eq!(shown(&ui), [0, 1, 3], "a prefix before a word's start"); + ui.event(key(NamedKey::ArrowDown)); + ui.event(key(NamedKey::Enter)); + assert_eq!(menu_frame(&mut ui, BELOW, Some("Filter")).1, Some(1)); +} + +#[test] +fn nothing_matching_leaves_nothing_to_choose() { + let mut ui = open_menu(Some("Filter")); + ui.event(typed("z")); + menu_frame(&mut ui, BELOW, Some("Filter")); + ui.event(key(NamedKey::ArrowDown)); + ui.event(key(NamedKey::Enter)); + let (_, chosen) = menu_frame(&mut ui, BELOW, Some("Filter")); + assert_eq!(chosen, None); + assert!(ui.popup_open(menu_id())); +} + +#[test] +fn the_palette_centres_across_the_window_and_chooses_the_best_match() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let palette = Id::ROOT.child("palette"); + let commands = items(); + let build = |ui: &mut Ui| { + let mut chosen = None; + frame(ui, |ui| chosen = popup::palette(ui, palette, &commands)); + chosen + }; + build(&mut ui); + ui.open_popup(palette); + for _ in 0..40 { + build(&mut ui); + } + let rect = ui.rect(palette).unwrap(); + assert_eq!(rect[0] + rect[2], 400.0, "centred"); + ui.event(typed("a")); + ui.event(key(NamedKey::Enter)); + assert_eq!( + build(&mut ui), + Some(3), + "`all` begins a word; `Paste` is disabled" + ); +} + +#[test] +fn colour_grids_move_in_two_dimensions_and_choose_a_swatch_or_none() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let grid = Id::ROOT.child("colours"); + let swatches: Vec<[f32; 4]> = (0..6).map(|index| [index as f32, 0.0, 0.0, 1.0]).collect(); + let build = |ui: &mut Ui| { + let mut chosen = None; + frame(ui, |ui| { + chosen = popup::colors(ui, grid, BELOW, "Automatic", &swatches, 3); + }); + chosen + }; + build(&mut ui); + ui.open_popup(grid); + build(&mut ui); + for named in [ + NamedKey::ArrowDown, + NamedKey::ArrowDown, + NamedKey::ArrowDown, + NamedKey::ArrowRight, + NamedKey::Enter, + ] { + ui.event(key(named)); + } + assert_eq!(build(&mut ui), Some(Some(swatches[4]))); + ui.open_popup(grid); + for _ in 0..40 { + build(&mut ui); + } + let rect = ui.rect(grid.child(("cell", 0_usize))).unwrap(); + click(&mut ui, [rect[0] + 4.0, rect[1] + 4.0]); + assert_eq!(build(&mut ui), Some(None)); +} + +#[test] +fn popups_opened_within_a_popup_stay_above_it() { + let mut ui = open_menu(None); + let submenu = Id::ROOT.child("submenu"); + let build = |ui: &mut Ui, open: bool| { + frame(ui, |ui| { + ui.open_as( + menu_id(), + Spec { + size: [px(100.0), px(100.0)], + anchor: Some(BELOW), + ..Spec::default() + }, + ); + if open { + ui.open_popup(submenu); + } + ui.close(); + popup::menu( + ui, + submenu, + Anchor::Right([100.0, 50.0, 120.0, 70.0]), + &items(), + None, + ); + }) + }; + build(&mut ui, true); + build(&mut ui, false); + assert!(ui.popup_open(menu_id()) && ui.popup_open(submenu)); + ui.event(key(NamedKey::Escape)); + build(&mut ui, false); + assert!(ui.popup_open(menu_id()) && !ui.popup_open(submenu)); +} + +/// Keys in order, counting the lookups a list makes. +struct Keyed { + keys: Vec, + at: HashMap, + lookups: std::cell::Cell, +} + +impl Keyed { + fn new(keys: impl IntoIterator) -> Self { + let keys: Vec = keys.into_iter().collect(); + let at = keys + .iter() + .enumerate() + .map(|(index, key)| (*key, index)) + .collect(); + Self { + keys, + at, + lookups: Default::default(), + } + } +} + +impl Rows for Keyed { + fn count(&self) -> usize { + self.keys.len() + } + + fn key(&self, index: usize) -> u64 { + self.lookups.set(self.lookups.get() + 1); + self.keys[index] + } + + fn find(&self, key: u64) -> Option { + self.lookups.set(self.lookups.get() + 1); + self.at.get(&key).copied() + } +} + +const LIST_ROW: f32 = 20.0; + +fn list_id() -> Id { + Id::ROOT.child("list") +} + +/// Builds a frame of a list 200 high over `rows`, ten rows in view. +fn list_frame( + ui: &mut Ui, + rows: &Keyed, + selected: &mut Option, + keys: &[NamedKey], +) -> Option { + let mut clicked = None; + frame(ui, |ui| { + let list = List { + rows, + row: LIST_ROW, + keys, + hover_selects: false, + }; + let spec = Spec { + size: [px(200.0), px(200.0)], + ..Spec::default() + }; + clicked = crate::list(ui, list_id(), spec, list, selected, |ui, row| { + ui.leaf( + "label", + Spec { + size: [fill(), fill()], + fill: row.selected.then_some([1.0; 4]), + ..Spec::default() + }, + ); + }); + }); + clicked +} + +fn row_top(ui: &Ui, key: u64) -> Option { + ui.rect(list_id().child(key)).map(|rect| rect[1]) +} + +/// Frames until the list's animations end. +fn settle_list(ui: &mut Ui, rows: &Keyed, selected: &mut Option) { + for _ in 0..40 { + list_frame(ui, rows, selected, &[]); + } + assert!(!ui.wants_frame()); +} + +#[test] +fn lists_build_only_the_rows_in_view() { + let mut boxes = Vec::new(); + for count in [20, 200_000] { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let rows = Keyed::new(0..count); + let mut selected = None; + settle_list(&mut ui, &rows, &mut selected); + rows.lookups.set(0); + list_frame(&mut ui, &rows, &mut selected, &[]); + assert!(rows.lookups.get() < 100, "{} lookups", rows.lookups.get()); + boxes.push(ui.nodes.len()); + } + assert_eq!(boxes[0], boxes[1]); +} + +#[test] +fn lists_hold_the_selection_still_as_items_arrive_and_leave_above() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let mut rows = Keyed::new(0..1000); + let mut selected = Some(500); + settle_list(&mut ui, &rows, &mut selected); + let at = row_top(&ui, 500).unwrap(); + assert!( + (0.0..200.0).contains(&at), + "a new selection scrolls into view" + ); + rows = Keyed::new((10_000..10_100).chain(0..1000)); + list_frame(&mut ui, &rows, &mut selected, &[]); + assert_eq!(row_top(&ui, 500), Some(at), "a hundred rows arrived above"); + rows = Keyed::new((0..1000).filter(|key| key % 2 == 1 || *key >= 500)); + list_frame(&mut ui, &rows, &mut selected, &[]); + assert_eq!(row_top(&ui, 500), Some(at), "half the rows above left"); + ui.event(Event::PointerMoved([50.0, 50.0])); + ui.event(Event::Wheel([0.0, -60.0])); + list_frame(&mut ui, &rows, &mut selected, &[]); + rows = Keyed::new((20_000..20_050).chain(rows.keys.iter().copied())); + list_frame(&mut ui, &rows, &mut selected, &[]); + let moving = row_top(&ui, 500).unwrap(); + assert!( + moving < at && moving > at - 60.0, + "the wheel eases on through arrivals" + ); + settle_list(&mut ui, &rows, &mut selected); + assert_eq!(row_top(&ui, 500), Some(at - 60.0)); +} + +#[test] +fn rows_ease_to_where_their_items_move_and_fade_in_and_out() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let mut selected = None; + let rows = Keyed::new(0..20); + settle_list(&mut ui, &rows, &mut selected); + assert_eq!(row_top(&ui, 5), Some(5.0 * LIST_ROW)); + let rows = Keyed::new([5, 0, 1, 2, 3, 100, 6, 7, 8, 9, 10, 11]); + list_frame(&mut ui, &rows, &mut selected, &[]); + let alpha = |ui: &Ui, key: u64| { + let id = list_id().child(key); + ui.nodes + .iter() + .find(|node| node.id == id) + .map(|node| node.alpha) + }; + let moved = row_top(&ui, 5).unwrap(); + assert!( + moved > 0.0 && moved < 5.0 * LIST_ROW, + "on its way up: {moved}" + ); + let arriving = alpha(&ui, 100).unwrap(); + assert!(arriving > 0.0 && arriving < 1.0, "fading in"); + let leaving = alpha(&ui, 4).unwrap(); + assert!(leaving > 0.0 && leaving < 1.0, "fading out"); + for _ in 0..10 { + list_frame(&mut ui, &rows, &mut selected, &[]); + } + assert!( + row_top(&ui, 5).unwrap() < 3.0, + "all but there within about 150 ms" + ); + settle_list(&mut ui, &rows, &mut selected); + assert_eq!(row_top(&ui, 5), Some(0.0)); + assert_eq!(alpha(&ui, 100), Some(1.0)); + assert_eq!(alpha(&ui, 4), None); +} + +#[test] +fn keys_move_the_selection_and_the_view_eases_after_it() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let rows = Keyed::new(0..1000); + let mut selected = None; + settle_list(&mut ui, &rows, &mut selected); + list_frame(&mut ui, &rows, &mut selected, &[NamedKey::ArrowDown]); + assert_eq!(selected, Some(0), "from nothing, the first row in view"); + list_frame(&mut ui, &rows, &mut selected, &[NamedKey::PageDown]); + assert_eq!(selected, Some(10)); + list_frame(&mut ui, &rows, &mut selected, &[NamedKey::End]); + assert_eq!(selected, Some(999)); + assert!( + ui.wants_frame() && row_top(&ui, 999) != Some(200.0 - LIST_ROW), + "the view eases towards it" + ); + settle_list(&mut ui, &rows, &mut selected); + assert_eq!( + row_top(&ui, 999), + Some(200.0 - LIST_ROW), + "the last row at the bottom" + ); + list_frame( + &mut ui, + &rows, + &mut selected, + &[NamedKey::PageUp, NamedKey::ArrowUp], + ); + assert_eq!(selected, Some(988)); + list_frame(&mut ui, &rows, &mut selected, &[NamedKey::Home]); + settle_list(&mut ui, &rows, &mut selected); + assert_eq!((selected, row_top(&ui, 0)), (Some(0), Some(0.0))); +} diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 6461f0066e5d18a7951cdbe93a1434628d4bfc01..41558a4644748ad2eec63c6558e621344da4294f 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -21,6 +21,9 @@ pub struct Theme { pub paper_ink: [f32; 4], /// Key caps, small controls and field borders. pub chip: [f32; 4], + /// Menus and other popups, and the shadow they cast. + pub popup: [f32; 4], + pub shadow: [f32; 4], /// Saturation and lightness each of a section's colours takes from its hue. pub shades: Shades, } @@ -62,6 +65,8 @@ impl Theme { paper: srgb(0x1f, 0x20, 0x22), paper_ink: srgb(0xe6, 0xe6, 0xe6), chip: srgb(0x38, 0x3c, 0x3d), + popup: srgb(0x2b, 0x2e, 0x30), + shadow: [0.0, 0.0, 0.0, 0.6], shades: Shades { frame: [[0.30, 0.36], [0.30, 0.30]], tab: [0.22, 0.25], @@ -84,6 +89,8 @@ impl Theme { paper: [1.0; 4], paper_ink: [0.0, 0.0, 0.0, 1.0], chip: srgb(0xcf, 0xd3, 0xd9), + popup: [1.0; 4], + shadow: [0.0, 0.0, 0.0, 0.3], shades: Shades { frame: [[0.55, 0.82], [0.55, 0.76]], tab: [0.45, 0.70],