authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 21:48:15-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 04:23:49-07:00
log8a427ca60769dc6268c802235ceed1f70d44ee6f
tree689a93779ae3f095ce2a1a825e49ce9c640275fd
parent332e865dc1d25461af433a52794ec50ebf81f388
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: popups, animated lists and shell polish

ui gains a popup layer anchored to boxes (menus, filterable lists, colour grids and a command palette) that fades in over everything and closes on Escape or an outside press, and a virtualized list whose rows ease to new places, fade in and out, and stay anchored on the selection while items come and go. Combo lists open with their filter over the combo itself. Page tabs that aren't open float as pills beside the page, section tabs keep their top edge as they rise, and rounded rectangles keep their antialiased outer edge. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5

13 files changed, 2412 insertions(+), 57 deletions(-)

Cargo.lock+3
......@@ -3073,6 +3073,9 @@ version = "0.1.0"
30733073dependencies = [
30743074 "draw",
30753075 "parley",
3076 "png",
3077 "pollster",
3078 "wgpu",
30763079 "winit",
30773080]
30783081
crates/draw/src/render.rs+3-1
......@@ -1098,7 +1098,9 @@ impl Renderer {
10981098 rect[3] * space.scale + space.origin[1],
10991099 ];
11001100 let start = self.vertices.len();
1101 self.quad(space, rect, [0.5 / ATLAS_SIZE as f32; 4], colors[0])?;
1101 // A pixel of margin holds the antialiased fringe outside the edge.
1102 let fringe = [rect[0] - 1.0, rect[1] - 1.0, rect[2] + 1.0, rect[3] + 1.0];
1103 self.quad(space, fringe, [0.5 / ATLAS_SIZE as f32; 4], colors[0])?;
11021104 let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5];
11031105 if !(4.0 * (half[0] + half[1])).is_finite() || !colors[1].iter().all(|v| v.is_finite()) {
11041106 return Err(RenderError::InvalidPrimitive);
crates/snowbound/src/main.rs+27-11
......@@ -21,7 +21,7 @@ use std::{
2121 sync::Arc,
2222 time::Instant,
2323};
24use ui::{Axis, Flags, Id, Shape, Spec, Theme, Ui, children, fill, fit, px};
24use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px};
2525use winit::{
2626 application::ApplicationHandler,
2727 dpi::{LogicalSize, PhysicalPosition, PhysicalSize},
......@@ -727,7 +727,10 @@ impl State {
727727 paper,
728728 |_| strip,
729729 );
730 let [foot, toe] = self.ui.rect(open_tab).map_or([end; 2], ui::shell::tab_base);
730 let [foot, toe] = self
731 .ui
732 .rect(open_tab)
733 .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW));
731734 for edge in [
732735 [
733736 ([start, frame[1] + outer], 0.0),
......@@ -1089,28 +1092,41 @@ impl State {
10891092 pad: [10.0 + 16.0 * level.saturating_sub(1) as f32, 0.0],
10901093 ..Spec::default()
10911094 };
1092 let spec = if selected {
1095 let clicked = if selected {
10931096 open = Some(self.ui.id(space));
1094 Spec {
1097 let spec = Spec {
10951098 color: Some(theme.paper_ink),
10961099 fill: Some(theme.paper),
10971100 ..spec
1098 }
1101 };
1102 self.ui.leaf(space, spec).clicked
10991103 } else {
1100 Spec {
1104 // Tabs not open float free of the page as pills; only the open one joins it.
1105 self.ui.open(
1106 space,
1107 Spec {
1108 size: [px(PAGE_LIST), px(26.0)],
1109 pad: [6.0, 0.0],
1110 ..Spec::default()
1111 },
1112 );
1113 let spec = Spec {
1114 size: [fill(), px(26.0)],
11011115 color: Some(if title.is_empty() {
11021116 ui::mix(theme.ink, section.tab, 0.5)
11031117 } else {
11041118 theme.ink
11051119 }),
11061120 fill: Some(section.tab),
1107 hover_fill: Some(section.frame[0]),
1108 radius: ROUNDING,
1109 shape: Shape::Trailing,
1121 hover_fill: Some(ui::mix(section.tab, theme.ink, 0.12)),
1122 radius: 13.0,
11101123 ..spec
1111 }
1124 };
1125 let clicked = self.ui.leaf("pill", spec).clicked;
1126 self.ui.close();
1127 clicked
11121128 };
1113 if self.ui.leaf(space, spec).clicked && !selected {
1129 if clicked && !selected {
11141130 self.commands.push(Command::OpenPage(*space));
11151131 }
11161132 }
crates/ui/Cargo.toml+5
......@@ -8,3 +8,8 @@ publish = false
88draw = { path = "../draw" }
99parley.workspace = true
1010winit = "0.30.13"
11
12[dev-dependencies]
13pollster = "0.4"
14png = "0.18"
15wgpu.workspace = true
crates/ui/assets/check.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M3.75 8.5L6.5 11.25L12.25 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>
crates/ui/examples/popups.rs created+675
......@@ -0,0 +1,675 @@
1//! Renders each popup control open, in both themes, to `/tmp/ui-popups-{dark,light}.png`,
2//! and a combo's list reordering after a keystroke to `/tmp/ui-list-{dark,light}.png`, for
3//! review; then times a list of 200 000 rows.
4
5use std::{
6 collections::HashMap,
7 time::{Duration, Instant},
8};
9use ui::{Anchor, Event, Id, List, Rows, Spec, Theme, Ui, fill, popup::Item, px, shell};
10use winit::keyboard::{Key, NamedKey};
11
12const PANEL: [f32; 2] = [420.0, 340.0];
13const SCALE: f32 = 2.0;
14
15macro_rules! art {
16 ($path:literal) => {
17 &[include_str!(concat!(
18 "../../snowbound/assets/",
19 $path,
20 ".svg"
21 ))]
22 };
23}
24
25fn main() {
26 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
27 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
28 let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
29 let mut renderer = draw::Renderer::new(
30 device.clone(),
31 queue.clone(),
32 wgpu::TextureFormat::Rgba8UnormSrgb,
33 );
34 for (name, theme) in [("dark", Theme::dark()), ("light", Theme::light())] {
35 let scenes: [fn(&mut Scene); 6] = [menu, fonts, colors, palette, tags, highlight];
36 let uis: Vec<Ui> = scenes
37 .iter()
38 .map(|build| {
39 let mut scene = Scene {
40 ui: Ui::new(theme.clone(), Duration::from_millis(500)),
41 start: Instant::now(),
42 frames: 0,
43 };
44 build(&mut scene);
45 scene.ui
46 })
47 .collect();
48 let panels = uis
49 .iter()
50 .map(|ui| paint(&device, &queue, &mut renderer, ui))
51 .collect();
52 let path = format!("/tmp/ui-popups-{name}.png");
53 render(panels, 3, &path);
54 println!("{path}");
55 let path = format!("/tmp/ui-list-{name}.png");
56 render(keystroke(&theme, &device, &queue, &mut renderer), 4, &path);
57 println!("{path}");
58 }
59 measure();
60}
61
62/// A font combo's list before `c` is typed into its filter and at frames after.
63fn keystroke(
64 theme: &Theme,
65 device: &wgpu::Device,
66 queue: &wgpu::Queue,
67 renderer: &mut draw::Renderer,
68) -> Vec<Vec<u8>> {
69 let mut scene = Scene {
70 ui: Ui::new(theme.clone(), Duration::from_millis(500)),
71 start: Instant::now(),
72 frames: 0,
73 };
74 let items = font_items();
75 let id = Id::ROOT.child("fonts");
76 let build = |ui: &mut Ui, [combo, _]: [Id; 2]| {
77 let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());
78 ui::popup::menu(ui, id, anchor, &items, Some("Calibri"));
79 };
80 scene.open(id, Vec::new(), build);
81 let mut panels = vec![paint(device, queue, renderer, &scene.ui)];
82 for event in typed("c") {
83 scene.ui.event(event);
84 }
85 for frame in 1..=20 {
86 scene.frame(build);
87 if [1, 2, 3, 4, 6, 9, 20].contains(&frame) {
88 panels.push(paint(device, queue, renderer, &scene.ui));
89 }
90 }
91 panels
92}
93
94/// Keys in order, and where each is.
95struct Keyed {
96 keys: Vec<u64>,
97 at: HashMap<u64, usize>,
98}
99
100impl Keyed {
101 fn new(keys: Vec<u64>) -> Self {
102 let at = keys
103 .iter()
104 .enumerate()
105 .map(|(index, key)| (*key, index))
106 .collect();
107 Self { keys, at }
108 }
109}
110
111impl Rows for Keyed {
112 fn count(&self) -> usize {
113 self.keys.len()
114 }
115
116 fn key(&self, index: usize) -> u64 {
117 self.keys[index]
118 }
119
120 fn find(&self, key: u64) -> Option<usize> {
121 self.at.get(&key).copied()
122 }
123}
124
125/// Prints how long a frame of a list takes to build and lay out: with 20 rows, with
126/// 200 000, after a keystroke filters those, and while 10 000 arrive each frame.
127fn measure() {
128 let names: Vec<String> = (0..200_000u64)
129 .map(|key| format!("Page {:06}", key.wrapping_mul(2_654_435_761) % 1_000_000))
130 .collect();
131 let mut ui = Ui::new(Theme::dark(), Duration::from_millis(500));
132 let start = Instant::now();
133 let mut frames = 0;
134 let mut selected = Some(100_000);
135 let mut frame = |ui: &mut Ui, rows: &Keyed, selected: &mut Option<u64>| {
136 frames += 1;
137 let began = Instant::now();
138 ui.begin(
139 [400.0, 600.0],
140 2.0,
141 start + Duration::from_millis(16) * frames,
142 );
143 let list = List {
144 rows,
145 row: 24.0,
146 keys: &[],
147 hover_selects: false,
148 };
149 let spec = Spec {
150 size: [fill(), fill()],
151 ..Spec::default()
152 };
153 ui::list(
154 ui,
155 Id::ROOT.child("pages"),
156 spec,
157 list,
158 selected,
159 |ui, row| {
160 let text = &names[row.key as usize];
161 ui.leaf(
162 "name",
163 Spec {
164 size: [fill(), fill()],
165 text: Some(text),
166 fill: row.selected.then_some([0.2, 0.3, 0.4, 1.0]),
167 pad: [8.0, 0.0],
168 ..Spec::default()
169 },
170 );
171 },
172 );
173 ui.end();
174 drop(ui.layers());
175 began.elapsed()
176 };
177 let average = |times: Vec<Duration>| times.iter().sum::<Duration>() / times.len() as u32;
178 for count in [20, 200_000] {
179 let rows = Keyed::new((0..count).collect());
180 let times: Vec<_> = (0..60)
181 .map(|_| frame(&mut ui, &rows, &mut selected))
182 .collect();
183 println!("{count} rows: {:?} a frame", average(times[30..].to_vec()));
184 }
185 let filtering = Instant::now();
186 let rows = Keyed::new(
187 (0..200_000)
188 .filter(|key| names[*key as usize].contains('7'))
189 .collect(),
190 );
191 let filtering = filtering.elapsed();
192 let first = frame(&mut ui, &rows, &mut selected);
193 let times: Vec<_> = (0..12)
194 .map(|_| frame(&mut ui, &rows, &mut selected))
195 .collect();
196 println!(
197 "a keystroke filtering 200 000 to {}: {filtering:?} to filter, then {first:?} and {:?} a frame as rows ease",
198 rows.count(),
199 average(times)
200 );
201 let mut keys: Vec<u64> = (0..100_000).collect();
202 let (mut list, mut rebuild) = (Vec::new(), Vec::new());
203 for wave in 0..10u64 {
204 let rebuilding = Instant::now();
205 // Arrivals interleave with the rows already listed, above and below the view.
206 let arriving = (0..10_000).map(|index| 100_000 + wave * 10_000 + index);
207 keys = keys
208 .chunks(10)
209 .zip(arriving)
210 .flat_map(|(chunk, key)| chunk.iter().copied().chain([key]))
211 .collect();
212 let rows = Keyed::new(keys.clone());
213 rebuild.push(rebuilding.elapsed());
214 list.push(frame(&mut ui, &rows, &mut selected));
215 }
216 println!(
217 "10 000 arriving a frame, up to 200 000: {:?} a frame for the list, {:?} to rebuild the rows",
218 average(list),
219 average(rebuild)
220 );
221}
222
223struct Scene {
224 ui: Ui,
225 start: Instant,
226 frames: u32,
227}
228
229impl Scene {
230 /// Builds a frame over the panel's backdrop and toolbar, handing `build` the ids of the
231 /// combo and the split button's arrow.
232 fn frame(&mut self, build: impl FnOnce(&mut Ui, [Id; 2])) {
233 self.frames += 1;
234 let now = self.start + Duration::from_millis(16) * self.frames;
235 let ui = &mut self.ui;
236 let theme = ui.theme.clone();
237 ui.begin(PANEL, SCALE, now);
238 ui.open(
239 "backdrop",
240 Spec {
241 size: [fill(), fill()],
242 fill: Some(theme.base),
243 ..Spec::default()
244 },
245 );
246 ui.open(
247 "toolbar",
248 Spec {
249 size: [fill(), px(30.0)],
250 fill: Some(theme.strip),
251 pad: [8.0, 4.0],
252 gap: 6.0,
253 ..Spec::default()
254 },
255 );
256 let combo = ui.id("font");
257 shell::combo(ui, "font", "Calibri", 120.0);
258 let split = ui.id("color");
259 shell::split_button(
260 ui,
261 "color",
262 art!("icons/font-color"),
263 Some(draw::srgb(0xe8, 0x3a, 0x30)),
264 );
265 ui.close();
266 ui.close();
267 build(ui, [combo, split.child("menu")]);
268 ui.end();
269 }
270
271 /// Opens `id` and runs frames until it has faded in, feeding `events` once it shows.
272 fn open(&mut self, id: Id, events: Vec<Event>, build: impl Fn(&mut Ui, [Id; 2])) {
273 self.frame(|_, _| {});
274 self.frame(|ui, ids| {
275 ui.open_popup(id);
276 build(ui, ids);
277 });
278 for event in events {
279 self.ui.event(event);
280 }
281 for _ in 0..30 {
282 self.frame(&build);
283 }
284 }
285}
286
287fn below(ui: &Ui, id: Id) -> Anchor {
288 Anchor::Below(ui.rect(id).unwrap_or_default())
289}
290
291fn key(named: NamedKey) -> Event {
292 Event::Key {
293 key: Key::Named(named),
294 text: None,
295 }
296}
297
298fn typed(text: &str) -> Vec<Event> {
299 text.chars()
300 .map(|character| Event::Key {
301 key: Key::Character(character.to_string().into()),
302 text: Some(character.to_string()),
303 })
304 .collect()
305}
306
307fn menu(scene: &mut Scene) {
308 let items = [
309 Item {
310 text: "Cut",
311 shortcut: "⌘X",
312 ..Item::default()
313 },
314 Item {
315 text: "Copy",
316 shortcut: "⌘C",
317 ..Item::default()
318 },
319 Item {
320 text: "Paste",
321 shortcut: "⌘V",
322 ..Item::default()
323 },
324 Item {
325 text: "Paste as plain text",
326 shortcut: "⌥⇧⌘V",
327 disabled: true,
328 ..Item::default()
329 },
330 Item {
331 text: "Bold",
332 icon: Some(art!("icons/bold")),
333 shortcut: "⌘B",
334 checked: true,
335 separated: true,
336 ..Item::default()
337 },
338 Item {
339 text: "Italic",
340 icon: Some(art!("icons/italic")),
341 shortcut: "⌘I",
342 ..Item::default()
343 },
344 Item {
345 text: "Link…",
346 icon: Some(art!("icons/link")),
347 shortcut: "⌘K",
348 ..Item::default()
349 },
350 Item {
351 text: "Select all",
352 shortcut: "⌘A",
353 separated: true,
354 ..Item::default()
355 },
356 ];
357 let id = Id::ROOT.child("context");
358 let events = vec![key(NamedKey::ArrowDown), key(NamedKey::ArrowDown)];
359 scene.open(id, events, |ui, _| {
360 ui::popup::menu(ui, id, Anchor::Point([140.0, 70.0]), &items, None);
361 });
362}
363
364const FONTS: &[&str] = &[
365 "Aptos",
366 "Arial",
367 "Arial Black",
368 "Bahnschrift",
369 "Calibri",
370 "Cambria",
371 "Candara",
372 "Comic Sans MS",
373 "Consolas",
374 "Constantia",
375 "Corbel",
376 "Courier New",
377 "Georgia",
378 "Segoe UI",
379 "Tahoma",
380 "Times New Roman",
381 "Trebuchet MS",
382 "Verdana",
383];
384
385fn font_items() -> Vec<Item<'static>> {
386 FONTS
387 .iter()
388 .map(|text| Item {
389 text,
390 checked: *text == "Calibri",
391 ..Item::default()
392 })
393 .collect()
394}
395
396fn fonts(scene: &mut Scene) {
397 let items = font_items();
398 let id = Id::ROOT.child("fonts");
399 scene.open(id, typed("ca"), |ui, [combo, _]| {
400 let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());
401 ui::popup::menu(ui, id, anchor, &items, Some("Calibri"));
402 });
403}
404
405fn office_colors() -> Vec<[f32; 4]> {
406 let theme = [
407 (0xff, 0xff, 0xff),
408 (0x00, 0x00, 0x00),
409 (0xee, 0xec, 0xe1),
410 (0x1f, 0x49, 0x7d),
411 (0x4f, 0x81, 0xbd),
412 (0xc0, 0x50, 0x4d),
413 (0x9b, 0xbb, 0x59),
414 (0x80, 0x64, 0xa2),
415 (0x4b, 0xac, 0xc6),
416 (0xf7, 0x96, 0x46),
417 ]
418 .map(|(r, g, b)| draw::srgb(r, g, b));
419 let standard = [
420 (0xc0, 0x00, 0x00),
421 (0xff, 0x00, 0x00),
422 (0xff, 0xc0, 0x00),
423 (0xff, 0xff, 0x00),
424 (0x92, 0xd0, 0x50),
425 (0x00, 0xb0, 0x50),
426 (0x00, 0xb0, 0xf0),
427 (0x00, 0x70, 0xc0),
428 (0x00, 0x20, 0x60),
429 (0x70, 0x30, 0xa0),
430 ]
431 .map(|(r, g, b)| draw::srgb(r, g, b));
432 let mut colors = theme.to_vec();
433 for step in [0.15, 0.35, 0.6, -0.3, -0.6] {
434 colors.extend(theme.iter().map(|color| {
435 let toward = if step > 0.0 {
436 [1.0; 4]
437 } else {
438 [0.0, 0.0, 0.0, 1.0]
439 };
440 ui::mix(*color, toward, f32::abs(step))
441 }));
442 }
443 colors.extend(standard);
444 colors
445}
446
447fn colors(scene: &mut Scene) {
448 let swatches = office_colors();
449 let id = Id::ROOT.child("font color");
450 scene.open(id, Vec::new(), |ui, [_, split]| {
451 let anchor = below(ui, split);
452 ui::popup::colors(ui, id, anchor, "Automatic", &swatches, 10);
453 });
454 let cell = scene.ui.rect(id.child(("cell", 15_usize))).unwrap();
455 scene
456 .ui
457 .event(Event::PointerMoved([cell[0] + 5.0, cell[1] + 5.0]));
458 for _ in 0..30 {
459 scene.frame(|ui, [_, split]| {
460 let anchor = below(ui, split);
461 ui::popup::colors(ui, id, anchor, "Automatic", &swatches, 10);
462 });
463 }
464}
465
466fn highlight(scene: &mut Scene) {
467 let swatches = [
468 (0xff, 0xff, 0x00),
469 (0x00, 0xff, 0x00),
470 (0x00, 0xff, 0xff),
471 (0xff, 0x00, 0xff),
472 (0x00, 0x00, 0xff),
473 (0xff, 0x00, 0x00),
474 (0x00, 0x00, 0x80),
475 (0x00, 0x80, 0x80),
476 (0x00, 0x80, 0x00),
477 (0x80, 0x00, 0x80),
478 (0x80, 0x00, 0x00),
479 (0x80, 0x80, 0x00),
480 (0x80, 0x80, 0x80),
481 (0xc0, 0xc0, 0xc0),
482 (0x00, 0x00, 0x00),
483 ]
484 .map(|(r, g, b)| draw::srgb(r, g, b));
485 let id = Id::ROOT.child("highlight");
486 scene.open(id, vec![key(NamedKey::ArrowDown)], |ui, [_, split]| {
487 let anchor = below(ui, split);
488 ui::popup::colors(ui, id, anchor, "No colour", &swatches, 5);
489 });
490}
491
492fn palette(scene: &mut Scene) {
493 let commands = [
494 ("New page", "⌘N"),
495 ("New section", "⌘T"),
496 ("New notebook", ""),
497 ("Open notebook…", "⌘O"),
498 ("Go to page…", "⌘G"),
499 ("Insert table", "⌥⌘T"),
500 ("Insert date", "⇧⌘D"),
501 ("Numbered list", "⌘/"),
502 ("Bulleted list", "⌘."),
503 ("Toggle page list", ""),
504 ("Zoom in", "⌘="),
505 ("Zoom out", "⌘-"),
506 ]
507 .map(|(text, shortcut)| Item {
508 text,
509 shortcut,
510 ..Item::default()
511 });
512 let id = Id::ROOT.child("palette");
513 scene.open(id, typed("ne"), |ui, _| {
514 ui::popup::palette(ui, id, &commands);
515 });
516}
517
518fn tags(scene: &mut Scene) {
519 let tags = [
520 ("To Do", None::<&'static [&'static str]>, "⌘1"),
521 ("Important", Some(art!("tags/star")), "⌘2"),
522 ("Question", None, "⌘3"),
523 ("Remember for later", Some(art!("tags/remember")), "⌘4"),
524 ("Definition", Some(art!("tags/definition")), "⌘5"),
525 ("Highlight", Some(art!("tags/highlight")), "⌘6"),
526 ("Contact", Some(art!("tags/contact")), "⌘7"),
527 ("Address", Some(art!("tags/address")), "⌘8"),
528 ("Phone number", Some(art!("tags/phone")), "⌘9"),
529 ("Web site to visit", None, ""),
530 ("Idea", None, ""),
531 ("Password", None, ""),
532 ("Critical", None, ""),
533 ("Project A", None, ""),
534 ("Project B", None, ""),
535 ]
536 .map(|(text, icon, shortcut)| Item {
537 text,
538 icon,
539 colored: true,
540 shortcut,
541 ..Item::default()
542 });
543 let id = Id::ROOT.child("tags");
544 scene.open(id, Vec::new(), |ui, [combo, _]| {
545 let rect = ui.rect(combo).unwrap_or_default();
546 let anchor = Anchor::Below([rect[0] + 200.0, rect[1], rect[0] + 222.0, rect[3]]);
547 ui::popup::menu(ui, id, anchor, &tags, Some("Filter tags"));
548 });
549 let row = scene.ui.rect(id.child("rows").child(3_usize)).unwrap();
550 scene
551 .ui
552 .event(Event::PointerMoved([row[0] + 40.0, row[1] + 8.0]));
553 for _ in 0..3 {
554 scene.frame(|ui, [combo, _]| {
555 let rect = ui.rect(combo).unwrap_or_default();
556 let anchor = Anchor::Below([rect[0] + 200.0, rect[1], rect[0] + 222.0, rect[3]]);
557 ui::popup::menu(ui, id, anchor, &tags, Some("Filter tags"));
558 });
559 }
560}
561
562/// Lays out panels of `PANEL` in rows of `columns` and writes them to `path`.
563fn render(panels: Vec<Vec<u8>>, columns: usize, path: &str) {
564 let panel = PANEL.map(|length| (length * SCALE) as u32);
565 let gap = 16;
566 let rows = panels.len().div_ceil(columns) as u32;
567 let size = [
568 (panel[0] + gap) * columns as u32 + gap,
569 (panel[1] + gap) * rows + gap,
570 ];
571 let mut image = vec![0x80; (size[0] * size[1] * 4) as usize];
572 for (index, pixels) in panels.iter().enumerate() {
573 let [left, top] = [
574 gap + (index % columns) as u32 * (panel[0] + gap),
575 gap + (index / columns) as u32 * (panel[1] + gap),
576 ];
577 for (y, line) in pixels.chunks((panel[0] * 4) as usize).enumerate() {
578 let start = (((top + y as u32) * size[0] + left) * 4) as usize;
579 image[start..start + line.len()].copy_from_slice(line);
580 }
581 }
582 let file = std::fs::File::create(path).unwrap();
583 let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), size[0], size[1]);
584 encoder.set_color(png::ColorType::Rgba);
585 encoder.set_depth(png::BitDepth::Eight);
586 encoder
587 .write_header()
588 .unwrap()
589 .write_image_data(&image)
590 .unwrap();
591}
592
593/// Paints `ui`'s frame, each on its own so the atlas holds one popup as the app's would,
594/// and reads it back.
595fn paint(
596 device: &wgpu::Device,
597 queue: &wgpu::Queue,
598 renderer: &mut draw::Renderer,
599 ui: &Ui,
600) -> Vec<u8> {
601 let size = PANEL.map(|length| (length * SCALE) as u32);
602 let interface = ui.layers();
603 let layers: Vec<_> = interface
604 .iter()
605 .filter_map(|layer| match layer {
606 ui::Layer::Primitives { clip, primitives } => Some(draw::Layer {
607 scale: SCALE,
608 origin: [0.0; 2],
609 clip: clip.map(|clip| clip.map(|value| value * SCALE)),
610 primitives,
611 }),
612 ui::Layer::Custom { .. } => None,
613 })
614 .collect();
615 let texture = device.create_texture(&wgpu::TextureDescriptor {
616 label: Some("Popups"),
617 size: wgpu::Extent3d {
618 width: size[0],
619 height: size[1],
620 depth_or_array_layers: 1,
621 },
622 mip_level_count: 1,
623 sample_count: 1,
624 dimension: wgpu::TextureDimension::D2,
625 format: wgpu::TextureFormat::Rgba8UnormSrgb,
626 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
627 view_formats: &[],
628 });
629 renderer
630 .draw(
631 &texture.create_view(&Default::default()),
632 size,
633 ui.theme.base,
634 &layers,
635 )
636 .unwrap();
637 let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
638 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
639 label: Some("Popups readback"),
640 size: u64::from(row) * u64::from(size[1]),
641 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
642 mapped_at_creation: false,
643 });
644 let mut encoder = device.create_command_encoder(&Default::default());
645 encoder.copy_texture_to_buffer(
646 texture.as_image_copy(),
647 wgpu::TexelCopyBufferInfo {
648 buffer: &buffer,
649 layout: wgpu::TexelCopyBufferLayout {
650 offset: 0,
651 bytes_per_row: Some(row),
652 rows_per_image: Some(size[1]),
653 },
654 },
655 wgpu::Extent3d {
656 width: size[0],
657 height: size[1],
658 depth_or_array_layers: 1,
659 },
660 );
661 queue.submit([encoder.finish()]);
662 buffer.map_async(wgpu::MapMode::Read, .., |result| result.unwrap());
663 device
664 .poll(wgpu::PollType::Wait {
665 submission_index: None,
666 timeout: Some(Duration::from_secs(5)),
667 })
668 .unwrap();
669 let mapped = buffer.get_mapped_range(..).unwrap();
670 mapped
671 .chunks(row as usize)
672 .flat_map(|line| &line[..size[0] as usize * 4])
673 .copied()
674 .collect()
675}
crates/ui/src/layout.rs+8-6
......@@ -74,10 +74,14 @@ pub(crate) fn solve(nodes: &mut [Built], states: &HashMap<Id, State>, scale: f32
7474 }
7575 }
7676 }
77 let window = nodes[0].computed[axis];
7778 for index in 0..nodes.len() {
7879 let mut cursor = nodes[index].pad[axis];
7980 for child in nodes[index].children.clone() {
80 nodes[child].relative[axis] = if nodes[child].flags.contains(Flags::FLOAT) {
81 nodes[child].relative[axis] = if let Some(anchor) = nodes[child].anchor {
82 let slide = (1.0 - nodes[child].alpha) * crate::SLIDE;
83 anchor.place(axis, nodes[child].computed[axis], window, slide)
84 } else if nodes[child].flags.contains(Flags::FLOAT) {
8185 nodes[child].position[axis]
8286 } else if along(&nodes[index], axis) {
8387 let at = cursor;
......@@ -127,11 +131,9 @@ fn strictness(nodes: &[Built], index: usize, axis: usize) -> f32 {
127131}
128132
129133fn in_flow(nodes: &[Built], index: usize) -> impl Iterator<Item = usize> + '_ {
130 nodes[index]
131 .children
132 .iter()
133 .copied()
134 .filter(|child| !nodes[*child].flags.contains(Flags::FLOAT))
134 nodes[index].children.iter().copied().filter(|child| {
135 !nodes[*child].flags.contains(Flags::FLOAT) && nodes[*child].anchor.is_none()
136 })
135137}
136138
137139/// The children's extent on `axis`: summed with gaps along the flow, otherwise the largest.
crates/ui/src/lib.rs+232-33
......@@ -4,11 +4,14 @@
44//! events are routed before building and its layout is solved after.
55
66mod layout;
7mod list;
8pub mod popup;
79pub mod shell;
810mod text;
911mod theme;
1012mod widgets;
1113
14pub use list::{List, Row, Rows, list};
1215pub use theme::{Section, Shades, Theme};
1316pub use widgets::{button, edit_key, edit_modifiers, scrollbar, text_field};
1417
......@@ -27,14 +30,18 @@ use std::{
2730use text::{Label, Texts};
2831use winit::{
2932 event::{Ime, MouseButton},
30 keyboard::{Key, ModifiersState},
33 keyboard::{Key, ModifiersState, NamedKey},
3134 window::CursorIcon,
3235};
3336
3437/// Seconds for an animated value to close half of its remaining distance.
3538const HALF_LIFE: f32 = 0.03;
36/// How far a box's shadow spreads, in logical pixels.
37const SHADOW: f32 = 3.0;
39/// How far a box's shadow spreads, in logical pixels, and how far below the box it
40/// falls; a popup's, floating higher, spreads and falls further.
41const SHADOW: [f32; 2] = [3.0, 0.0];
42const POPUP_SHADOW: [f32; 2] = [12.0, 4.0];
43/// How far a popup slides in from its anchor as it fades in.
44const SLIDE: f32 = 6.0;
3845/// Logical size of a box's icon, and its distance from the label.
3946const ICON: f32 = 16.0;
4047const ICON_GAP: f32 = 6.0;
......@@ -154,11 +161,50 @@ impl BitOr for Flags {
154161pub enum Shape {
155162 #[default]
156163 Rounded,
157 /// A section tab: the top leading corner rounded and the trailing edge leaning out
158 /// `slant` pixels from top to bottom, centred on the box's edge so neighbours overlap.
159 Tab { slant: f32 },
160 /// Only the trailing corners rounded, so the leading edge joins what it sits against.
161 Trailing,
164 /// A section tab: the top leading corner rounded and the top trailing corner `lean`
165 /// pixels inside the box's width, from where the trailing edge leans out at 45°, so
166 /// neighbours overlap and a taller tab only reaches further along the bottom.
167 Tab { lean: f32 },
168}
169
170/// Where a popup opens, flipping to the far side of its anchor where the window ends first.
171#[derive(Clone, Copy, Debug, PartialEq)]
172pub enum Anchor {
173 /// Under the rectangle, from its leading edge, as a drop-down opens.
174 Below([f32; 4]),
175 /// Past the rectangle's trailing edge, from its top, as a submenu opens.
176 Right([f32; 4]),
177 /// Over the rectangle from its corner, as a combo box opens into its own list.
178 Over([f32; 4]),
179 /// At a point, as a context menu opens: at once, without fading in.
180 Point([f32; 2]),
181}
182
183impl Anchor {
184 /// Where a popup `size` long on `axis` starts in a window `room` long: past the
185 /// anchor on the axis it opens along, level with it otherwise, `slide` nearer to it
186 /// along the way it opens.
187 fn place(self, axis: usize, size: f32, room: f32, slide: f32) -> f32 {
188 let (rect, along) = match self {
189 Anchor::Below(rect) => (rect, Some(1)),
190 Anchor::Right(rect) => (rect, Some(0)),
191 Anchor::Over(rect) => (rect, None),
192 Anchor::Point([x, y]) => ([x, y, x, y], Some(1)),
193 };
194 let [low, high] = [rect[axis], rect[axis + 2]];
195 let (first, second, slide) = if along == Some(axis) {
196 (high, low - size, slide)
197 } else {
198 (low, high - size, 0.0)
199 };
200 if first + size <= room {
201 first - slide
202 } else if second >= 0.0 {
203 second + slide
204 } else {
205 first.min(room - size).max(0.0)
206 }
207 }
162208}
163209
164210/// What a box is this frame. Colours are linear RGBA.
......@@ -194,6 +240,11 @@ pub struct Spec<'a> {
194240 /// For floating boxes, the offset from the parent's corner.
195241 pub position: [f32; 2],
196242 pub cursor: Option<CursorIcon>,
243 /// Floats the box over all others beside a rectangle in the window, as a popup
244 /// `Ui::open_popup` opened; boxes beneath take no input while one is open.
245 pub anchor: Option<Anchor>,
246 /// How transparent the box and its contents are, from 0 to 1.
247 pub fade: f32,
197248}
198249
199250/// Input the host forwards; positions and wheel distances are logical pixels.
......@@ -229,7 +280,7 @@ pub struct Signal {
229280 pub dragging: bool,
230281 pub focused: bool,
231282 /// For custom boxes: every event routed to the box, in order. For focused boxes:
232 /// keys, text and composition.
283 /// keys, text and composition. For scrolling boxes: the wheel.
233284 pub events: Vec<Event>,
234285}
235286
......@@ -271,6 +322,9 @@ struct Built {
271322 center: bool,
272323 position: [f32; 2],
273324 cursor: Option<CursorIcon>,
325 anchor: Option<Anchor>,
326 /// The opacity of the box and its contents; a popup's rises as it opens.
327 alpha: f32,
274328 /// Rectangles relative to the box, painted over its fill.
275329 marks: Vec<([f32; 4], [f32; 4])>,
276330 computed: [f32; 2],
......@@ -299,6 +353,16 @@ struct State {
299353 tween: Option<[f32; 2]>,
300354}
301355
356/// An open popup, above the one opened before it.
357struct Popup {
358 id: Id,
359 /// The focus before it opened, restored when it closes.
360 focus: Option<Id>,
361 /// Its filter field's text, and the key of the row the keyboard or pointer last chose.
362 query: String,
363 highlight: Option<u64>,
364}
365
302366struct Hit {
303367 id: Id,
304368 rect: [f32; 4],
......@@ -351,6 +415,28 @@ enum Display {
351415 },
352416}
353417
418impl Display {
419 /// Multiplies the item's opacity by `alpha`.
420 fn fade(&mut self, alpha: f32) {
421 let fade = |color: &mut [f32; 4]| color[3] *= alpha;
422 match self {
423 Display::Rect {
424 fill,
425 shade,
426 border,
427 ..
428 } => [Some(fill), shade.as_mut(), border.as_mut()]
429 .into_iter()
430 .flatten()
431 .for_each(fade),
432 Display::Segment { color, .. } | Display::Text { color, .. } => fade(color),
433 Display::Path { colors, .. } => colors.iter_mut().for_each(fade),
434 Display::Icon { tint, .. } => fade(tint),
435 Display::Clip(_) | Display::Image { .. } | Display::Custom { .. } => {}
436 }
437 }
438}
439
354440pub struct Ui {
355441 pub theme: Theme,
356442 frame: u64,
......@@ -364,7 +450,15 @@ pub struct Ui {
364450 signals: HashMap<Id, Signal>,
365451 /// The previous frame's interactive boxes in paint order.
366452 hits: Vec<Hit>,
453 /// Hits before this one lie beneath an open popup.
454 modal: usize,
455 popups: Vec<Popup>,
367456 pointer: Option<[f32; 2]>,
457 /// The pointer moved this frame.
458 moved: bool,
459 /// The share of its remaining distance an animated value closes this frame.
460 rate: f32,
461 lists: HashMap<Id, list::State>,
368462 hover: Option<Id>,
369463 active: Option<Id>,
370464 focus: Option<Id>,
......@@ -389,7 +483,12 @@ impl Ui {
389483 queue: Vec::new(),
390484 signals: HashMap::new(),
391485 hits: Vec::new(),
486 modal: 0,
487 popups: Vec::new(),
392488 pointer: None,
489 moved: false,
490 rate: 1.0,
491 lists: HashMap::new(),
393492 hover: None,
394493 active: None,
395494 focus: None,
......@@ -477,6 +576,7 @@ impl Ui {
477576 self.stack.clear();
478577 self.stack.push(0);
479578 self.signals.clear();
579 self.moved = false;
480580 for event in std::mem::take(&mut self.queue) {
481581 self.route(event);
482582 }
......@@ -487,6 +587,7 @@ impl Ui {
487587 match event {
488588 Event::PointerMoved(point) => {
489589 self.pointer = Some(point);
590 self.moved = true;
490591 let hover = self.hit(point, Flags::CLICKABLE | Flags::CUSTOM);
491592 if hover != self.hover
492593 && self.active.is_none_or(|active| Some(active) != self.hover)
......@@ -511,6 +612,18 @@ impl Ui {
511612 let Some(point) = self.pointer else {
512613 return;
513614 };
615 if !self.popups.is_empty() {
616 let under = self.popups.iter().rposition(|popup| {
617 self.rect(popup.id)
618 .is_some_and(|rect| contains(rect, point))
619 });
620 // A press outside every popup only dismisses them.
621 let Some(under) = under else {
622 self.close_from(0);
623 return;
624 };
625 self.close_from(under + 1);
626 }
514627 let target = self.hit(point, Flags::CLICKABLE | Flags::CUSTOM);
515628 if let Some(id) = target
516629 && button == MouseButton::Left
......@@ -559,10 +672,15 @@ impl Ui {
559672 let state = self.states.entry(id).or_default();
560673 let most = (state.content - (state.rect[3] - state.rect[1])).max(0.0);
561674 state.scroll_target = (state.scroll_target - delta[1]).clamp(0.0, most);
675 self.signals.entry(id).or_default().events.push(event);
562676 }
563677 None => {}
564678 }
565679 }
680 Event::Key {
681 key: Key::Named(NamedKey::Escape),
682 ..
683 } if !self.popups.is_empty() => self.close_from(self.popups.len() - 1),
566684 Event::Key { .. } | Event::Ime(_) => {
567685 if let Some(focus) = self.focus {
568686 self.signals.entry(focus).or_default().events.push(event);
......@@ -604,21 +722,62 @@ impl Ui {
604722
605723 /// The topmost box under `point` with any of `flags`.
606724 fn hit(&self, point: [f32; 2], flags: Flags) -> Option<Id> {
607 self.hits
725 self.hits[self.modal..]
608726 .iter()
609727 .rev()
610 .find(|hit| {
611 hit.flags.intersects(flags)
612 && point[0] >= hit.rect[0]
613 && point[0] < hit.rect[2]
614 && point[1] >= hit.rect[1]
615 && point[1] < hit.rect[3]
616 })
728 .find(|hit| hit.flags.intersects(flags) && contains(hit.rect, point))
617729 .map(|hit| hit.id)
618730 }
619731
732 /// Opens popup `id`, which shows while built with its `Spec::anchor` each frame, and
733 /// takes the keyboard. Popups open from within another stay above it; any other
734 /// closes those open.
735 pub fn open_popup(&mut self, id: Id) {
736 if self.popup_open(id) {
737 return;
738 }
739 let within = self
740 .stack
741 .iter()
742 .filter_map(|index| {
743 let id = self.nodes[*index].id;
744 self.popups.iter().position(|popup| popup.id == id)
745 })
746 .max();
747 self.close_from(within.map_or(0, |within| within + 1));
748 self.popups.push(Popup {
749 id,
750 focus: self.focus,
751 query: String::new(),
752 highlight: None,
753 });
754 self.focus = Some(id);
755 let state = self.states.entry(id).or_default();
756 state.touched = self.frame;
757 state.tween = Some([0.0, 1.0]);
758 }
759
760 pub fn popup_open(&self, id: Id) -> bool {
761 self.popups.iter().any(|popup| popup.id == id)
762 }
763
764 /// Closes popup `id` and those opened from it, returning the focus it took.
765 pub fn close_popup(&mut self, id: Id) {
766 if let Some(index) = self.popups.iter().position(|popup| popup.id == id) {
767 self.close_from(index);
768 }
769 }
770
771 fn close_from(&mut self, index: usize) {
772 if let Some(popup) = self.popups.get(index) {
773 self.focus = popup.focus;
774 self.popups.truncate(index);
775 }
776 }
777
620778 fn ease(&mut self, dt: f32) {
621779 let rate = 1.0 - 0.5_f32.powf(dt / HALF_LIFE);
780 self.rate = rate;
622781 let mut animating = false;
623782 for (id, state) in &mut self.states {
624783 let hot = self.hover == Some(*id) && self.active.is_none_or(|active| active == *id);
......@@ -667,13 +826,24 @@ impl Ui {
667826 let label = spec
668827 .text
669828 .map(|text| self.texts.label(text, self.theme.font_size, self.frame));
670 let parent = *self.stack.last().unwrap();
829 // Popups hang from the root, outside the clips and flow of where they are built.
830 let parent = if spec.anchor.is_some() {
831 0
832 } else {
833 *self.stack.last().unwrap()
834 };
671835 let index = self.nodes.len();
672 self.nodes
673 .push(Built::new(id, parent, spec, label, self.theme.text));
836 let state = self.states.entry(id).or_default();
837 state.touched = self.frame;
838 let reveal = match spec.anchor {
839 Some(Anchor::Point(_)) | None => 1.0,
840 Some(_) => state.tween.map_or(1.0, |tween| tween[0]),
841 };
842 let mut built = Built::new(id, parent, spec, label, self.theme.text);
843 built.alpha *= reveal;
844 self.nodes.push(built);
674845 self.nodes[parent].children.push(index);
675846 self.stack.push(index);
676 self.states.entry(id).or_default().touched = self.frame;
677847 id
678848 }
679849
......@@ -745,9 +915,24 @@ impl Ui {
745915 self.states
746916 .retain(|id, state| state.touched == self.frame || *id == Id::ROOT);
747917 self.texts.prune(self.frame);
918 self.lists.retain(|id, _| self.states.contains_key(id));
919 if let Some(gone) = self
920 .popups
921 .iter()
922 .position(|popup| !self.states.contains_key(&popup.id))
923 {
924 self.close_from(gone);
925 }
748926 self.display.clear();
749927 self.hits.clear();
750928 self.paint(0, None);
929 let beneath = self.hits.len();
930 for index in self.nodes[0].children.clone() {
931 if self.nodes[index].anchor.is_some() {
932 self.paint(index, None);
933 }
934 }
935 self.modal = if self.popups.is_empty() { 0 } else { beneath };
751936 for id in [&mut self.hover, &mut self.active, &mut self.focus] {
752937 if id.is_some_and(|id| !self.states.contains_key(&id)) {
753938 *id = None;
......@@ -756,6 +941,7 @@ impl Ui {
756941 }
757942
758943 fn paint(&mut self, index: usize, clip: Option<[f32; 4]>) {
944 let start = self.display.len();
759945 let node = &self.nodes[index];
760946 let state = &self.states[&node.id];
761947 let rect = node.rect;
......@@ -781,10 +967,15 @@ impl Ui {
781967 let border = blend(node.border, node.hover_border);
782968 let size = [rect[2] - rect[0], rect[3] - rect[1]];
783969 if let Some(shadow) = node.shadow {
970 let [spread, drop] = if node.anchor.is_some() {
971 POPUP_SHADOW
972 } else {
973 SHADOW
974 };
784975 self.display.push(Display::Path {
785976 data: outline(node.shape, size, node.radius),
786 origin: [rect[0], rect[1]],
787 style: PathStyle::Shadow(SHADOW),
977 origin: [rect[0], rect[1] + drop],
978 style: PathStyle::Shadow(spread),
788979 colors: [shadow; 2],
789980 });
790981 }
......@@ -889,11 +1080,19 @@ impl Ui {
8891080 self.display.push(Display::Clip(inner_clip));
8901081 }
8911082 for child in node.children.clone() {
892 self.paint(child, inner_clip);
1083 if self.nodes[child].anchor.is_none() {
1084 self.paint(child, inner_clip);
1085 }
8931086 }
8941087 if inner_clip != clip {
8951088 self.display.push(Display::Clip(clip));
8961089 }
1090 let alpha = self.nodes[index].alpha;
1091 if alpha < 1.0 {
1092 for item in &mut self.display[start..] {
1093 item.fade(alpha);
1094 }
1095 }
8971096 }
8981097
8991098 /// This frame's painting in order, in logical pixels.
......@@ -1067,6 +1266,8 @@ impl Built {
10671266 center: spec.center,
10681267 position: spec.position,
10691268 cursor: spec.cursor,
1269 anchor: spec.anchor,
1270 alpha: 1.0 - spec.fade,
10701271 marks: Vec::new(),
10711272 computed: [0.0; 2],
10721273 relative: [0.0; 2],
......@@ -1080,17 +1281,11 @@ impl Built {
10801281/// A tab's outline stays open along its bottom, so a border leaves the edge it stands on.
10811282fn outline(shape: Shape, [width, height]: [f32; 2], radius: f32) -> String {
10821283 let corners = match shape {
1083 Shape::Tab { slant } => [
1284 Shape::Tab { lean } => [
10841285 ([0.0, height], 0.0),
10851286 ([0.0, 0.0], radius),
1086 ([width - slant / 2.0, 0.0], radius / 2.0),
1087 ([width + slant / 2.0, height], 0.0),
1088 ],
1089 Shape::Trailing => [
1090 ([0.0, height], 0.0),
1091 ([0.0, 0.0], 0.0),
1092 ([width, 0.0], radius),
1093 ([width, height], radius),
1287 ([width - lean, 0.0], radius / 2.0),
1288 ([width - lean + height, height], 0.0),
10941289 ],
10951290 Shape::Rounded => [
10961291 ([0.0, height], radius),
......@@ -1174,6 +1369,10 @@ pub fn mix(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {
11741369 std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t)
11751370}
11761371
1372fn contains(rect: [f32; 4], point: [f32; 2]) -> bool {
1373 point[0] >= rect[0] && point[0] < rect[2] && point[1] >= rect[1] && point[1] < rect[3]
1374}
1375
11771376fn intersect(a: [f32; 4], b: [f32; 4]) -> Option<[f32; 4]> {
11781377 let rect = [
11791378 a[0].max(b[0]),
crates/ui/src/list.rs created+314
......@@ -0,0 +1,314 @@
1//! A virtualized list of equal rows keyed by their items: only rows in view are built,
2//! rows ease to where their items move, and the view holds its place on the selection
3//! while items arrive and leave above it.
4
5use crate::{Axis, Event, Flags, Id, SLIDE, Size, Spec, Ui, fill, mix, px, scrollbar};
6use std::collections::HashMap;
7use winit::keyboard::NamedKey;
8
9/// Room beside a scrolling list's rows for its scrollbar.
10pub(crate) const GUTTER: f32 = 12.0;
11
12/// Items a list shows, in order.
13pub trait Rows {
14 /// How many items are listed.
15 fn count(&self) -> usize;
16 /// The key naming the item at `index`, the same in every frame the item is listed.
17 fn key(&self, index: usize) -> u64;
18 /// Where the item named `key` is listed now.
19 fn find(&self, key: u64) -> Option<usize>;
20 /// Whether the selection may rest on row `index`.
21 fn selectable(&self, _index: usize) -> bool {
22 true
23 }
24 /// Extra room above row `index`, summed over it and the rows before; `index` may be
25 /// the length.
26 fn space_before(&self, _index: usize) -> f32 {
27 0.0
28 }
29}
30
31/// How a list shows and moves through its rows.
32pub struct List<'a, R> {
33 pub rows: &'a R,
34 /// Every row's height.
35 pub row: f32,
36 /// Arrow, page, Home and End presses meant for the list this frame; others are ignored.
37 pub keys: &'a [NamedKey],
38 /// The selection follows the pointer, as in a menu.
39 pub hover_selects: bool,
40}
41
42/// A row to build: its item's key, its index while listed or None as it fades out after
43/// leaving, and whether it is selected.
44pub struct Row {
45 pub key: u64,
46 pub index: Option<usize>,
47 pub selected: bool,
48}
49
50/// A list's view across frames, in logical pixels.
51#[derive(Default)]
52pub(crate) struct State {
53 scroll: f64,
54 target: f64,
55 /// Rows built last frame, from the top of the view down.
56 shown: Vec<Shown>,
57 /// The selection last frame, so a new one scrolls into view.
58 selected: Option<u64>,
59}
60
61#[derive(Clone, Copy)]
62struct Shown {
63 key: u64,
64 /// Where its row lies, from the top of the view.
65 place: f64,
66 /// How far from its place the row is drawn, easing to nothing.
67 offset: f64,
68 alpha: f32,
69 index: Option<usize>,
70}
71
72/// Builds `list` into a box of `spec`, calling `build` inside each row in view, and moves
73/// `selected` with the list's keys. Returns the index of the row clicked.
74pub fn list<R: Rows>(
75 ui: &mut Ui,
76 id: Id,
77 spec: Spec<'_>,
78 list: List<'_, R>,
79 selected: &mut Option<u64>,
80 mut build: impl FnMut(&mut Ui, Row),
81) -> Option<usize> {
82 let List {
83 rows,
84 row,
85 keys,
86 hover_selects,
87 } = list;
88 let height = f64::from(row);
89 let len = rows.count();
90 let top = |index: usize| index as f64 * height + f64::from(rows.space_before(index));
91 // The first row whose bottom lies below `y`.
92 let row_at = |y: f64| {
93 let [mut low, mut high] = [0, len];
94 while low < high {
95 let middle = (low + high) / 2;
96 if top(middle) + height <= y {
97 low = middle + 1;
98 } else {
99 high = middle;
100 }
101 }
102 low
103 };
104 let rect = ui.rect(id);
105 let view = f64::from(match spec.size[1].size {
106 Size::Pixels(pixels) => pixels,
107 _ => rect.map_or(0.0, |rect| rect[3] - rect[1]),
108 });
109 let most = (top(len) - view).max(0.0);
110 let fresh = !ui.lists.contains_key(&id);
111 let mut state = ui.lists.remove(&id).unwrap_or_default();
112
113 // Hold the view on the selection, or the first row showing, wherever it now lies.
114 let listed = || state.shown.iter().filter(|shown| shown.index.is_some());
115 let held = state.selected.and_then(|key| {
116 listed().find(|shown| shown.key == key && shown.place >= 0.0 && shown.place < view)
117 });
118 // At the top, the view stays there to show what arrives.
119 let anchor = held
120 .into_iter()
121 .chain(listed().filter(|shown| state.scroll > 0.0 && shown.place + height > 0.0))
122 .find_map(|shown| Some(top(rows.find(shown.key)?) - shown.place));
123 if let Some(scroll) = anchor {
124 state.target += scroll - state.scroll;
125 state.scroll = scroll;
126 }
127 state.scroll = state.scroll.clamp(0.0, most);
128 state.target = state.target.clamp(0.0, most);
129 let anchored = state.scroll;
130
131 for event in ui.signal(id).events {
132 if let Event::Wheel(delta) = event {
133 state.target = (state.target - f64::from(delta[1])).clamp(0.0, most);
134 }
135 }
136 let mut clicked = None;
137 for shown in &state.shown {
138 let signal = ui.signal(id.child(shown.key));
139 let Some(index) = rows.find(shown.key).filter(|index| rows.selectable(*index)) else {
140 continue;
141 };
142 if hover_selects && signal.hovered && ui.moved {
143 *selected = Some(shown.key);
144 state.selected = *selected;
145 }
146 if signal.clicked {
147 *selected = Some(shown.key);
148 clicked = Some(index);
149 }
150 }
151 let forward = |from: usize| (from..len).find(|index| rows.selectable(*index));
152 let backward = |from: usize| {
153 (0..=from.min(len.saturating_sub(1)))
154 .rev()
155 .find(|index| rows.selectable(*index))
156 };
157 let page = ((view / height) as usize).max(1);
158 let mut current = selected.and_then(|key| rows.find(key));
159 for key in keys.iter().filter(|_| len > 0) {
160 let next = match (key, current) {
161 (NamedKey::ArrowDown, None) => forward(row_at(anchored)),
162 (NamedKey::ArrowDown, Some(at)) => forward(at + 1),
163 (NamedKey::ArrowUp, None) => backward(row_at(anchored + view).saturating_sub(1)),
164 (NamedKey::ArrowUp, Some(at)) => at.checked_sub(1).and_then(backward),
165 (NamedKey::PageDown, at) => {
166 let to = at.map_or(0, |at| (at + page).min(len - 1));
167 forward(to).or_else(|| backward(to))
168 }
169 (NamedKey::PageUp, at) => {
170 let to = at.map_or(0, |at| at.saturating_sub(page));
171 backward(to).or_else(|| forward(to))
172 }
173 (NamedKey::Home, _) => forward(0),
174 (NamedKey::End, _) => backward(len - 1),
175 _ => None,
176 };
177 if next.is_some() {
178 current = next;
179 *selected = current.map(|index| rows.key(index));
180 }
181 }
182 if *selected != state.selected {
183 if let Some(at) = selected.and_then(|key| rows.find(key)) {
184 let lowest = (top(at) + height - view).min(top(at));
185 state.target = state.target.clamp(lowest, top(at)).clamp(0.0, most);
186 }
187 state.selected = *selected;
188 }
189
190 let rate = f64::from(ui.rate);
191 let settle = |value: f64, target: f64, close: f64| {
192 if fresh || (target - value).abs() < close {
193 target
194 } else {
195 value + (target - value) * rate
196 }
197 };
198 state.scroll = settle(state.scroll, state.target, 0.25);
199 let scroll = state.scroll;
200 let step = scroll - anchored;
201
202 // Where each row built last frame was drawn, in the view it was drawn in.
203 let before: HashMap<u64, Shown> = state
204 .shown
205 .iter()
206 .map(|shown| (shown.key, *shown))
207 .collect();
208 let place = |index: usize| {
209 let key = rows.key(index);
210 let was = top(index) - anchored;
211 let (offset, alpha) = match before.get(&key) {
212 Some(old) => (old.place + old.offset - was, old.alpha),
213 None if !fresh && was + height > 0.0 && was < view => (-SLIDE as f64, 0.0),
214 None => (0.0, 1.0),
215 };
216 Shown {
217 key,
218 place: top(index) - scroll,
219 offset: settle(offset, 0.0, 0.5),
220 alpha: settle(f64::from(alpha), 1.0, 0.01) as f32,
221 index: Some(index),
222 }
223 };
224 let in_view = row_at(scroll)..row_at(scroll + view + height).min(len);
225 let mut shown: Vec<Shown> = in_view.clone().map(place).collect();
226 let mut leaving = Vec::new();
227 for old in before.values() {
228 match rows.find(old.key) {
229 Some(index) if !in_view.contains(&index) => {
230 let moving = place(index);
231 let drawn = moving.place + moving.offset;
232 if drawn + height > 0.0 && drawn < view {
233 shown.push(moving);
234 }
235 }
236 Some(_) => {}
237 None => {
238 let alpha = settle(f64::from(old.alpha), 0.0, 0.02) as f32;
239 if alpha > 0.0 {
240 leaving.push(Shown {
241 place: old.place + old.offset - step,
242 offset: 0.0,
243 alpha,
244 index: None,
245 ..*old
246 });
247 }
248 }
249 }
250 }
251 shown.sort_by(|a, b| a.place.total_cmp(&b.place));
252 ui.animating |= state.scroll != state.target
253 || !leaving.is_empty()
254 || shown
255 .iter()
256 .any(|shown| shown.offset != 0.0 || shown.alpha < 1.0);
257
258 let theme = ui.theme.clone();
259 ui.open_as(
260 id,
261 Spec {
262 flags: spec.flags | Flags::SCROLL | Flags::CLIP,
263 ..spec
264 },
265 );
266 // Rows leave the scrollbar a gutter.
267 let width = match rect {
268 Some(rect) if most > 0.0 => px(rect[2] - rect[0] - GUTTER),
269 _ => fill(),
270 };
271 for shown in leaving.iter().chain(&shown) {
272 let flags = match shown.index {
273 Some(index) if rows.selectable(index) => Flags::FLOAT | Flags::CLICKABLE,
274 _ => Flags::FLOAT,
275 };
276 ui.open(
277 shown.key,
278 Spec {
279 flags,
280 size: [width, px(row)],
281 position: [0.0, (shown.place + shown.offset) as f32],
282 fade: 1.0 - shown.alpha,
283 ..Spec::default()
284 },
285 );
286 build(
287 ui,
288 Row {
289 key: shown.key,
290 index: shown.index,
291 selected: shown.index.is_some() && *selected == Some(shown.key),
292 },
293 );
294 ui.close();
295 }
296 let thumb = mix(theme.text_dim, theme.chip, 0.5);
297 if let Some(offset) = scrollbar(
298 ui,
299 "bar",
300 Axis::Y,
301 scroll as f32,
302 [0.0, most as f32],
303 view as f32,
304 thumb,
305 ) {
306 state.scroll = f64::from(offset);
307 state.target = state.scroll;
308 }
309 ui.close();
310 shown.extend(leaving);
311 state.shown = shown;
312 ui.lists.insert(id, state);
313 clicked
314}
crates/ui/src/popup.rs created+559
......@@ -0,0 +1,559 @@
1//! Controls built on the popup layer: menus and filterable lists, a colour grid and a
2//! command palette. Each builds while its popup is open and returns what was chosen the
3//! frame it is, closing the popup.
4
5use crate::{
6 Anchor, Axis, Event, Flags, ICON, ICON_GAP, Id, List, Popup, Row, Rows, Spec, Ui, children,
7 fill, fit, list::GUTTER, mix, px, text_field,
8};
9use winit::keyboard::{Key, NamedKey};
10
11const CHECK: &[&str] = &[include_str!("../assets/check.svg")];
12/// Inset of a popup's contents, and its distance from what it drops down from.
13const PAD: f32 = 4.0;
14const ROW: f32 = 26.0;
15/// Rows a list shows before it scrolls.
16const ROWS: f32 = 12.0;
17/// Height of the rule between groups.
18const RULE: f32 = 9.0;
19const NARROWEST: f32 = 140.0;
20const PALETTE: f32 = 560.0;
21/// A colour grid's cell, around its swatch.
22const CELL: f32 = 22.0;
23/// The keys a menu's list takes, and Enter to choose.
24const KEYS: [NamedKey; 7] = [
25 NamedKey::ArrowUp,
26 NamedKey::ArrowDown,
27 NamedKey::PageUp,
28 NamedKey::PageDown,
29 NamedKey::Home,
30 NamedKey::End,
31 NamedKey::Enter,
32];
33
34/// A row of a menu or palette.
35#[derive(Clone, Copy, Debug, Default)]
36pub struct Item<'a> {
37 pub text: &'a str,
38 pub icon: Option<&'static [&'static str]>,
39 /// The icon has colours of its own, so the text's colour does not tint it.
40 pub colored: bool,
41 /// Keys that run the item, shown dim at the trailing edge.
42 pub shortcut: &'a str,
43 /// Marked with a check in place of its icon.
44 pub checked: bool,
45 pub disabled: bool,
46 /// Starts a group, ruled off from the one above while unfiltered.
47 pub separated: bool,
48}
49
50/// Builds popup `id` as a menu of `items` beside `anchor` while it is open, under a
51/// filter field showing `filter` while empty when given. Returns the index of the item
52/// chosen.
53pub fn menu(
54 ui: &mut Ui,
55 id: Id,
56 anchor: Anchor,
57 items: &[Item],
58 filter: Option<&str>,
59) -> Option<usize> {
60 if !ui.popup_open(id) {
61 return None;
62 }
63 let [text, shortcut] = items.iter().fold([0.0_f32; 2], |[text, shortcut], item| {
64 [
65 text.max(ui.measure(item.text)[0]),
66 shortcut.max(ui.measure(item.shortcut)[0]),
67 ]
68 });
69 let icons = items.iter().any(|item| item.icon.is_some() || item.checked);
70 let width =
71 text + if shortcut > 0.0 {
72 3.0 * ICON_GAP + shortcut
73 } else {
74 0.0
75 } + if icons { ICON + ICON_GAP } else { 0.0 }
76 + if items.len() as f32 > ROWS {
77 GUTTER
78 } else {
79 0.0
80 }
81 + 2.0 * (PAD + 8.0);
82 let least = match anchor {
83 Anchor::Below(rect) => rect[2] - rect[0],
84 Anchor::Over(rect) => rect[2] - rect[0] + 2.0 * PAD,
85 _ => 0.0,
86 };
87 choose(
88 ui,
89 id,
90 anchor,
91 items,
92 filter,
93 width.max(least).max(NARROWEST),
94 )
95}
96
97/// Builds popup `id` as a command palette across the top of the window while it is open,
98/// listing `commands` that match what is typed, most relevant first. Returns the index of
99/// the command chosen.
100pub fn palette(ui: &mut Ui, id: Id, commands: &[Item]) -> Option<usize> {
101 if !ui.popup_open(id) {
102 return None;
103 }
104 let window = ui.rect(Id::ROOT).unwrap_or_default();
105 let width = PALETTE.min(window[2] - 8.0 * PAD).max(NARROWEST);
106 let [left, top] = [(window[2] - width) / 2.0, window[3] / 8.0];
107 choose(
108 ui,
109 id,
110 Anchor::Below([left, top, left, top]),
111 commands,
112 Some("Search commands"),
113 width,
114 )
115}
116
117fn choose(
118 ui: &mut Ui,
119 id: Id,
120 anchor: Anchor,
121 items: &[Item],
122 filter: Option<&str>,
123 width: f32,
124) -> Option<usize> {
125 let field = id.child("filter");
126 if filter.is_some() && ui.focus == Some(id) {
127 ui.focus = Some(field);
128 }
129 let keys = navigation(ui, &[id, field], &KEYS);
130 let popup = state(ui, id);
131 let mut query = std::mem::take(&mut popup.query);
132 let mut highlight = popup.highlight;
133 let theme = ui.theme.clone();
134 surface(ui, id, anchor, width);
135 if let Some(placeholder) = filter {
136 let before = query.clone();
137 // Over a combo box, the field takes the box's place.
138 let height = match anchor {
139 Anchor::Over(rect) => rect[3] - rect[1],
140 _ => ROW,
141 };
142 text_field(
143 ui,
144 field,
145 &mut query,
146 placeholder,
147 Spec {
148 size: [fill(), px(height)],
149 fill: Some(theme.base),
150 border: Some(theme.accent),
151 radius: 4.0,
152 pad: [8.0, 0.0],
153 ..Spec::default()
154 },
155 );
156 if query != before {
157 highlight = None;
158 }
159 }
160 let matches = Matches::new(items, &query);
161 // Unfiltered, the checked item starts highlighted; filtered, the best match.
162 highlight = highlight.or_else(|| {
163 let first = if query.is_empty() {
164 matches.order.iter().position(|index| items[*index].checked)
165 } else {
166 (0..matches.count()).find(|row| matches.selectable(*row))
167 };
168 first.map(|row| matches.key(row))
169 });
170 let chosen = if matches.count() == 0 {
171 ui.leaf(
172 "empty",
173 Spec {
174 size: [fill(), px(ROW)],
175 text: Some("No matches"),
176 color: Some(theme.text_dim),
177 pad: [8.0, 0.0],
178 ..Spec::default()
179 },
180 );
181 None
182 } else {
183 let window = ui.rect(Id::ROOT).map_or(0.0, |window| window[3]);
184 let field = if filter.is_some() { ROW + PAD } else { 0.0 };
185 let content = matches.count() as f32 * ROW + matches.space_before(matches.count());
186 let view = content.min((ROWS * ROW).min(window - 4.0 * PAD - field).max(ROW));
187 let list = List {
188 rows: &matches,
189 row: ROW,
190 keys: &keys,
191 hover_selects: true,
192 };
193 let clicked = crate::list(
194 ui,
195 id.child("rows"),
196 Spec {
197 size: [fill(), px(view)],
198 ..Spec::default()
199 },
200 list,
201 &mut highlight,
202 |ui, row| menu_row(ui, &theme, &matches, row),
203 );
204 let entered = keys.contains(&NamedKey::Enter);
205 clicked.or_else(|| {
206 highlight
207 .and_then(|key| matches.find(key))
208 .filter(|row| entered && matches.selectable(*row))
209 })
210 };
211 ui.close();
212 if let Some(row) = chosen {
213 ui.close_popup(id);
214 return Some(matches.order[row]);
215 }
216 let popup = state(ui, id);
217 popup.query = query;
218 popup.highlight = highlight;
219 None
220}
221
222/// Builds a menu's row: the item's icon or check, its text and its shortcut, on the
223/// highlight when selected, under a rule when it starts a group.
224fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
225 let item = &matches.items[row.key as usize];
226 if row
227 .index
228 .is_some_and(|index| index > 0 && matches.ruled && item.separated)
229 {
230 ui.leaf(
231 "rule",
232 Spec {
233 flags: Flags::FLOAT,
234 size: [fill(), px(1.0)],
235 position: [0.0, -(RULE + 1.0) / 2.0],
236 fill: Some(theme.chip),
237 ..Spec::default()
238 },
239 );
240 }
241 let color = if item.disabled {
242 theme.text_dim
243 } else {
244 theme.text
245 };
246 ui.open(
247 "item",
248 Spec {
249 size: [fill(), fill()],
250 fill: row.selected.then(|| theme.hover()),
251 radius: 4.0,
252 pad: [8.0, 0.0],
253 gap: ICON_GAP,
254 ..Spec::default()
255 },
256 );
257 if matches.icons {
258 let tint = if item.colored && !item.checked {
259 [1.0, 1.0, 1.0, color[3]]
260 } else {
261 color
262 };
263 ui.leaf(
264 "icon",
265 Spec {
266 size: [px(ICON), px(ROW)],
267 icon: if item.checked { Some(CHECK) } else { item.icon },
268 color: Some(if item.disabled {
269 mix(tint, theme.popup, 0.5)
270 } else {
271 tint
272 }),
273 ..Spec::default()
274 },
275 );
276 }
277 ui.leaf(
278 "text",
279 Spec {
280 size: [fill(), px(ROW)],
281 text: Some(item.text),
282 color: Some(color),
283 ..Spec::default()
284 },
285 );
286 if !item.shortcut.is_empty() {
287 ui.leaf(
288 "shortcut",
289 Spec {
290 size: [fit(), px(ROW)],
291 text: Some(item.shortcut),
292 color: Some(theme.text_dim),
293 ..Spec::default()
294 },
295 );
296 }
297 ui.close();
298}
299
300/// Builds popup `id` as a grid of `swatches` in rows of `columns` beside `anchor` while it
301/// is open, under a button labelled `none` for no colour of its own. Returns the swatch
302/// chosen, or None for the button.
303pub fn colors(
304 ui: &mut Ui,
305 id: Id,
306 anchor: Anchor,
307 none: &str,
308 swatches: &[[f32; 4]],
309 columns: usize,
310) -> Option<Option<[f32; 4]>> {
311 if !ui.popup_open(id) {
312 return None;
313 }
314 // The button is cell 0 and the swatches follow it.
315 let cell = |index: usize| id.child(("cell", index));
316 let count = swatches.len() + 1;
317 let keys = navigation(
318 ui,
319 &[id],
320 &[
321 NamedKey::ArrowLeft,
322 NamedKey::ArrowRight,
323 NamedKey::ArrowUp,
324 NamedKey::ArrowDown,
325 NamedKey::Enter,
326 ],
327 );
328 let mut highlight = state(ui, id).highlight.map(|cell| cell as usize);
329 let mut chosen = None;
330 for index in 0..count {
331 let signal = ui.signal(cell(index));
332 if signal.hovered && ui.moved {
333 highlight = Some(index);
334 }
335 if signal.clicked {
336 chosen = Some(index);
337 }
338 }
339 for key in keys {
340 highlight = Some(match (key, highlight) {
341 (NamedKey::Enter, _) => {
342 chosen = chosen.or(highlight);
343 continue;
344 }
345 (_, None) => 0,
346 (NamedKey::ArrowLeft, Some(at)) => at.saturating_sub(1),
347 (NamedKey::ArrowRight, Some(at)) => (at + 1).min(count - 1),
348 (NamedKey::ArrowUp, Some(at)) => at.saturating_sub(columns),
349 (_, Some(0)) => 1,
350 (_, Some(at)) if at + columns < count => at + columns,
351 (_, Some(at)) => at,
352 });
353 }
354 if let Some(index) = chosen {
355 ui.close_popup(id);
356 return Some((index > 0).then(|| swatches[index - 1]));
357 }
358
359 let theme = ui.theme.clone();
360 let lit = |index: usize| (highlight == Some(index)).then(|| theme.hover());
361 surface(ui, id, anchor, columns as f32 * CELL + 2.0 * PAD);
362 ui.open_as(
363 cell(0),
364 Spec {
365 flags: Flags::CLICKABLE,
366 size: [fill(), px(ROW)],
367 text: Some(none),
368 fill: lit(0),
369 radius: 4.0,
370 pad: [8.0, 0.0],
371 ..Spec::default()
372 },
373 );
374 ui.close();
375 for (row, colors) in swatches.chunks(columns).enumerate() {
376 ui.open(
377 ("row", row),
378 Spec {
379 size: [fill(), px(CELL)],
380 ..Spec::default()
381 },
382 );
383 for (column, color) in colors.iter().enumerate() {
384 let index = 1 + row * columns + column;
385 ui.open_as(
386 cell(index),
387 Spec {
388 flags: Flags::CLICKABLE,
389 size: [px(CELL), px(CELL)],
390 fill: lit(index),
391 radius: 4.0,
392 pad: [3.0, 3.0],
393 ..Spec::default()
394 },
395 );
396 ui.leaf(
397 "swatch",
398 Spec {
399 size: [fill(), fill()],
400 fill: Some(*color),
401 border: Some(mix(*color, theme.text, 0.25)),
402 radius: 2.0,
403 ..Spec::default()
404 },
405 );
406 ui.close();
407 }
408 ui.close();
409 }
410 ui.close();
411 state(ui, id).highlight = highlight.map(|cell| cell as u64);
412 None
413}
414
415/// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it.
416fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) {
417 let anchor = match anchor {
418 Anchor::Below([left, top, right, bottom]) => {
419 Anchor::Below([left, top - PAD, right, bottom + PAD])
420 }
421 // Level with the row it opens from, or with its contents over the box.
422 Anchor::Right([left, top, right, bottom]) => {
423 Anchor::Right([left, top - PAD, right, bottom + PAD])
424 }
425 Anchor::Over([left, top, right, bottom]) => {
426 Anchor::Over([left - PAD, top - PAD, right + PAD, bottom + PAD])
427 }
428 point => point,
429 };
430 let theme = &ui.theme;
431 let spec = Spec {
432 axis: Axis::Y,
433 size: [px(width), children()],
434 fill: Some(theme.popup),
435 border: Some(theme.chip),
436 shadow: Some(theme.shadow),
437 radius: 6.0,
438 pad: [PAD; 2],
439 gap: PAD,
440 anchor: Some(anchor),
441 ..Spec::default()
442 };
443 ui.open_as(id, spec);
444}
445
446fn state(ui: &mut Ui, id: Id) -> &mut Popup {
447 ui.popups
448 .iter_mut()
449 .find(|popup| popup.id == id)
450 .expect("the popup is open")
451}
452
453/// Takes the presses of `keys` routed this frame to the focus when it is one of `owners`.
454fn navigation(ui: &mut Ui, owners: &[Id], keys: &[NamedKey]) -> Vec<NamedKey> {
455 let Some(signal) = ui
456 .focus
457 .filter(|focus| owners.contains(focus))
458 .and_then(|focus| ui.signals.get_mut(&focus))
459 else {
460 return Vec::new();
461 };
462 let mut taken = Vec::new();
463 signal.events.retain(|event| match event {
464 Event::Key {
465 key: Key::Named(key),
466 ..
467 } if keys.contains(key) => {
468 taken.push(*key);
469 false
470 }
471 _ => true,
472 });
473 taken
474}
475
476/// The items matching a query, most relevant first, keyed by their index in `items`.
477struct Matches<'a> {
478 items: &'a [Item<'a>],
479 order: Vec<usize>,
480 /// Each item's row, or None when it does not match.
481 rows: Vec<Option<usize>>,
482 /// Rules above each row and the end, counted from the top; only while unfiltered.
483 rules: Vec<u32>,
484 ruled: bool,
485 icons: bool,
486}
487
488impl<'a> Matches<'a> {
489 fn new(items: &'a [Item<'a>], query: &str) -> Self {
490 let mut ranked: Vec<_> = items
491 .iter()
492 .enumerate()
493 .filter_map(|(index, item)| Some((score(query, item.text)?, index)))
494 .collect();
495 ranked.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
496 let order: Vec<usize> = ranked.into_iter().map(|(_, index)| index).collect();
497 let mut rows = vec![None; items.len()];
498 for (row, index) in order.iter().enumerate() {
499 rows[*index] = Some(row);
500 }
501 let ruled = query.is_empty();
502 let mut rules = vec![0];
503 for (row, index) in order.iter().enumerate().skip(1) {
504 let rule = u32::from(ruled && items[*index].separated);
505 rules.push(rules[row - 1] + rule);
506 }
507 rules.push(*rules.last().unwrap());
508 Self {
509 items,
510 order,
511 rows,
512 rules,
513 ruled,
514 icons: items.iter().any(|item| item.icon.is_some() || item.checked),
515 }
516 }
517}
518
519impl Rows for Matches<'_> {
520 fn count(&self) -> usize {
521 self.order.len()
522 }
523
524 fn key(&self, index: usize) -> u64 {
525 self.order[index] as u64
526 }
527
528 fn find(&self, key: u64) -> Option<usize> {
529 *self.rows.get(key as usize)?
530 }
531
532 fn selectable(&self, index: usize) -> bool {
533 !self.items[self.order[index]].disabled
534 }
535
536 fn space_before(&self, index: usize) -> f32 {
537 self.rules[index] as f32 * RULE
538 }
539}
540
541/// How well `text` matches `query`, ignoring case: 3 at its start, 2 at a word's, 1
542/// anywhere, 0 with the query's characters in order among others; None otherwise.
543fn score(query: &str, text: &str) -> Option<u8> {
544 let [query, text] = [query, text].map(str::to_lowercase);
545 let at_word = |(at, _): (usize, &str)| text[..at].ends_with(|c: char| !c.is_alphanumeric());
546 if text.starts_with(&query) {
547 Some(3)
548 } else if text.match_indices(&query).any(at_word) {
549 Some(2)
550 } else if text.contains(&query) {
551 Some(1)
552 } else {
553 let mut chars = text.chars();
554 query
555 .chars()
556 .all(|wanted| chars.any(|c| c == wanted))
557 .then_some(0)
558 }
559}
crates/ui/src/shell.rs+11-6
......@@ -41,7 +41,7 @@ pub fn section_tabs(
4141 let placed: Vec<_> = tabs
4242 .iter()
4343 .map(|(name, _)| {
44 let width = 10.0 + ui.measure(name)[0] + 4.0 + tallest / 2.0;
44 let width = 10.0 + ui.measure(name)[0] + 4.0 + lean(height);
4545 left += width;
4646 (left - width, width)
4747 })
......@@ -75,7 +75,7 @@ pub fn section_tabs(
7575 border: Some(fade(section.edge, open)),
7676 shadow: (open > 0.0).then(|| fade([0.0, 0.0, 0.0, 0.35], open)),
7777 radius: TAB_ROUNDING,
78 shape: Shape::Tab { slant: tall },
78 shape: Shape::Tab { lean: lean(height) },
7979 pad: [10.0, 0.0],
8080 ..Spec::default()
8181 },
......@@ -89,10 +89,15 @@ pub fn section_tabs(
8989 (clicked, open_tab)
9090}
9191
92/// Where the tab laid out at `tab` meets the edge below it: from its leading edge to the
93/// foot of its slant.
94pub fn tab_base(tab: [f32; 4]) -> [f32; 2] {
95 [tab[0], tab[2] + (tab[3] - tab[1]) / 2.0]
92/// Where the tab laid out at `tab` in a row `height` tall meets the edge below it: from its
93/// leading edge to the foot of its slant.
94pub fn tab_base(tab: [f32; 4], height: f32) -> [f32; 2] {
95 [tab[0], tab[2] - lean(height) + (tab[3] - tab[1])]
96}
97
98/// How far inside a tab's box its slant starts: half the open tab's height.
99fn lean(height: f32) -> f32 {
100 (height - 2.0) / 2.0
96101}
97102
98103/// A square button showing `icon` tinted by `tint`; artwork in its own colours takes white.
crates/ui/src/tests.rs+565
......@@ -524,3 +524,568 @@ fn scrollbar_thumbs_track_the_offset_and_drags_reach_both_ends() {
524524 build(&mut ui, &mut offset);
525525 assert_eq!(offset, 0.0);
526526}
527
528fn items() -> Vec<popup::Item<'static>> {
529 let item = |text| popup::Item {
530 text,
531 ..popup::Item::default()
532 };
533 vec![
534 item("Cut"),
535 item("Copy"),
536 popup::Item {
537 disabled: true,
538 ..item("Paste")
539 },
540 popup::Item {
541 separated: true,
542 ..item("Select all")
543 },
544 ]
545}
546
547fn menu_id() -> Id {
548 Id::ROOT.child("menu")
549}
550
551/// Builds a frame with a button filling the window under the menu, and returns the
552/// button's signal and the item chosen.
553fn menu_frame(ui: &mut Ui, anchor: Anchor, filter: Option<&str>) -> (Signal, Option<usize>) {
554 let mut result = (Signal::default(), None);
555 frame(ui, |ui| {
556 let under = ui.leaf(
557 "under",
558 Spec {
559 flags: Flags::CLICKABLE,
560 size: [fill(), fill()],
561 ..Spec::default()
562 },
563 );
564 let chosen = popup::menu(ui, menu_id(), anchor, &items(), filter);
565 result = (under, chosen);
566 });
567 result
568}
569
570const BELOW: Anchor = Anchor::Below([20.0, 20.0, 100.0, 40.0]);
571
572/// A window with the menu open and faded in below `BELOW`.
573fn open_menu(filter: Option<&str>) -> Ui {
574 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
575 menu_frame(&mut ui, BELOW, filter);
576 ui.open_popup(menu_id());
577 for _ in 0..40 {
578 menu_frame(&mut ui, BELOW, filter);
579 }
580 ui
581}
582
583/// The centre of the menu's row for `item`, from the latest layout.
584fn row(ui: &Ui, item: usize) -> [f32; 2] {
585 let rect = ui.rect(menu_id().child("rows").child(item)).unwrap();
586 [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0]
587}
588
589fn click(ui: &mut Ui, point: [f32; 2]) {
590 let at = Instant::now();
591 ui.event(Event::PointerMoved(point));
592 press(ui, at, true);
593 press(ui, at, false);
594}
595
596#[test]
597fn popups_open_beside_their_anchor_and_flip_to_stay_in_the_window() {
598 let mut ui = open_menu(None);
599 let rect = ui.rect(menu_id()).unwrap();
600 assert_eq!(
601 [rect[0], rect[1]],
602 [20.0, 44.0],
603 "under the anchor, a gap below"
604 );
605 assert_eq!(rect[2] - rect[0], 140.0, "no narrower than a menu");
606 let height = rect[3] - rect[1];
607 menu_frame(&mut ui, Anchor::Below([300.0, 260.0, 380.0, 280.0]), None);
608 menu_frame(&mut ui, Anchor::Below([300.0, 260.0, 380.0, 280.0]), None);
609 assert_eq!(
610 ui.rect(menu_id()),
611 Some([240.0, 256.0 - height, 380.0, 256.0]),
612 "above, level with the anchor's far edge"
613 );
614 menu_frame(&mut ui, Anchor::Right([350.0, 100.0, 390.0, 120.0]), None);
615 menu_frame(&mut ui, Anchor::Right([350.0, 100.0, 390.0, 120.0]), None);
616 assert_eq!(
617 ui.rect(menu_id()),
618 Some([210.0, 96.0, 350.0, 96.0 + height]),
619 "a submenu that cannot fit to the right opens to the left, its first row level"
620 );
621}
622
623#[test]
624fn popups_fade_in_except_at_a_point() {
625 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
626 menu_frame(&mut ui, BELOW, None);
627 ui.open_popup(menu_id());
628 menu_frame(&mut ui, BELOW, None);
629 menu_frame(&mut ui, BELOW, None);
630 let reveal = |ui: &Ui| {
631 ui.nodes
632 .iter()
633 .find(|node| node.id == menu_id())
634 .unwrap()
635 .alpha
636 };
637 assert!(reveal(&ui) > 0.0 && reveal(&ui) < 1.0);
638 assert!(
639 ui.rect(menu_id()).unwrap()[1] < 44.0,
640 "and slide from the anchor"
641 );
642 assert!(ui.wants_frame());
643 for _ in 0..10 {
644 menu_frame(&mut ui, BELOW, None);
645 }
646 assert!(reveal(&ui) > 0.95, "within about 150 ms");
647 ui.close_popup(menu_id());
648 menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None);
649 ui.open_popup(menu_id());
650 menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None);
651 menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None);
652 assert_eq!(reveal(&ui), 1.0, "a context menu shows at once");
653 assert_eq!(ui.rect(menu_id()).unwrap()[..2], [50.0, 50.0]);
654}
655
656#[test]
657fn a_press_outside_dismisses_without_reaching_what_is_beneath() {
658 let mut ui = open_menu(None);
659 click(&mut ui, [300.0, 250.0]);
660 let (under, chosen) = menu_frame(&mut ui, BELOW, None);
661 assert!(!ui.popup_open(menu_id()) && chosen.is_none());
662 assert!(!under.pressed && !under.clicked);
663 click(&mut ui, [300.0, 250.0]);
664 let (under, _) = menu_frame(&mut ui, BELOW, None);
665 assert!(under.clicked, "once closed, beneath takes presses again");
666}
667
668#[test]
669fn escape_dismisses_and_returns_the_focus() {
670 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
671 let page = Id::ROOT.child("under");
672 menu_frame(&mut ui, BELOW, None);
673 ui.set_focus(Some(page));
674 ui.open_popup(menu_id());
675 menu_frame(&mut ui, BELOW, None);
676 assert_eq!(ui.focused(), Some(menu_id()));
677 ui.event(key(NamedKey::Escape));
678 menu_frame(&mut ui, BELOW, None);
679 assert!(!ui.popup_open(menu_id()));
680 assert_eq!(ui.focused(), Some(page));
681}
682
683#[test]
684fn input_over_a_popup_stays_with_it() {
685 let mut ui = open_menu(None);
686 ui.event(Event::PointerMoved(row(&ui, 1)));
687 let (under, _) = menu_frame(&mut ui, BELOW, None);
688 assert!(!under.hovered);
689 let point = row(&ui, 1);
690 click(&mut ui, point);
691 let (under, chosen) = menu_frame(&mut ui, BELOW, None);
692 assert_eq!(chosen, Some(1));
693 assert!(!under.pressed && !under.clicked);
694 assert!(!ui.popup_open(menu_id()), "choosing closes it");
695}
696
697#[test]
698fn disabled_items_ignore_the_pointer() {
699 let mut ui = open_menu(None);
700 let point = row(&ui, 2);
701 click(&mut ui, point);
702 let (_, chosen) = menu_frame(&mut ui, BELOW, None);
703 assert_eq!(chosen, None);
704 assert!(ui.popup_open(menu_id()));
705}
706
707#[test]
708fn keys_move_the_highlight_past_disabled_items() {
709 let mut ui = open_menu(None);
710 for (keys, highlight) in [
711 (vec![NamedKey::ArrowDown], 0),
712 (vec![NamedKey::ArrowDown, NamedKey::ArrowDown], 3),
713 (vec![NamedKey::ArrowDown], 3),
714 (vec![NamedKey::ArrowUp], 1),
715 (vec![NamedKey::End], 3),
716 (vec![NamedKey::Home], 0),
717 (vec![NamedKey::PageDown], 3),
718 ] {
719 for named in keys {
720 ui.event(key(named));
721 }
722 menu_frame(&mut ui, BELOW, None);
723 assert_eq!(ui.popups[0].highlight, Some(highlight));
724 }
725 ui.event(key(NamedKey::Enter));
726 assert_eq!(menu_frame(&mut ui, BELOW, None).1, Some(3));
727}
728
729#[test]
730fn the_pointer_moves_the_highlight_only_when_it_moves() {
731 let mut ui = open_menu(None);
732 ui.event(Event::PointerMoved(row(&ui, 1)));
733 menu_frame(&mut ui, BELOW, None);
734 ui.event(key(NamedKey::ArrowDown));
735 menu_frame(&mut ui, BELOW, None);
736 menu_frame(&mut ui, BELOW, None);
737 assert_eq!(
738 ui.popups[0].highlight,
739 Some(3),
740 "a still pointer keeps its row"
741 );
742}
743
744#[test]
745fn typing_filters_to_the_best_matches_first() {
746 let mut ui = open_menu(Some("Filter"));
747 assert_eq!(ui.focused(), Some(menu_id().child("filter")));
748 for text in ["c", "t"] {
749 ui.event(typed(text));
750 }
751 let settle = |ui: &mut Ui| {
752 for _ in 0..30 {
753 menu_frame(ui, BELOW, Some("Filter"));
754 }
755 };
756 settle(&mut ui);
757 assert_eq!(ui.popups[0].query, "ct");
758 let shown = |ui: &Ui| {
759 let mut shown: Vec<_> = (0..4_usize)
760 .filter_map(|item| {
761 let rect = ui.rect(menu_id().child("rows").child(item))?;
762 Some((rect[1] as i32, item))
763 })
764 .collect();
765 shown.sort();
766 shown.into_iter().map(|(_, item)| item).collect::<Vec<_>>()
767 };
768 assert_eq!(shown(&ui), [3, 0], "letters together before letters apart");
769 ui.event(key(NamedKey::Backspace));
770 settle(&mut ui);
771 assert_eq!(shown(&ui), [0, 1, 3], "a prefix before a word's start");
772 ui.event(key(NamedKey::ArrowDown));
773 ui.event(key(NamedKey::Enter));
774 assert_eq!(menu_frame(&mut ui, BELOW, Some("Filter")).1, Some(1));
775}
776
777#[test]
778fn nothing_matching_leaves_nothing_to_choose() {
779 let mut ui = open_menu(Some("Filter"));
780 ui.event(typed("z"));
781 menu_frame(&mut ui, BELOW, Some("Filter"));
782 ui.event(key(NamedKey::ArrowDown));
783 ui.event(key(NamedKey::Enter));
784 let (_, chosen) = menu_frame(&mut ui, BELOW, Some("Filter"));
785 assert_eq!(chosen, None);
786 assert!(ui.popup_open(menu_id()));
787}
788
789#[test]
790fn the_palette_centres_across_the_window_and_chooses_the_best_match() {
791 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
792 let palette = Id::ROOT.child("palette");
793 let commands = items();
794 let build = |ui: &mut Ui| {
795 let mut chosen = None;
796 frame(ui, |ui| chosen = popup::palette(ui, palette, &commands));
797 chosen
798 };
799 build(&mut ui);
800 ui.open_popup(palette);
801 for _ in 0..40 {
802 build(&mut ui);
803 }
804 let rect = ui.rect(palette).unwrap();
805 assert_eq!(rect[0] + rect[2], 400.0, "centred");
806 ui.event(typed("a"));
807 ui.event(key(NamedKey::Enter));
808 assert_eq!(
809 build(&mut ui),
810 Some(3),
811 "`all` begins a word; `Paste` is disabled"
812 );
813}
814
815#[test]
816fn colour_grids_move_in_two_dimensions_and_choose_a_swatch_or_none() {
817 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
818 let grid = Id::ROOT.child("colours");
819 let swatches: Vec<[f32; 4]> = (0..6).map(|index| [index as f32, 0.0, 0.0, 1.0]).collect();
820 let build = |ui: &mut Ui| {
821 let mut chosen = None;
822 frame(ui, |ui| {
823 chosen = popup::colors(ui, grid, BELOW, "Automatic", &swatches, 3);
824 });
825 chosen
826 };
827 build(&mut ui);
828 ui.open_popup(grid);
829 build(&mut ui);
830 for named in [
831 NamedKey::ArrowDown,
832 NamedKey::ArrowDown,
833 NamedKey::ArrowDown,
834 NamedKey::ArrowRight,
835 NamedKey::Enter,
836 ] {
837 ui.event(key(named));
838 }
839 assert_eq!(build(&mut ui), Some(Some(swatches[4])));
840 ui.open_popup(grid);
841 for _ in 0..40 {
842 build(&mut ui);
843 }
844 let rect = ui.rect(grid.child(("cell", 0_usize))).unwrap();
845 click(&mut ui, [rect[0] + 4.0, rect[1] + 4.0]);
846 assert_eq!(build(&mut ui), Some(None));
847}
848
849#[test]
850fn popups_opened_within_a_popup_stay_above_it() {
851 let mut ui = open_menu(None);
852 let submenu = Id::ROOT.child("submenu");
853 let build = |ui: &mut Ui, open: bool| {
854 frame(ui, |ui| {
855 ui.open_as(
856 menu_id(),
857 Spec {
858 size: [px(100.0), px(100.0)],
859 anchor: Some(BELOW),
860 ..Spec::default()
861 },
862 );
863 if open {
864 ui.open_popup(submenu);
865 }
866 ui.close();
867 popup::menu(
868 ui,
869 submenu,
870 Anchor::Right([100.0, 50.0, 120.0, 70.0]),
871 &items(),
872 None,
873 );
874 })
875 };
876 build(&mut ui, true);
877 build(&mut ui, false);
878 assert!(ui.popup_open(menu_id()) && ui.popup_open(submenu));
879 ui.event(key(NamedKey::Escape));
880 build(&mut ui, false);
881 assert!(ui.popup_open(menu_id()) && !ui.popup_open(submenu));
882}
883
884/// Keys in order, counting the lookups a list makes.
885struct Keyed {
886 keys: Vec<u64>,
887 at: HashMap<u64, usize>,
888 lookups: std::cell::Cell<usize>,
889}
890
891impl Keyed {
892 fn new(keys: impl IntoIterator<Item = u64>) -> Self {
893 let keys: Vec<u64> = keys.into_iter().collect();
894 let at = keys
895 .iter()
896 .enumerate()
897 .map(|(index, key)| (*key, index))
898 .collect();
899 Self {
900 keys,
901 at,
902 lookups: Default::default(),
903 }
904 }
905}
906
907impl Rows for Keyed {
908 fn count(&self) -> usize {
909 self.keys.len()
910 }
911
912 fn key(&self, index: usize) -> u64 {
913 self.lookups.set(self.lookups.get() + 1);
914 self.keys[index]
915 }
916
917 fn find(&self, key: u64) -> Option<usize> {
918 self.lookups.set(self.lookups.get() + 1);
919 self.at.get(&key).copied()
920 }
921}
922
923const LIST_ROW: f32 = 20.0;
924
925fn list_id() -> Id {
926 Id::ROOT.child("list")
927}
928
929/// Builds a frame of a list 200 high over `rows`, ten rows in view.
930fn list_frame(
931 ui: &mut Ui,
932 rows: &Keyed,
933 selected: &mut Option<u64>,
934 keys: &[NamedKey],
935) -> Option<usize> {
936 let mut clicked = None;
937 frame(ui, |ui| {
938 let list = List {
939 rows,
940 row: LIST_ROW,
941 keys,
942 hover_selects: false,
943 };
944 let spec = Spec {
945 size: [px(200.0), px(200.0)],
946 ..Spec::default()
947 };
948 clicked = crate::list(ui, list_id(), spec, list, selected, |ui, row| {
949 ui.leaf(
950 "label",
951 Spec {
952 size: [fill(), fill()],
953 fill: row.selected.then_some([1.0; 4]),
954 ..Spec::default()
955 },
956 );
957 });
958 });
959 clicked
960}
961
962fn row_top(ui: &Ui, key: u64) -> Option<f32> {
963 ui.rect(list_id().child(key)).map(|rect| rect[1])
964}
965
966/// Frames until the list's animations end.
967fn settle_list(ui: &mut Ui, rows: &Keyed, selected: &mut Option<u64>) {
968 for _ in 0..40 {
969 list_frame(ui, rows, selected, &[]);
970 }
971 assert!(!ui.wants_frame());
972}
973
974#[test]
975fn lists_build_only_the_rows_in_view() {
976 let mut boxes = Vec::new();
977 for count in [20, 200_000] {
978 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
979 let rows = Keyed::new(0..count);
980 let mut selected = None;
981 settle_list(&mut ui, &rows, &mut selected);
982 rows.lookups.set(0);
983 list_frame(&mut ui, &rows, &mut selected, &[]);
984 assert!(rows.lookups.get() < 100, "{} lookups", rows.lookups.get());
985 boxes.push(ui.nodes.len());
986 }
987 assert_eq!(boxes[0], boxes[1]);
988}
989
990#[test]
991fn lists_hold_the_selection_still_as_items_arrive_and_leave_above() {
992 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
993 let mut rows = Keyed::new(0..1000);
994 let mut selected = Some(500);
995 settle_list(&mut ui, &rows, &mut selected);
996 let at = row_top(&ui, 500).unwrap();
997 assert!(
998 (0.0..200.0).contains(&at),
999 "a new selection scrolls into view"
1000 );
1001 rows = Keyed::new((10_000..10_100).chain(0..1000));
1002 list_frame(&mut ui, &rows, &mut selected, &[]);
1003 assert_eq!(row_top(&ui, 500), Some(at), "a hundred rows arrived above");
1004 rows = Keyed::new((0..1000).filter(|key| key % 2 == 1 || *key >= 500));
1005 list_frame(&mut ui, &rows, &mut selected, &[]);
1006 assert_eq!(row_top(&ui, 500), Some(at), "half the rows above left");
1007 ui.event(Event::PointerMoved([50.0, 50.0]));
1008 ui.event(Event::Wheel([0.0, -60.0]));
1009 list_frame(&mut ui, &rows, &mut selected, &[]);
1010 rows = Keyed::new((20_000..20_050).chain(rows.keys.iter().copied()));
1011 list_frame(&mut ui, &rows, &mut selected, &[]);
1012 let moving = row_top(&ui, 500).unwrap();
1013 assert!(
1014 moving < at && moving > at - 60.0,
1015 "the wheel eases on through arrivals"
1016 );
1017 settle_list(&mut ui, &rows, &mut selected);
1018 assert_eq!(row_top(&ui, 500), Some(at - 60.0));
1019}
1020
1021#[test]
1022fn rows_ease_to_where_their_items_move_and_fade_in_and_out() {
1023 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1024 let mut selected = None;
1025 let rows = Keyed::new(0..20);
1026 settle_list(&mut ui, &rows, &mut selected);
1027 assert_eq!(row_top(&ui, 5), Some(5.0 * LIST_ROW));
1028 let rows = Keyed::new([5, 0, 1, 2, 3, 100, 6, 7, 8, 9, 10, 11]);
1029 list_frame(&mut ui, &rows, &mut selected, &[]);
1030 let alpha = |ui: &Ui, key: u64| {
1031 let id = list_id().child(key);
1032 ui.nodes
1033 .iter()
1034 .find(|node| node.id == id)
1035 .map(|node| node.alpha)
1036 };
1037 let moved = row_top(&ui, 5).unwrap();
1038 assert!(
1039 moved > 0.0 && moved < 5.0 * LIST_ROW,
1040 "on its way up: {moved}"
1041 );
1042 let arriving = alpha(&ui, 100).unwrap();
1043 assert!(arriving > 0.0 && arriving < 1.0, "fading in");
1044 let leaving = alpha(&ui, 4).unwrap();
1045 assert!(leaving > 0.0 && leaving < 1.0, "fading out");
1046 for _ in 0..10 {
1047 list_frame(&mut ui, &rows, &mut selected, &[]);
1048 }
1049 assert!(
1050 row_top(&ui, 5).unwrap() < 3.0,
1051 "all but there within about 150 ms"
1052 );
1053 settle_list(&mut ui, &rows, &mut selected);
1054 assert_eq!(row_top(&ui, 5), Some(0.0));
1055 assert_eq!(alpha(&ui, 100), Some(1.0));
1056 assert_eq!(alpha(&ui, 4), None);
1057}
1058
1059#[test]
1060fn keys_move_the_selection_and_the_view_eases_after_it() {
1061 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1062 let rows = Keyed::new(0..1000);
1063 let mut selected = None;
1064 settle_list(&mut ui, &rows, &mut selected);
1065 list_frame(&mut ui, &rows, &mut selected, &[NamedKey::ArrowDown]);
1066 assert_eq!(selected, Some(0), "from nothing, the first row in view");
1067 list_frame(&mut ui, &rows, &mut selected, &[NamedKey::PageDown]);
1068 assert_eq!(selected, Some(10));
1069 list_frame(&mut ui, &rows, &mut selected, &[NamedKey::End]);
1070 assert_eq!(selected, Some(999));
1071 assert!(
1072 ui.wants_frame() && row_top(&ui, 999) != Some(200.0 - LIST_ROW),
1073 "the view eases towards it"
1074 );
1075 settle_list(&mut ui, &rows, &mut selected);
1076 assert_eq!(
1077 row_top(&ui, 999),
1078 Some(200.0 - LIST_ROW),
1079 "the last row at the bottom"
1080 );
1081 list_frame(
1082 &mut ui,
1083 &rows,
1084 &mut selected,
1085 &[NamedKey::PageUp, NamedKey::ArrowUp],
1086 );
1087 assert_eq!(selected, Some(988));
1088 list_frame(&mut ui, &rows, &mut selected, &[NamedKey::Home]);
1089 settle_list(&mut ui, &rows, &mut selected);
1090 assert_eq!((selected, row_top(&ui, 0)), (Some(0), Some(0.0)));
1091}
crates/ui/src/theme.rs+7
......@@ -21,6 +21,9 @@ pub struct Theme {
2121 pub paper_ink: [f32; 4],
2222 /// Key caps, small controls and field borders.
2323 pub chip: [f32; 4],
24 /// Menus and other popups, and the shadow they cast.
25 pub popup: [f32; 4],
26 pub shadow: [f32; 4],
2427 /// Saturation and lightness each of a section's colours takes from its hue.
2528 pub shades: Shades,
2629}
......@@ -62,6 +65,8 @@ impl Theme {
6265 paper: srgb(0x1f, 0x20, 0x22),
6366 paper_ink: srgb(0xe6, 0xe6, 0xe6),
6467 chip: srgb(0x38, 0x3c, 0x3d),
68 popup: srgb(0x2b, 0x2e, 0x30),
69 shadow: [0.0, 0.0, 0.0, 0.6],
6570 shades: Shades {
6671 frame: [[0.30, 0.36], [0.30, 0.30]],
6772 tab: [0.22, 0.25],
......@@ -84,6 +89,8 @@ impl Theme {
8489 paper: [1.0; 4],
8590 paper_ink: [0.0, 0.0, 0.0, 1.0],
8691 chip: srgb(0xcf, 0xd3, 0xd9),
92 popup: [1.0; 4],
93 shadow: [0.0, 0.0, 0.0, 0.3],
8794 shades: Shades {
8895 frame: [[0.55, 0.82], [0.55, 0.76]],
8996 tab: [0.45, 0.70],