| author | |
| committer | |
| log | 8a427ca60769dc6268c802235ceed1f70d44ee6f |
| tree | 689a93779ae3f095ce2a1a825e49ce9c640275fd |
| parent | 332e865dc1d25461af433a52794ec50ebf81f388 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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-513 files changed, 2412 insertions(+), 57 deletions(-)
Cargo.lock+3| ... | ... | @@ -3073,6 +3073,9 @@ version = "0.1.0" |
| 3073 | 3073 | dependencies = [ |
| 3074 | 3074 | "draw", |
| 3075 | 3075 | "parley", |
| 3076 | "png", | |
| 3077 | "pollster", | |
| 3078 | "wgpu", | |
| 3076 | 3079 | "winit", |
| 3077 | 3080 | ] |
| 3078 | 3081 |
crates/draw/src/render.rs+3-1| ... | ... | @@ -1098,7 +1098,9 @@ impl Renderer { |
| 1098 | 1098 | rect[3] * space.scale + space.origin[1], |
| 1099 | 1099 | ]; |
| 1100 | 1100 | 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])?; | |
| 1102 | 1104 | let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; |
| 1103 | 1105 | if !(4.0 * (half[0] + half[1])).is_finite() || !colors[1].iter().all(|v| v.is_finite()) { |
| 1104 | 1106 | return Err(RenderError::InvalidPrimitive); |
crates/snowbound/src/main.rs+27-11| ... | ... | @@ -21,7 +21,7 @@ use std::{ |
| 21 | 21 | sync::Arc, |
| 22 | 22 | time::Instant, |
| 23 | 23 | }; |
| 24 | use ui::{Axis, Flags, Id, Shape, Spec, Theme, Ui, children, fill, fit, px}; | |
| 24 | use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px}; | |
| 25 | 25 | use winit::{ |
| 26 | 26 | application::ApplicationHandler, |
| 27 | 27 | dpi::{LogicalSize, PhysicalPosition, PhysicalSize}, |
| ... | ... | @@ -727,7 +727,10 @@ impl State { |
| 727 | 727 | paper, |
| 728 | 728 | |_| strip, |
| 729 | 729 | ); |
| 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)); | |
| 731 | 734 | for edge in [ |
| 732 | 735 | [ |
| 733 | 736 | ([start, frame[1] + outer], 0.0), |
| ... | ... | @@ -1089,28 +1092,41 @@ impl State { |
| 1089 | 1092 | pad: [10.0 + 16.0 * level.saturating_sub(1) as f32, 0.0], |
| 1090 | 1093 | ..Spec::default() |
| 1091 | 1094 | }; |
| 1092 | let spec = if selected { | |
| 1095 | let clicked = if selected { | |
| 1093 | 1096 | open = Some(self.ui.id(space)); |
| 1094 | Spec { | |
| 1097 | let spec = Spec { | |
| 1095 | 1098 | color: Some(theme.paper_ink), |
| 1096 | 1099 | fill: Some(theme.paper), |
| 1097 | 1100 | ..spec |
| 1098 | } | |
| 1101 | }; | |
| 1102 | self.ui.leaf(space, spec).clicked | |
| 1099 | 1103 | } 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)], | |
| 1101 | 1115 | color: Some(if title.is_empty() { |
| 1102 | 1116 | ui::mix(theme.ink, section.tab, 0.5) |
| 1103 | 1117 | } else { |
| 1104 | 1118 | theme.ink |
| 1105 | 1119 | }), |
| 1106 | 1120 | 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, | |
| 1110 | 1123 | ..spec |
| 1111 | } | |
| 1124 | }; | |
| 1125 | let clicked = self.ui.leaf("pill", spec).clicked; | |
| 1126 | self.ui.close(); | |
| 1127 | clicked | |
| 1112 | 1128 | }; |
| 1113 | if self.ui.leaf(space, spec).clicked && !selected { | |
| 1129 | if clicked && !selected { | |
| 1114 | 1130 | self.commands.push(Command::OpenPage(*space)); |
| 1115 | 1131 | } |
| 1116 | 1132 | } |
crates/ui/Cargo.toml+5| ... | ... | @@ -8,3 +8,8 @@ publish = false |
| 8 | 8 | draw = { path = "../draw" } |
| 9 | 9 | parley.workspace = true |
| 10 | 10 | winit = "0.30.13" |
| 11 | ||
| 12 | [dev-dependencies] | |
| 13 | pollster = "0.4" | |
| 14 | png = "0.18" | |
| 15 | wgpu.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 | ||
| 5 | use std::{ | |
| 6 | collections::HashMap, | |
| 7 | time::{Duration, Instant}, | |
| 8 | }; | |
| 9 | use ui::{Anchor, Event, Id, List, Rows, Spec, Theme, Ui, fill, popup::Item, px, shell}; | |
| 10 | use winit::keyboard::{Key, NamedKey}; | |
| 11 | ||
| 12 | const PANEL: [f32; 2] = [420.0, 340.0]; | |
| 13 | const SCALE: f32 = 2.0; | |
| 14 | ||
| 15 | macro_rules! art { | |
| 16 | ($path:literal) => { | |
| 17 | &[include_str!(concat!( | |
| 18 | "../../snowbound/assets/", | |
| 19 | $path, | |
| 20 | ".svg" | |
| 21 | ))] | |
| 22 | }; | |
| 23 | } | |
| 24 | ||
| 25 | fn 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. | |
| 63 | fn 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. | |
| 95 | struct Keyed { | |
| 96 | keys: Vec<u64>, | |
| 97 | at: HashMap<u64, usize>, | |
| 98 | } | |
| 99 | ||
| 100 | impl 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 | ||
| 111 | impl 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. | |
| 127 | fn 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 | ||
| 223 | struct Scene { | |
| 224 | ui: Ui, | |
| 225 | start: Instant, | |
| 226 | frames: u32, | |
| 227 | } | |
| 228 | ||
| 229 | impl 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 | ||
| 287 | fn below(ui: &Ui, id: Id) -> Anchor { | |
| 288 | Anchor::Below(ui.rect(id).unwrap_or_default()) | |
| 289 | } | |
| 290 | ||
| 291 | fn key(named: NamedKey) -> Event { | |
| 292 | Event::Key { | |
| 293 | key: Key::Named(named), | |
| 294 | text: None, | |
| 295 | } | |
| 296 | } | |
| 297 | ||
| 298 | fn 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 | ||
| 307 | fn 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 | ||
| 364 | const 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 | ||
| 385 | fn 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 | ||
| 396 | fn 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 | ||
| 405 | fn 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 | ||
| 447 | fn 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 | ||
| 466 | fn 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 | ||
| 492 | fn 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 | ||
| 518 | fn 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`. | |
| 563 | fn 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. | |
| 595 | fn 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 |
| 74 | 74 | } |
| 75 | 75 | } |
| 76 | 76 | } |
| 77 | let window = nodes[0].computed[axis]; | |
| 77 | 78 | for index in 0..nodes.len() { |
| 78 | 79 | let mut cursor = nodes[index].pad[axis]; |
| 79 | 80 | 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) { | |
| 81 | 85 | nodes[child].position[axis] |
| 82 | 86 | } else if along(&nodes[index], axis) { |
| 83 | 87 | let at = cursor; |
| ... | ... | @@ -127,11 +131,9 @@ fn strictness(nodes: &[Built], index: usize, axis: usize) -> f32 { |
| 127 | 131 | } |
| 128 | 132 | |
| 129 | 133 | fn 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 | }) | |
| 135 | 137 | } |
| 136 | 138 | |
| 137 | 139 | /// 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 @@ |
| 4 | 4 | //! events are routed before building and its layout is solved after. |
| 5 | 5 | |
| 6 | 6 | mod layout; |
| 7 | mod list; | |
| 8 | pub mod popup; | |
| 7 | 9 | pub mod shell; |
| 8 | 10 | mod text; |
| 9 | 11 | mod theme; |
| 10 | 12 | mod widgets; |
| 11 | 13 | |
| 14 | pub use list::{List, Row, Rows, list}; | |
| 12 | 15 | pub use theme::{Section, Shades, Theme}; |
| 13 | 16 | pub use widgets::{button, edit_key, edit_modifiers, scrollbar, text_field}; |
| 14 | 17 | |
| ... | ... | @@ -27,14 +30,18 @@ use std::{ |
| 27 | 30 | use text::{Label, Texts}; |
| 28 | 31 | use winit::{ |
| 29 | 32 | event::{Ime, MouseButton}, |
| 30 | keyboard::{Key, ModifiersState}, | |
| 33 | keyboard::{Key, ModifiersState, NamedKey}, | |
| 31 | 34 | window::CursorIcon, |
| 32 | 35 | }; |
| 33 | 36 | |
| 34 | 37 | /// Seconds for an animated value to close half of its remaining distance. |
| 35 | 38 | const HALF_LIFE: f32 = 0.03; |
| 36 | /// How far a box's shadow spreads, in logical pixels. | |
| 37 | const 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. | |
| 41 | const SHADOW: [f32; 2] = [3.0, 0.0]; | |
| 42 | const POPUP_SHADOW: [f32; 2] = [12.0, 4.0]; | |
| 43 | /// How far a popup slides in from its anchor as it fades in. | |
| 44 | const SLIDE: f32 = 6.0; | |
| 38 | 45 | /// Logical size of a box's icon, and its distance from the label. |
| 39 | 46 | const ICON: f32 = 16.0; |
| 40 | 47 | const ICON_GAP: f32 = 6.0; |
| ... | ... | @@ -154,11 +161,50 @@ impl BitOr for Flags { |
| 154 | 161 | pub enum Shape { |
| 155 | 162 | #[default] |
| 156 | 163 | 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)] | |
| 172 | pub 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 | ||
| 183 | impl 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 | } | |
| 162 | 208 | } |
| 163 | 209 | |
| 164 | 210 | /// What a box is this frame. Colours are linear RGBA. |
| ... | ... | @@ -194,6 +240,11 @@ pub struct Spec<'a> { |
| 194 | 240 | /// For floating boxes, the offset from the parent's corner. |
| 195 | 241 | pub position: [f32; 2], |
| 196 | 242 | 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, | |
| 197 | 248 | } |
| 198 | 249 | |
| 199 | 250 | /// Input the host forwards; positions and wheel distances are logical pixels. |
| ... | ... | @@ -229,7 +280,7 @@ pub struct Signal { |
| 229 | 280 | pub dragging: bool, |
| 230 | 281 | pub focused: bool, |
| 231 | 282 | /// 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. | |
| 233 | 284 | pub events: Vec<Event>, |
| 234 | 285 | } |
| 235 | 286 | |
| ... | ... | @@ -271,6 +322,9 @@ struct Built { |
| 271 | 322 | center: bool, |
| 272 | 323 | position: [f32; 2], |
| 273 | 324 | 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, | |
| 274 | 328 | /// Rectangles relative to the box, painted over its fill. |
| 275 | 329 | marks: Vec<([f32; 4], [f32; 4])>, |
| 276 | 330 | computed: [f32; 2], |
| ... | ... | @@ -299,6 +353,16 @@ struct State { |
| 299 | 353 | tween: Option<[f32; 2]>, |
| 300 | 354 | } |
| 301 | 355 | |
| 356 | /// An open popup, above the one opened before it. | |
| 357 | struct 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 | ||
| 302 | 366 | struct Hit { |
| 303 | 367 | id: Id, |
| 304 | 368 | rect: [f32; 4], |
| ... | ... | @@ -351,6 +415,28 @@ enum Display { |
| 351 | 415 | }, |
| 352 | 416 | } |
| 353 | 417 | |
| 418 | impl 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 | ||
| 354 | 440 | pub struct Ui { |
| 355 | 441 | pub theme: Theme, |
| 356 | 442 | frame: u64, |
| ... | ... | @@ -364,7 +450,15 @@ pub struct Ui { |
| 364 | 450 | signals: HashMap<Id, Signal>, |
| 365 | 451 | /// The previous frame's interactive boxes in paint order. |
| 366 | 452 | hits: Vec<Hit>, |
| 453 | /// Hits before this one lie beneath an open popup. | |
| 454 | modal: usize, | |
| 455 | popups: Vec<Popup>, | |
| 367 | 456 | 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>, | |
| 368 | 462 | hover: Option<Id>, |
| 369 | 463 | active: Option<Id>, |
| 370 | 464 | focus: Option<Id>, |
| ... | ... | @@ -389,7 +483,12 @@ impl Ui { |
| 389 | 483 | queue: Vec::new(), |
| 390 | 484 | signals: HashMap::new(), |
| 391 | 485 | hits: Vec::new(), |
| 486 | modal: 0, | |
| 487 | popups: Vec::new(), | |
| 392 | 488 | pointer: None, |
| 489 | moved: false, | |
| 490 | rate: 1.0, | |
| 491 | lists: HashMap::new(), | |
| 393 | 492 | hover: None, |
| 394 | 493 | active: None, |
| 395 | 494 | focus: None, |
| ... | ... | @@ -477,6 +576,7 @@ impl Ui { |
| 477 | 576 | self.stack.clear(); |
| 478 | 577 | self.stack.push(0); |
| 479 | 578 | self.signals.clear(); |
| 579 | self.moved = false; | |
| 480 | 580 | for event in std::mem::take(&mut self.queue) { |
| 481 | 581 | self.route(event); |
| 482 | 582 | } |
| ... | ... | @@ -487,6 +587,7 @@ impl Ui { |
| 487 | 587 | match event { |
| 488 | 588 | Event::PointerMoved(point) => { |
| 489 | 589 | self.pointer = Some(point); |
| 590 | self.moved = true; | |
| 490 | 591 | let hover = self.hit(point, Flags::CLICKABLE | Flags::CUSTOM); |
| 491 | 592 | if hover != self.hover |
| 492 | 593 | && self.active.is_none_or(|active| Some(active) != self.hover) |
| ... | ... | @@ -511,6 +612,18 @@ impl Ui { |
| 511 | 612 | let Some(point) = self.pointer else { |
| 512 | 613 | return; |
| 513 | 614 | }; |
| 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 | } | |
| 514 | 627 | let target = self.hit(point, Flags::CLICKABLE | Flags::CUSTOM); |
| 515 | 628 | if let Some(id) = target |
| 516 | 629 | && button == MouseButton::Left |
| ... | ... | @@ -559,10 +672,15 @@ impl Ui { |
| 559 | 672 | let state = self.states.entry(id).or_default(); |
| 560 | 673 | let most = (state.content - (state.rect[3] - state.rect[1])).max(0.0); |
| 561 | 674 | state.scroll_target = (state.scroll_target - delta[1]).clamp(0.0, most); |
| 675 | self.signals.entry(id).or_default().events.push(event); | |
| 562 | 676 | } |
| 563 | 677 | None => {} |
| 564 | 678 | } |
| 565 | 679 | } |
| 680 | Event::Key { | |
| 681 | key: Key::Named(NamedKey::Escape), | |
| 682 | .. | |
| 683 | } if !self.popups.is_empty() => self.close_from(self.popups.len() - 1), | |
| 566 | 684 | Event::Key { .. } | Event::Ime(_) => { |
| 567 | 685 | if let Some(focus) = self.focus { |
| 568 | 686 | self.signals.entry(focus).or_default().events.push(event); |
| ... | ... | @@ -604,21 +722,62 @@ impl Ui { |
| 604 | 722 | |
| 605 | 723 | /// The topmost box under `point` with any of `flags`. |
| 606 | 724 | fn hit(&self, point: [f32; 2], flags: Flags) -> Option<Id> { |
| 607 | self.hits | |
| 725 | self.hits[self.modal..] | |
| 608 | 726 | .iter() |
| 609 | 727 | .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)) | |
| 617 | 729 | .map(|hit| hit.id) |
| 618 | 730 | } |
| 619 | 731 | |
| 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 | ||
| 620 | 778 | fn ease(&mut self, dt: f32) { |
| 621 | 779 | let rate = 1.0 - 0.5_f32.powf(dt / HALF_LIFE); |
| 780 | self.rate = rate; | |
| 622 | 781 | let mut animating = false; |
| 623 | 782 | for (id, state) in &mut self.states { |
| 624 | 783 | let hot = self.hover == Some(*id) && self.active.is_none_or(|active| active == *id); |
| ... | ... | @@ -667,13 +826,24 @@ impl Ui { |
| 667 | 826 | let label = spec |
| 668 | 827 | .text |
| 669 | 828 | .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 | }; | |
| 671 | 835 | 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); | |
| 674 | 845 | self.nodes[parent].children.push(index); |
| 675 | 846 | self.stack.push(index); |
| 676 | self.states.entry(id).or_default().touched = self.frame; | |
| 677 | 847 | id |
| 678 | 848 | } |
| 679 | 849 | |
| ... | ... | @@ -745,9 +915,24 @@ impl Ui { |
| 745 | 915 | self.states |
| 746 | 916 | .retain(|id, state| state.touched == self.frame || *id == Id::ROOT); |
| 747 | 917 | 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 | } | |
| 748 | 926 | self.display.clear(); |
| 749 | 927 | self.hits.clear(); |
| 750 | 928 | 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 }; | |
| 751 | 936 | for id in [&mut self.hover, &mut self.active, &mut self.focus] { |
| 752 | 937 | if id.is_some_and(|id| !self.states.contains_key(&id)) { |
| 753 | 938 | *id = None; |
| ... | ... | @@ -756,6 +941,7 @@ impl Ui { |
| 756 | 941 | } |
| 757 | 942 | |
| 758 | 943 | fn paint(&mut self, index: usize, clip: Option<[f32; 4]>) { |
| 944 | let start = self.display.len(); | |
| 759 | 945 | let node = &self.nodes[index]; |
| 760 | 946 | let state = &self.states[&node.id]; |
| 761 | 947 | let rect = node.rect; |
| ... | ... | @@ -781,10 +967,15 @@ impl Ui { |
| 781 | 967 | let border = blend(node.border, node.hover_border); |
| 782 | 968 | let size = [rect[2] - rect[0], rect[3] - rect[1]]; |
| 783 | 969 | if let Some(shadow) = node.shadow { |
| 970 | let [spread, drop] = if node.anchor.is_some() { | |
| 971 | POPUP_SHADOW | |
| 972 | } else { | |
| 973 | SHADOW | |
| 974 | }; | |
| 784 | 975 | self.display.push(Display::Path { |
| 785 | 976 | 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), | |
| 788 | 979 | colors: [shadow; 2], |
| 789 | 980 | }); |
| 790 | 981 | } |
| ... | ... | @@ -889,11 +1080,19 @@ impl Ui { |
| 889 | 1080 | self.display.push(Display::Clip(inner_clip)); |
| 890 | 1081 | } |
| 891 | 1082 | 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 | } | |
| 893 | 1086 | } |
| 894 | 1087 | if inner_clip != clip { |
| 895 | 1088 | self.display.push(Display::Clip(clip)); |
| 896 | 1089 | } |
| 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 | } | |
| 897 | 1096 | } |
| 898 | 1097 | |
| 899 | 1098 | /// This frame's painting in order, in logical pixels. |
| ... | ... | @@ -1067,6 +1266,8 @@ impl Built { |
| 1067 | 1266 | center: spec.center, |
| 1068 | 1267 | position: spec.position, |
| 1069 | 1268 | cursor: spec.cursor, |
| 1269 | anchor: spec.anchor, | |
| 1270 | alpha: 1.0 - spec.fade, | |
| 1070 | 1271 | marks: Vec::new(), |
| 1071 | 1272 | computed: [0.0; 2], |
| 1072 | 1273 | relative: [0.0; 2], |
| ... | ... | @@ -1080,17 +1281,11 @@ impl Built { |
| 1080 | 1281 | /// A tab's outline stays open along its bottom, so a border leaves the edge it stands on. |
| 1081 | 1282 | fn outline(shape: Shape, [width, height]: [f32; 2], radius: f32) -> String { |
| 1082 | 1283 | let corners = match shape { |
| 1083 | Shape::Tab { slant } => [ | |
| 1284 | Shape::Tab { lean } => [ | |
| 1084 | 1285 | ([0.0, height], 0.0), |
| 1085 | 1286 | ([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), | |
| 1094 | 1289 | ], |
| 1095 | 1290 | Shape::Rounded => [ |
| 1096 | 1291 | ([0.0, height], radius), |
| ... | ... | @@ -1174,6 +1369,10 @@ pub fn mix(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] { |
| 1174 | 1369 | std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t) |
| 1175 | 1370 | } |
| 1176 | 1371 | |
| 1372 | fn 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 | ||
| 1177 | 1376 | fn intersect(a: [f32; 4], b: [f32; 4]) -> Option<[f32; 4]> { |
| 1178 | 1377 | let rect = [ |
| 1179 | 1378 | 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 | ||
| 5 | use crate::{Axis, Event, Flags, Id, SLIDE, Size, Spec, Ui, fill, mix, px, scrollbar}; | |
| 6 | use std::collections::HashMap; | |
| 7 | use winit::keyboard::NamedKey; | |
| 8 | ||
| 9 | /// Room beside a scrolling list's rows for its scrollbar. | |
| 10 | pub(crate) const GUTTER: f32 = 12.0; | |
| 11 | ||
| 12 | /// Items a list shows, in order. | |
| 13 | pub 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. | |
| 32 | pub 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. | |
| 44 | pub 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)] | |
| 52 | pub(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)] | |
| 62 | struct 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. | |
| 74 | pub 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 | ||
| 5 | use 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 | }; | |
| 9 | use winit::keyboard::{Key, NamedKey}; | |
| 10 | ||
| 11 | const CHECK: &[&str] = &[include_str!("../assets/check.svg")]; | |
| 12 | /// Inset of a popup's contents, and its distance from what it drops down from. | |
| 13 | const PAD: f32 = 4.0; | |
| 14 | const ROW: f32 = 26.0; | |
| 15 | /// Rows a list shows before it scrolls. | |
| 16 | const ROWS: f32 = 12.0; | |
| 17 | /// Height of the rule between groups. | |
| 18 | const RULE: f32 = 9.0; | |
| 19 | const NARROWEST: f32 = 140.0; | |
| 20 | const PALETTE: f32 = 560.0; | |
| 21 | /// A colour grid's cell, around its swatch. | |
| 22 | const CELL: f32 = 22.0; | |
| 23 | /// The keys a menu's list takes, and Enter to choose. | |
| 24 | const 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)] | |
| 36 | pub 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. | |
| 53 | pub 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. | |
| 100 | pub 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 | ||
| 117 | fn 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. | |
| 224 | fn 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. | |
| 303 | pub 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. | |
| 416 | fn 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 | ||
| 446 | fn 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`. | |
| 454 | fn 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`. | |
| 477 | struct 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 | ||
| 488 | impl<'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 | ||
| 519 | impl 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. | |
| 543 | fn 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( |
| 41 | 41 | let placed: Vec<_> = tabs |
| 42 | 42 | .iter() |
| 43 | 43 | .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); | |
| 45 | 45 | left += width; |
| 46 | 46 | (left - width, width) |
| 47 | 47 | }) |
| ... | ... | @@ -75,7 +75,7 @@ pub fn section_tabs( |
| 75 | 75 | border: Some(fade(section.edge, open)), |
| 76 | 76 | shadow: (open > 0.0).then(|| fade([0.0, 0.0, 0.0, 0.35], open)), |
| 77 | 77 | radius: TAB_ROUNDING, |
| 78 | shape: Shape::Tab { slant: tall }, | |
| 78 | shape: Shape::Tab { lean: lean(height) }, | |
| 79 | 79 | pad: [10.0, 0.0], |
| 80 | 80 | ..Spec::default() |
| 81 | 81 | }, |
| ... | ... | @@ -89,10 +89,15 @@ pub fn section_tabs( |
| 89 | 89 | (clicked, open_tab) |
| 90 | 90 | } |
| 91 | 91 | |
| 92 | /// Where the tab laid out at `tab` meets the edge below it: from its leading edge to the | |
| 93 | /// foot of its slant. | |
| 94 | pub 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. | |
| 94 | pub 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. | |
| 99 | fn lean(height: f32) -> f32 { | |
| 100 | (height - 2.0) / 2.0 | |
| 96 | 101 | } |
| 97 | 102 | |
| 98 | 103 | /// 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() { |
| 524 | 524 | build(&mut ui, &mut offset); |
| 525 | 525 | assert_eq!(offset, 0.0); |
| 526 | 526 | } |
| 527 | ||
| 528 | fn 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 | ||
| 547 | fn 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. | |
| 553 | fn 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 | ||
| 570 | const 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`. | |
| 573 | fn 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. | |
| 584 | fn 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 | ||
| 589 | fn 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] | |
| 597 | fn 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] | |
| 624 | fn 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] | |
| 657 | fn 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] | |
| 669 | fn 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] | |
| 684 | fn 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] | |
| 698 | fn 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] | |
| 708 | fn 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] | |
| 730 | fn 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] | |
| 745 | fn 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] | |
| 778 | fn 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] | |
| 790 | fn 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] | |
| 816 | fn 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] | |
| 850 | fn 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. | |
| 885 | struct Keyed { | |
| 886 | keys: Vec<u64>, | |
| 887 | at: HashMap<u64, usize>, | |
| 888 | lookups: std::cell::Cell<usize>, | |
| 889 | } | |
| 890 | ||
| 891 | impl 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 | ||
| 907 | impl 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 | ||
| 923 | const LIST_ROW: f32 = 20.0; | |
| 924 | ||
| 925 | fn 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. | |
| 930 | fn 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 | ||
| 962 | fn 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. | |
| 967 | fn 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] | |
| 975 | fn 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] | |
| 991 | fn 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] | |
| 1022 | fn 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] | |
| 1060 | fn 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 { |
| 21 | 21 | pub paper_ink: [f32; 4], |
| 22 | 22 | /// Key caps, small controls and field borders. |
| 23 | 23 | pub chip: [f32; 4], |
| 24 | /// Menus and other popups, and the shadow they cast. | |
| 25 | pub popup: [f32; 4], | |
| 26 | pub shadow: [f32; 4], | |
| 24 | 27 | /// Saturation and lightness each of a section's colours takes from its hue. |
| 25 | 28 | pub shades: Shades, |
| 26 | 29 | } |
| ... | ... | @@ -62,6 +65,8 @@ impl Theme { |
| 62 | 65 | paper: srgb(0x1f, 0x20, 0x22), |
| 63 | 66 | paper_ink: srgb(0xe6, 0xe6, 0xe6), |
| 64 | 67 | chip: srgb(0x38, 0x3c, 0x3d), |
| 68 | popup: srgb(0x2b, 0x2e, 0x30), | |
| 69 | shadow: [0.0, 0.0, 0.0, 0.6], | |
| 65 | 70 | shades: Shades { |
| 66 | 71 | frame: [[0.30, 0.36], [0.30, 0.30]], |
| 67 | 72 | tab: [0.22, 0.25], |
| ... | ... | @@ -84,6 +89,8 @@ impl Theme { |
| 84 | 89 | paper: [1.0; 4], |
| 85 | 90 | paper_ink: [0.0, 0.0, 0.0, 1.0], |
| 86 | 91 | chip: srgb(0xcf, 0xd3, 0xd9), |
| 92 | popup: [1.0; 4], | |
| 93 | shadow: [0.0, 0.0, 0.0, 0.3], | |
| 87 | 94 | shades: Shades { |
| 88 | 95 | frame: [[0.55, 0.82], [0.55, 0.76]], |
| 89 | 96 | tab: [0.45, 0.70], |