From 93933f26a4f155a3654291ce96aa67bfcf3c69f3 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 3 Oct 2026 11:36:49 -0700 Subject: [PATCH] refactor: popups open beside a box by its id, placed where layout puts it that frame - `Anchor::Below`, `Right` and `Over` name a box; layout solves the interface first, then each popup beside its box as laid out in the same frame, so a popup opened with its box is never at the window's corner on its first frame - Layout keeps a popup below or over its box at least as wide, opens a submenu past the menu holding its row, and makes a field standing in for a combo as tall as it - `scroll_to` scrolls once the frame is laid out, so it reaches a box built that frame - A section tab's rename field is built inside the tab; the notebook button at the far edge, the locked-section and empty-notebook notices, the recording transport and the templates' close button are placed by layout - A menu button's menu, the conflict bar's menu and the Outlook task's tooltip open from boxes placed where they open - `Ui::rect` gives way to `Ui::laid_out`, the latest layout, for hosts painting the page, mapping its input, and hit-testing drags; `Ui::size` is the window's size this frame Assisted-by: claude-opus-5.5 --- crates/snowbound/src/conflict_render.rs | 6 +- crates/snowbound/src/live.rs | 11 +- crates/snowbound/src/main.rs | 151 +++++++---- crates/snowbound/src/menus.rs | 16 +- crates/snowbound/src/options.rs | 12 +- crates/snowbound/src/palette.rs | 4 +- crates/snowbound/src/pane.rs | 2 +- crates/snowbound/src/print.rs | 2 +- crates/snowbound/src/properties.rs | 2 +- crates/snowbound/src/protection.rs | 45 +++- crates/snowbound/src/recording.rs | 30 ++- crates/snowbound/src/rename.rs | 60 ++--- crates/snowbound/src/save_as.rs | 2 +- crates/snowbound/src/search.rs | 23 +- crates/snowbound/src/sidebar.rs | 86 ++++-- crates/snowbound/src/symbol.rs | 2 +- crates/snowbound/src/sync.rs | 23 +- crates/snowbound/src/tags.rs | 2 +- crates/snowbound/src/templates.rs | 14 +- crates/snowbound/src/themes.rs | 12 +- crates/snowbound/src/unpack.rs | 2 +- crates/ui/examples/popups.rs | 52 ++-- crates/ui/src/access/tests.rs | 13 +- crates/ui/src/layout.rs | 164 +++++++++--- crates/ui/src/lib.rs | 216 ++++++++------- crates/ui/src/list.rs | 2 +- crates/ui/src/popup.rs | 77 ++---- crates/ui/src/shell.rs | 35 ++- crates/ui/src/tests.rs | 334 ++++++++++++++---------- crates/ui/src/widgets.rs | 6 +- 30 files changed, 815 insertions(+), 591 deletions(-) diff --git a/crates/snowbound/src/conflict_render.rs b/crates/snowbound/src/conflict_render.rs index 021fb96cdda636fe2ff09bdb125bbdd58dda330f..f323baea357f69f8341c46692a3b9ab83735c36e 100644 --- a/crates/snowbound/src/conflict_render.rs +++ b/crates/snowbound/src/conflict_render.rs @@ -86,7 +86,7 @@ fn paint( queue: &wgpu::Queue, ) -> Vec { let size = SIZE.map(|side| (side * SCALE) as u32); - let corner = ui.rect(super::page()).unwrap(); + let corner = ui.laid_out(super::page()).unwrap(); let box_size = [ ((corner[2] - corner[0]) * SCALE) as u32, ((corner[3] - corner[1]) * SCALE) as u32, @@ -281,7 +281,7 @@ fn conflict_views_offer_the_versions_and_render_offscreen() { other => panic!("{other:?}", other = other.0), } let row = Id::ROOT.child("frame").child("panel").child(version); - let listed = ui.rect(row).is_some(); + let listed = ui.laid_out(row).is_some(); assert_eq!(listed, shown.is_some(), "{name}"); if let (Some(output), Some((renderer, device, queue))) = (&output, &mut gpu) { let shown = session.reader(space)().unwrap(); @@ -398,7 +398,7 @@ fn page_versions_list_open_read_only_and_render_offscreen() { } for context in &versions { let row = Id::ROOT.child("frame").child("panel").child(context); - assert_eq!(ui.rect(row).is_some(), shown.is_some(), "{name}"); + assert_eq!(ui.laid_out(row).is_some(), shown.is_some(), "{name}"); } if let (Some(output), Some((renderer, device, queue))) = (&output, &mut gpu) { let shown = match version { diff --git a/crates/snowbound/src/live.rs b/crates/snowbound/src/live.rs index d28ff3299df8b6e251a903cc21943ad802ecf596..d1d47f54b47970461d9aeb0e32ee9d6a59fccfb6 100644 --- a/crates/snowbound/src/live.rs +++ b/crates/snowbound/src/live.rs @@ -676,11 +676,8 @@ impl State { ..ui::popup::Item::default() }) .collect(); - let rect = self.ui.rect(self.ui.id("more")); - if let Some(rect) = rect - && let Some(index) = - ui::popup::menu(&mut self.ui, menu, ui::Anchor::Below(rect), &items, None) - { + let anchor = ui::Anchor::Below(self.ui.id("more")); + if let Some(index) = ui::popup::menu(&mut self.ui, menu, anchor, &items, None) { chosen = Some(peers[index].hello.peer); } } @@ -874,7 +871,7 @@ impl State { let Some(session) = &self.session else { return; }; - let Some([left, top, right, bottom]) = self.ui.rect(page()) else { + let Some([left, top, right, bottom]) = self.ui.laid_out(page()) else { return; }; let here = (section(session), Some(session.space.into())); @@ -998,7 +995,7 @@ impl State { [x0, y0, y1]: [f32; 3], selection: &[[f32; 4]], ) { - let Some([left, top, ..]) = self.ui.rect(page()) else { + let Some([left, top, ..]) = self.ui.laid_out(page()) else { return; }; let id = peer.hello.peer; diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 612cebd400d2ab5d93965b3967aa59428b974eff..49db60a8bb0f184217d3d46eab71658210c570ce 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1602,7 +1602,7 @@ impl State { |_| [0.0; 4], ); } - if let Some(rect) = self.ui.rect(page()) { + if let Some(rect) = self.ui.laid_out(page()) { let size = [ ((rect[2] - rect[0]) * scale).round() as u32, ((rect[3] - rect[1]) * scale).round() as u32, @@ -1712,7 +1712,7 @@ impl State { .ui .animate(tab_row(), if self.full_page { 0.0 } else { TAB_ROW }); let drags = self.chrome_drags(); - let tab_row = self.ui.open_as( + self.ui.open_as( tab_row(), Spec { flags: if drags { @@ -1757,24 +1757,41 @@ impl State { let unread = self.unread_keys(); let (clicked, open_tab) = match &self.session { Some(session) => { + let lit = self.page_drop(row); + let dragged = self.dragged_tab(row); + let shown = self.open_tab().unwrap_or(session.tab); // A tab being renamed takes the name typed, which its field covers. + let renaming = self.renaming.as_mut().and_then(|renaming| { + let tab = (session.tabs.iter()) + .position(|tab| renaming.entry(&session.library, &tab.path, true))?; + Some((tab, &mut renaming.name)) + }); + let typed = renaming + .as_ref() + .map(|(tab, name)| (*tab, name.to_string())); let tabs: Vec<_> = session .tabs .iter() - .map(|tab| { - let name = match &self.renaming { - Some(renaming) if renaming.entry(&session.library, &tab.path, true) => { - renaming.name.as_str() - } + .enumerate() + .map(|(index, tab)| { + let name = match &typed { + Some((renamed, name)) if *renamed == index => name.as_str(), _ => tab.name.as_str(), }; let unread = unread.contains(&session.library.key(&tab.path)); (name, section_color(tab.color), unread) }) .collect(); - let lit = self.page_drop(row); - let dragged = self.dragged_tab(row); - let shown = self.open_tab().unwrap_or(session.tab); + let mut kept = None; + let mut field; + let renaming = match renaming { + Some((tab, name)) => { + field = + |ui: &mut Ui, tall| kept = rename::tab_field(ui, &theme, name, tall); + Some((tab, &mut field as &mut dyn FnMut(&mut Ui, f32))) + } + None => None, + }; let ui::shell::Tabs { clicked, context, @@ -1790,10 +1807,14 @@ impl State { shown, lit, dragged, + renaming, §ion, TAB_ROW, theme.strip, ); + if let Some(keep) = kept { + self.finish_renaming(keep); + } name(&mut self.ui, row, "Sections"); self.drag_tabs(held, slot, settled, row); let Some(session) = &self.session else { @@ -1825,7 +1846,6 @@ impl State { }; self.rename(target); } - self.tab_rename_field(&theme, row, tab_row); (clicked, open_tab) } None if self.locked.is_some() => self.locked_tabs(row, §ion, theme.strip), @@ -1839,6 +1859,7 @@ impl State { 0, None, None, + None, §ion, TAB_ROW, theme.strip, @@ -1963,9 +1984,7 @@ impl State { if !opening { self.template_strip(&theme); } - if let Some(rect) = self.ui.rect(page()) { - self.transport(&theme, rect)?; - } + self.transport(&theme)?; let scroll = self.view.scroll(); for (index, axis) in [Axis::X, Axis::Y] .into_iter() @@ -1990,20 +2009,24 @@ impl State { if !opening { self.peer_carets(); } - self.ui.close(); - if let Some(task) = self.view.task_under_pointer() { - let [left, top, ..] = self.ui.rect(page()).unwrap_or_default(); + let task = self.view.task_under_pointer().map(|[x0, y0, x1, y1]| { let scale = self.ui.scale(); - let [x0, y0, x1, y1] = task; - let part = [ - left + x0 / scale, - top + y0 / scale, - left + x1 / scale, - top + y1 / scale, - ]; + self.ui.leaf( + "task", + Spec { + flags: Flags::FLOAT, + size: [px((x1 - x0) / scale), px((y1 - y0) / scale)], + position: [x0 / scale, y0 / scale], + ..Spec::default() + }, + ); + page().child("task") + }); + self.ui.close(); + if let Some(task) = task { ui::popup::tooltip_over( &mut self.ui, - part, + task, "Outlook task", Some("Edit it in OneNote with Outlook"), ); @@ -2046,8 +2069,9 @@ impl State { /// Borders the open section tab and the frame's top, and rounds and borders the page /// together with the open page's tab, joined where they meet. fn edges(&mut self, section: ui::Section, open_tab: Id, open_page: Option) { - let panel = self.ui.rect(frame().child("panel")); - let (Some(frame), Some(page)) = (self.ui.rect(frame()), self.ui.rect(page())) else { + let panel = self.ui.laid_out(frame().child("panel")); + let (Some(frame), Some(page)) = (self.ui.laid_out(frame()), self.ui.laid_out(page())) + else { return; }; let rounding = self.rounding(); @@ -2056,7 +2080,7 @@ impl State { let [left, top, right, bottom] = page; let left_tabs = self.page_tabs_left; let tab = open_page - .and_then(|id| self.ui.rect(id)) + .and_then(|id| self.ui.laid_out(id)) .map(|row| [row[0], row[1], row[2], row[3] - ROW_GAP]) .zip(panel) .and_then(|(row, panel)| { @@ -2129,7 +2153,7 @@ impl State { // the open tab stands on it. let [start, end] = [frame[0], frame[2]]; let outer = platform::corner_radius(&self.window); - let window = self.ui.rect(Id::ROOT).map_or(end, |root| root[2]); + let window = self.ui.size()[0]; let [beside, beside_right] = [start, window - end].map(|gap| (outer * gap / sidebar::WIDTH).min(outer)); let strip = self.ui.theme.strip; @@ -2153,11 +2177,11 @@ impl State { // The border breaks where the open tab shows, which the row may scroll it out of. let [left, right] = self .ui - .rect(sections()) + .laid_out(sections()) .map_or([end; 2], |row| [row[0], row[2]]); let [foot, toe] = self .ui - .rect(open_tab) + .laid_out(open_tab) .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW)) .map(|x| x.clamp(left, right)); // The border runs down both sides to the window's bottom, round the bottom corner @@ -2398,7 +2422,7 @@ impl State { disabled: !enabled, ..Default::default() })); - let anchor = ui::Anchor::Below(ui.rect(ui.id("menu")).unwrap_or_default()); + let anchor = ui::Anchor::Below(ui.id("menu")); if ui::popup::menu(ui, toolbar_popup("paste"), anchor, &items, None).is_some() { choice = Some(Choice::Command(Cmd::Paste)); } @@ -2422,7 +2446,7 @@ impl State { ui::shell::menu_button(ui, "styles", art::STYLES, None, styles) } else { ui::shell::unavailable(ui, "styles", art::STYLES, text, true); - ui::Anchor::Below(ui.rect(ui.id("styles")).unwrap_or_default()) + ui::Anchor::Below(ui.id("styles")) }; let shown = notebook::sidecar::themes::STYLES .iter() @@ -2485,7 +2509,7 @@ impl State { ..Default::default() }) .collect(); - let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); + let anchor = ui::Anchor::Over(combo); if let Some(index) = ui::popup::menu(ui, toolbar_popup("font"), anchor, &items, Some("Font")) && let Some(name) = choices[index].1 @@ -2534,7 +2558,7 @@ impl State { ..Default::default() }); } - let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); + let anchor = ui::Anchor::Over(combo); if let Some(index) = ui::popup::menu(ui, toolbar_popup("size"), anchor, &items, Some(&size)) { choice = Some(Choice::Size(sizes[index])); @@ -2601,7 +2625,7 @@ impl State { .iter() .map(|&(color, name)| (colorref(color), name)) .collect(); - let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default()); + let anchor = ui::Anchor::Below(split); if let Some(chosen) = ui::popup::colors(ui, toolbar_popup(part), anchor, none, &colors, columns) { @@ -2681,8 +2705,7 @@ impl State { } tip(ui, id); } - let [bullets, numbering] = - ["bullets", "numbering"].map(|part| ui.rect(ui.id(part)).unwrap_or_default()); + let [bullets, numbering] = ["bullets", "numbering"].map(|part| ui.id(part)); let current = |bullet| match state.list { Some(ListStyle::Bullet(place)) if bullet => Some(place), Some(ListStyle::Number(place)) if !bullet => Some(place), @@ -2982,7 +3005,7 @@ impl State { choice = Some(Choice::Command(Cmd::Pen)); } tip(ui, Cmd::Pen); - let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default()); + let anchor = ui::Anchor::Below(split); if let Some(place) = pen_gallery(ui, anchor, &drawing_pens, pens.pen) { choice = Some(Choice::Pen(place)); } @@ -3367,7 +3390,7 @@ impl State { } let read_only = self.session.as_ref().is_some_and(Session::read_only); let scale = self.ui.scale(); - let corner = self.ui.rect(page()).unwrap_or_default(); + let corner = self.ui.laid_out(page()).unwrap_or_default(); let device = |point: [f32; 2]| { [ (point[0] - corner[0]) * scale, @@ -3934,7 +3957,7 @@ impl State { /// Follows the view moving: the input method's position and accessibility. fn after_move(&mut self) -> Result<(), Box> { let scale = self.ui.scale(); - let corner = self.ui.rect(page()).unwrap_or_default(); + let corner = self.ui.laid_out(page()).unwrap_or_default(); let [x0, y0, x1, y1] = self.view.caret_area()?; self.window.set_ime_cursor_area( LogicalPosition::new(x0 / scale + corner[0], y0 / scale + corner[1]), @@ -4292,7 +4315,7 @@ impl State { /// `page` asks or its tree is not grafted yet. The interface's tree goes first, since it /// holds the page's. fn update_accessibility(&mut self, page: bool) -> Result<(), Box> { - let built = self.ui.rect(self::page()).is_some(); + let built = self.ui.laid_out(self::page()).is_some(); if !built && self.page_grafted { // Its graft is gone, and with it the tree, which must be sent whole again. self.accessibility.deactivate(); @@ -4396,12 +4419,12 @@ impl State { .accessibility_tree(&self.window.title(), self.window.scale_factor()); let focus = std::mem::replace(&mut chrome.focus, ui::Id::ROOT.node()); let mut tree = Tree::new(chrome, true); - if self.ui.rect(page()).is_some() { + if self.ui.laid_out(page()).is_some() { let mut fresh = accessibility::Accessibility::default(); let page = page_tree(&self.view, &self.ui, &self.window, &mut fresh)?; tree.update_and_process_changes(page, &mut Unwatched); } - if focus != graft || self.ui.rect(page()).is_some() { + if focus != graft || self.ui.laid_out(page()).is_some() { let update = accesskit::TreeUpdate { nodes: Vec::new(), tree: None, @@ -4567,7 +4590,7 @@ impl State { } let theme = &self.ui.theme; let scale = self.ui.scale(); - let corner = self.ui.rect(page()).unwrap_or_default(); + let corner = self.ui.laid_out(page()).unwrap_or_default(); // A page taking a while to open shows its outline, drawn in points from the corner. let (page_primitives, viewport) = match self.loading() { Some(since) => ( @@ -4640,14 +4663,14 @@ impl State { } fn over_page(&self) -> bool { - let [x0, y0, x1, y1] = self.ui.rect(page()).unwrap_or_default(); + let [x0, y0, x1, y1] = self.ui.laid_out(page()).unwrap_or_default(); let [x, y] = self.pointer; (x0..x1).contains(&x) && (y0..y1).contains(&y) } /// Window point `point` in the page's device pixels. fn page_point(&self, point: [f32; 2]) -> [f32; 2] { - let [left, top, ..] = self.ui.rect(page()).unwrap_or_default(); + let [left, top, ..] = self.ui.laid_out(page()).unwrap_or_default(); let scale = self.ui.scale(); [(point[0] - left) * scale, (point[1] - top) * scale] } @@ -4777,7 +4800,7 @@ fn page_tree( window: &Window, access: &mut accessibility::Accessibility, ) -> Result { - let corner = ui.rect(page()).unwrap_or_default(); + let corner = ui.laid_out(page()).unwrap_or_default(); let scale = ui.scale(); let ratio = window.scale_factor() as f32 / scale; let viewport = canvas::gpu::Viewport { @@ -4868,8 +4891,8 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O ..Spec::default() }, ); - for (part, text) in [("said", said), ("action", action)] { - ui.leaf( + let line = |ui: &mut Ui, part: &str, text| { + ui.open( part, Spec { size: [fill(), fit()], @@ -4879,16 +4902,28 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O ..Spec::default() }, ); - } - let action = ui.id("action"); + }; + line(ui, "said", said); + ui.close(); + line(ui, "action", action); + // The menu opens under the line the click asks for, from its leading edge, as wide as + // its items. + let from = ui.id("menu"); + ui.leaf( + "menu", + Spec { + flags: Flags::FLOAT, + size: [px(0.0), fill()], + ..Spec::default() + }, + ); + ui.close(); ui.close(); ui.close(); let clicked = ui.signal(row).clicked; let menu = Id::ROOT.child("conflict-menu"); let copy = Id::ROOT.child("conflict-copy"); - // The menu opens under the line the click asks for, as wide as its items. - let [left, top, _, bottom] = ui.rect(action).unwrap_or_default(); - let anchor = ui::Anchor::Below([left, top, left, bottom]); + let anchor = ui::Anchor::Below(from); match bar { Bar::Page { page, shown } => clicked.then_some(Command::Versions { page, show: !shown }), Bar::History { @@ -5568,7 +5603,7 @@ fn dropdown( choice = Some(Choice::Command(id)); } tip(ui, id); - ui::Anchor::Below(ui.rect(button).unwrap_or_default()) + ui::Anchor::Below(button) } Head::Tag(place, tag) if status_of(commands::Id::Tag(place)).enabled => { let on = status_of(commands::Id::Tag(place)).checked; @@ -5577,7 +5612,7 @@ fn dropdown( choice = Some(Choice::Command(commands::Id::Tag(place))); } tag_tip(ui, place, tag); - ui::Anchor::Below(ui.rect(button).unwrap_or_default()) + ui::Anchor::Below(button) } // Where its command does not apply, the button only opens the menu. Head::Split(id) => { @@ -5596,7 +5631,7 @@ fn dropdown( let tint = ui.theme.text; ui::shell::unavailable(ui, part, icon, tint, true); name(ui, button, label); - ui::Anchor::Below(ui.rect(button).unwrap_or_default()) + ui::Anchor::Below(button) } Head::Menu(label, icon) => { let anchor = ui::shell::menu_button(ui, part, icon, None, menu); diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index fec00314167bb717640e66eb8a80514491bd9ea6..d100d1b7e8a07b407fcaee17bf923d5c66fa5a8e 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -406,7 +406,7 @@ impl State { shown && self .ui - .rect(crate::sections()) + .laid_out(crate::sections()) .is_some_and(|[left, top, right, bottom]| { x >= left && x < right && y >= top && y < bottom }) @@ -1108,7 +1108,7 @@ impl State { (0..session.tabs.len()) .filter(|tab| *tab != session.tab) .find(|tab| { - self.ui.rect(ui::shell::tab_id(row, *tab)).is_some_and( + self.ui.laid_out(ui::shell::tab_id(row, *tab)).is_some_and( |[left, top, right, bottom]| x >= left && x < right && y >= top && y < bottom, ) }) @@ -1120,7 +1120,7 @@ impl State { let Dragged::Page(space) = drag.what else { return None; }; - let origin = self.ui.rect(self.ui.id(crate::page_list_top()))?[1]; + let origin = self.ui.laid_out(self.ui.id(crate::page_list_top()))?[1]; Some(crate::PageDrag { space, top: drag.live().then(|| drag.corner(self.pointer)[1] - origin), @@ -1142,10 +1142,10 @@ impl State { ) { let mine = |what: &Dragged| matches!(what, Dragged::Page(_)); self.settle(mine, settled); - let held = - held.and_then(|space| Some((Dragged::Page(space), self.ui.rect(self.ui.id(space))?))); + let held = held + .and_then(|space| Some((Dragged::Page(space), self.ui.laid_out(self.ui.id(space))?))); let target = self.page_drop(row); - let origin = self.ui.rect(self.ui.id(crate::page_list_top())); + let origin = self.ui.laid_out(self.ui.id(crate::page_list_top())); let dropped = self.follow_drag(held, mine); let ( Some(session), @@ -1217,7 +1217,7 @@ impl State { let Dragged::Tab(index) = drag.what else { return None; }; - let start = drag.corner(self.pointer)[0] - self.ui.rect(row)?[0]; + let start = drag.corner(self.pointer)[0] - self.ui.laid_out(row)?[0]; Some(ui::shell::Dragged { index, start: drag.live().then_some(start), @@ -1239,7 +1239,7 @@ impl State { let held = held.and_then(|tab| { Some(( Dragged::Tab(tab), - self.ui.rect(ui::shell::tab_id(row, tab))?, + self.ui.laid_out(ui::shell::tab_id(row, tab))?, )) }); let dropped = self.follow_drag(held, mine); diff --git a/crates/snowbound/src/options.rs b/crates/snowbound/src/options.rs index f2f05cea7f275ebbc9f547a2b5b2b5e887d2f3fe..7ce4c327ca01d80b49b13694815ddbad4d76c7f8 100644 --- a/crates/snowbound/src/options.rs +++ b/crates/snowbound/src/options.rs @@ -521,8 +521,8 @@ impl State { // The section in the top quarter of the list, or the last once the list is scrolled to // its end. let current = options.picked.or_else(|| { - let [_, top, _, bottom] = ui.rect(list())?; - let starts = |id| ui.rect(id).map(|rect: [f32; 4]| rect[1]); + let [_, top, _, bottom] = ui.laid_out(list())?; + let starts = |id| ui.laid_out(id).map(|rect: [f32; 4]| rect[1]); let end = starts(list().child("top")).is_some_and(|start| start < top - 1.0) && starts(list().child("end")).is_some_and(|end| end <= bottom + 1.0); sections @@ -886,7 +886,7 @@ fn dropdown(ui: &mut Ui, name: &str, choices: &[&str], chosen: usize, width: f32 ..Item::default() }) .collect(); - let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + let anchor = Anchor::Below(combo); ui::popup::menu(ui, menu, anchor, &items, None) } @@ -968,7 +968,7 @@ fn renderer(state: &mut State, options: &mut Options, name: &str) { ..Item::default() }) .collect(); - let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + let anchor = Anchor::Below(combo); if let Some(index) = ui::popup::menu(ui, menu, anchor, &items, None) { let picked = Backend::PLATFORM[index]; options.renderer = if Some(picked) == automatic { @@ -1044,7 +1044,7 @@ fn font_face(state: &mut State, options: &mut Options, name: &str) { ..Item::default() }) .collect(); - let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); + let anchor = Anchor::Over(combo); if let Some(index) = ui::popup::menu(ui, self::fonts(), anchor, &items, Some(name)) { font.face = fonts[index].clone(); } @@ -1083,7 +1083,7 @@ fn font_color(state: &mut State, options: &mut Options, name: &str) { .iter() .map(|&(color, name)| (canvas::gpu::colorref(color), name)) .collect(); - let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + let anchor = Anchor::Below(combo); if let Some(chosen) = ui::popup::colors(ui, font_colors(), anchor, "Automatic", &swatches, 10) { font.color = chosen.and_then(|chosen| { let at = swatches.iter().position(|(swatch, _)| *swatch == chosen)?; diff --git a/crates/snowbound/src/palette.rs b/crates/snowbound/src/palette.rs index b69cdd93b722114924fd42911763c241c999428b..39778bf2636f3a90467f6bfde196da841dbeaced 100644 --- a/crates/snowbound/src/palette.rs +++ b/crates/snowbound/src/palette.rs @@ -224,7 +224,7 @@ impl State { ui::popup::submenus(&mut self.ui, id(), &items[0], |index| { commands[index].submenu.then(|| submenu(index)) }); - let anchor = Anchor::Right(self.ui.rect(id()).unwrap_or_default()); + let anchor = Anchor::Right(id()); for (index, row) in commands.iter().enumerate() { if let (Some(target), Some(action)) = (&row.target, &row.action) && let Some(chosen) = self.submenu(submenu(index), action, target, anchor) @@ -307,7 +307,7 @@ impl State { (action, Item { separated, ..item }) }, )); - let anchor = Anchor::Right(self.ui.rect(id()).unwrap_or_default()); + let anchor = Anchor::Right(id()); if let Some(action) = self.action_menu(panel, anchor, Some("Search actions"), &target, &actions) { diff --git a/crates/snowbound/src/pane.rs b/crates/snowbound/src/pane.rs index 1bedb5be3165617fb2324d084337549ffb0eaa46..17fe3c2fb6268db752c192f5e4e8d27852d9756b 100644 --- a/crates/snowbound/src/pane.rs +++ b/crates/snowbound/src/pane.rs @@ -360,7 +360,7 @@ fn choice( ..Default::default() }) .collect(); - let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + let anchor = Anchor::Below(combo); ui::popup::menu(ui, popup(name), anchor, &items, None) } diff --git a/crates/snowbound/src/print.rs b/crates/snowbound/src/print.rs index 7e4d5a5cd576fa7adfefa3d8448345d2b764d42b..0043bac4e5087c824470e92194ba756950bcfc7f 100644 --- a/crates/snowbound/src/print.rs +++ b/crates/snowbound/src/print.rs @@ -99,7 +99,7 @@ fn choose(ui: &mut Ui, name: &str, names: &[&str], current: usize) -> Option Result<(), Box> { + pub(crate) fn transport(&mut self, theme: &Theme) -> Result<(), Box> { let row = theme.font_size * 2.0; if matches!(self.media, Media::Saving { .. }) { self.stop_recording(false)?; @@ -354,13 +350,30 @@ impl State { .as_ref() .map_or(0.0, |image| image.size()[1] as f32 + 6.0); let height = row + 16.0 + picture_height; + // In the page's lower leading corner. + self.ui.open( + "corner", + Spec { + flags: Flags::FLOAT, + axis: Axis::Y, + size: [fill(), fill()], + pad: [16.0, 16.0], + ..Spec::default() + }, + ); + self.ui.leaf( + "room", + Spec { + size: [px(0.0), fill()], + ..Spec::default() + }, + ); self.ui.open( "transport", Spec { - flags: Flags::FLOAT | Flags::CLICKABLE, + flags: Flags::CLICKABLE, axis: Axis::Y, size: [children(), px(height)], - position: [16.0, page[3] - page[1] - height - 16.0], fill: Some(theme.popup), shadow: Some(theme.shadow), radius: 8.0, @@ -435,6 +448,7 @@ impl State { } self.ui.close(); self.ui.close(); + self.ui.close(); if let Some(at) = seek && let Media::Playing(playback) = &mut self.media { diff --git a/crates/snowbound/src/rename.rs b/crates/snowbound/src/rename.rs index 6bf1bf1ca7aa818f6ca9a38dcf6e0514ecdd8060..989500cb2d51a0c06b85a7802589aa1e8c935435 100644 --- a/crates/snowbound/src/rename.rs +++ b/crates/snowbound/src/rename.rs @@ -54,6 +54,23 @@ pub fn field() -> Id { Id::ROOT.child("rename") } +/// The rename field over the label of a section tab `tall`, editing `name`, as `edit`. +pub fn tab_field(ui: &mut Ui, theme: &Theme, name: &mut String, tall: f32) -> Option { + let width = ui.measure(name)[0] + 4.0 + 2.0 * PAD; + ui.open( + "rename", + Spec { + flags: ui::Flags::FLOAT, + size: [px(width.max(48.0)), px(tall - 6.0)], + position: [ui::shell::TAB_PAD - PAD, 3.0], + ..Spec::default() + }, + ); + let kept = edit(ui, theme, name, tall - 6.0); + ui.close(); + kept +} + /// The rename field, `height` tall, editing `name`: whether Enter kept or Escape dropped /// the name typed. pub fn edit(ui: &mut Ui, theme: &Theme, name: &mut String, height: f32) -> Option { @@ -146,49 +163,6 @@ impl State { } } - /// The rename field over the section tab being renamed, of those built as `row` in the - /// tab row `bar`, where the tab's label stands. - pub(crate) fn tab_rename_field(&mut self, theme: &Theme, row: Id, bar: Id) { - let (Some(session), Some(renaming)) = (&self.session, &mut self.renaming) else { - return; - }; - let Some(tab) = session - .tabs - .iter() - .position(|tab| renaming.entry(&session.library, &tab.path, true)) - else { - return; - }; - let (Some(rect), Some(bar)) = - (self.ui.rect(ui::shell::tab_id(row, tab)), self.ui.rect(bar)) - else { - return; - }; - let width = self.ui.measure(&renaming.name)[0] + 4.0 + 2.0 * PAD; - self.ui.open( - "rename", - Spec { - flags: ui::Flags::FLOAT, - size: [px(width.max(48.0)), px(rect[3] - rect[1] - 6.0)], - position: [ - rect[0] + ui::shell::TAB_PAD - PAD - bar[0], - rect[1] + 3.0 - bar[1], - ], - ..Spec::default() - }, - ); - let kept = edit( - &mut self.ui, - theme, - &mut renaming.name, - rect[3] - rect[1] - 6.0, - ); - self.ui.close(); - if let Some(keep) = kept { - self.finish_renaming(keep); - } - } - /// Gives page `space` of the open section the title `name`. fn retitle(&mut self, space: ExGuid, name: String) -> Result<(), Box> { self.persist()?; diff --git a/crates/snowbound/src/save_as.rs b/crates/snowbound/src/save_as.rs index 38b496f901dd0a8c3acb992e60d98d35275af865..0ca6dd3922abe0887057a309284263085738e5f9 100644 --- a/crates/snowbound/src/save_as.rs +++ b/crates/snowbound/src/save_as.rs @@ -265,7 +265,7 @@ fn choose(ui: &mut ui::Ui, label: &str, names: &[&str], current: usize) -> Optio ..Item::default() }) .collect(); - let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + let anchor = Anchor::Below(combo); let chosen = ui::popup::menu(ui, menu, anchor, &items, None); ui.close(); chosen diff --git a/crates/snowbound/src/search.rs b/crates/snowbound/src/search.rs index fe34337b60d4e7039974e9782815461e04b62ead..d8c1bd5ebde5b1e8a098860d05240113b019c078 100644 --- a/crates/snowbound/src/search.rs +++ b/crates/snowbound/src/search.rs @@ -780,8 +780,7 @@ impl State { self.ui.close(); // Results opened this frame build from the next, as the box's field was built. if searching || !self.ui.popup_open(results()) { - let rect = self.ui.rect(box_id).unwrap_or_default(); - self.results_popup(theme, rect)?; + self.results_popup(theme, box_id)?; } self.scope_popup(); Ok(()) @@ -791,8 +790,7 @@ impl State { let button = self.ui.id("scope"); if ui::shell::tool_button(&mut self.ui, "scope", art::SEARCH, theme.text_dim, None).pressed { - let rect = self.ui.rect(button).unwrap_or_default(); - self.search.anchor = Some(Anchor::Below(rect)); + self.search.anchor = Some(Anchor::Below(button)); self.ui.open_popup(scope_menu()); } let open = self.ui.popup_open(scope_menu()); @@ -856,7 +854,7 @@ impl State { } /// The dropdown of results, over the box it drops from. - fn results_popup(&mut self, theme: &Theme, rect: [f32; 4]) -> Result<(), Box> { + fn results_popup(&mut self, theme: &Theme, over: Id) -> Result<(), Box> { if !self.ui.popup_open(results()) { // Closed by Esc or a press elsewhere: the search ends. if std::mem::take(&mut self.search.open) { @@ -879,21 +877,18 @@ impl State { if self.ui.focused() == Some(results()) { self.ui.set_focus(Some(field())); } - let [left, top, right, bottom] = rect; - let anchor = Anchor::Over([left - 4.0, top - 4.0, right + 4.0, bottom + 4.0]); - let width = RESULTS.max(right - left); self.ui.open_as( results(), Spec { axis: Axis::Y, - size: [px(width), children()], + size: [px(RESULTS), children()], fill: Some(theme.popup), border: Some(theme.chip), shadow: Some(theme.shadow), radius: 6.0, pad: [4.0, 4.0], gap: 4.0, - anchor: Some(anchor), + anchor: Some(Anchor::Over(over)), role: Some(Role::Dialog), ..Spec::default() }, @@ -985,8 +980,7 @@ impl State { ) .clicked { - let rect = self.ui.rect(change).unwrap_or_default(); - self.search.anchor = Some(Anchor::Below(rect)); + self.search.anchor = Some(Anchor::Below(change)); self.ui.open_popup(scope_menu()); } self.ui.leaf( @@ -1016,8 +1010,9 @@ impl State { count: found.len(), }; let content = found.len() as f32 * RESULT + ui::Rows::space_before(&rows, found.len()); - let window = self.ui.rect(Id::ROOT).map_or(600.0, |window| window[3]); - let view = content.min((window - bottom - 80.0).max(RESULT)); + // Down to the window's foot from the box, as last laid out. + let bottom = self.ui.laid_out(over).map_or(0.0, |rect| rect[3]); + let view = content.min((self.ui.size()[1] - bottom - 80.0).max(RESULT)); let before = self.search.selected; let places: Vec = found .iter() diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs index 0a6cd81520af16766cf5eb5f22127f4cbc4b86ae..453837a65b9b3a070c518cf0dd21d46d3e0bc2fb 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -298,7 +298,7 @@ fn sidebar( } if row.dragging && !renamed - && let Some(rect) = ui.rect(id) + && let Some(rect) = ui.laid_out(id) { tree.held_notebook = Some(Entry { notebook: index, @@ -316,7 +316,7 @@ fn sidebar( ui.close(); } ui.close(); - tree.blocks.extend(ui.rect(block)); + tree.blocks.extend(ui.laid_out(block)); } if let Some((_, slot)) = tree.lifted_notebook { opening(ui, tree, ("notebook gap", ""), slot == place); @@ -632,7 +632,7 @@ fn opening(ui: &mut Ui, tree: &mut Tree, part: impl std::hash::Hash + Copy, here let id = ui.id(part); let height = ui.animate(id, if here { ROW } else { 0.0 }); if here { - tree.gap = ui.rect(id); + tree.gap = ui.laid_out(id); } if height > 0.0 { ui.leaf( @@ -657,7 +657,7 @@ fn unfolding(ui: &mut Ui, part: impl std::hash::Hash, unfolded: bool) -> bool { ui::children() } else { let rows = ui - .rect(outer.child("rows")) + .laid_out(outer.child("rows")) .map_or(0.0, |rect| rect[3] - rect[1]); let height = ui.animate(outer, if unfolded { rows } else { 0.0 }); if !unfolded && height == 0.0 { @@ -687,7 +687,7 @@ fn unfolding(ui: &mut Ui, part: impl std::hash::Hash, unfolded: bool) -> bool { /// Records a section or group row, built as `id`, for dragging. fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, id: Id, group: bool, row: &Signal) { - let Some(rect) = ui.rect(id) else { + let Some(rect) = ui.laid_out(id) else { return; }; let entry = Entry { @@ -929,7 +929,7 @@ impl crate::State { gap, .. } = tree; - let corner = self.ui.rect(rows_id).unwrap_or_default(); + let corner = self.ui.laid_out(rows_id).unwrap_or_default(); let found = ghost.is_some(); let (ghost_held, settled) = self.lifted_row(theme, ghost, gap, &rows, corner); let settled = settled || !found; @@ -987,17 +987,9 @@ impl crate::State { if self.temporary { return; } - let (nav, toggle) = if self.navigation_bar_right { - // The notebook button stands at the body's far edge, as laid out last frame. - let edge = self - .ui - .rect(self.ui.current()) - .map_or(0.0, |[left, _, right, _]| right - left - RAIL); - (0.0, edge) - } else { - let toggle = (MARGIN + ROW_PAD - (RAIL - ICON) / 2.0) * width / WIDTH; - (toggle + RAIL, toggle) - }; + let far = self.navigation_bar_right; + let toggle = (MARGIN + ROW_PAD - (RAIL - ICON) / 2.0) * width / WIDTH; + let nav = if far { 0.0 } else { toggle + RAIL }; let pad = [ (RAIL - ui::shell::TOOL) / 2.0, (RAIL - ui::shell::TOOL) / 2.0 + DROP, @@ -1021,10 +1013,32 @@ impl crate::State { } } self.ui.close(); + // The notebook button stands at the body's far edge, after the room before it. + if far { + self.ui.open( + "far edge", + Spec { + flags: Flags::FLOAT, + size: [fill(), px(height)], + ..Spec::default() + }, + ); + self.ui.leaf( + "room", + Spec { + size: [fill(), px(height)], + ..Spec::default() + }, + ); + } self.ui.open( "toggle", Spec { - flags: Flags::FLOAT | Flags::CLIP, + flags: if far { + Flags::CLIP + } else { + Flags::FLOAT | Flags::CLIP + }, size: [px(RAIL), px(height)], position: [toggle, 0.0], pad, @@ -1043,6 +1057,9 @@ impl crate::State { } crate::tip(&mut self.ui, Cmd::Sidebar); self.ui.close(); + if far { + self.ui.close(); + } } /// The dragged row, `ghost`, drawn over the others in the rows' box `corner`: following @@ -1499,25 +1516,43 @@ impl crate::State { const BUTTON: [f32; 2] = [240.0, 32.0]; let theme = self.page_area_theme(); let chrome = std::mem::replace(&mut self.ui.theme, theme.clone()); - let [left, top, right, bottom] = self.ui.rect(id).unwrap_or_default(); self.ui.open_as( id, Spec { + axis: Axis::Y, size: [fill(), fill()], fill: Some(theme.paper), ..Spec::default() }, ); + // Centred across, with two fifths of the room to spare above it and the rest below. + let room = |ui: &mut Ui, part: &str, size| { + ui.leaf( + part, + Spec { + size, + ..Spec::default() + }, + ); + }; + let share = |fraction| ui::Extent { + size: ui::Size::Fraction(fraction), + strictness: 0.0, + }; + room(&mut self.ui, "above", [fill(), share(0.4)]); + self.ui.open( + "across", + Spec { + size: [fill(), ui::children()], + ..Spec::default() + }, + ); + room(&mut self.ui, "before", [fill(), px(0.0)]); self.ui.open( "content", Spec { - flags: Flags::FLOAT, axis: Axis::Y, size: [px(BUTTON[0]), ui::children()], - position: [ - ((right - left - BUTTON[0]) / 2.0).max(0.0), - ((bottom - top) * 0.4 - 60.0).max(0.0), - ], gap: 10.0, ..Spec::default() }, @@ -1558,6 +1593,9 @@ impl crate::State { self.commands.extend(chosen); let saved = crate::server::saved_servers(&mut self.ui, servers); self.ui.close(); + room(&mut self.ui, "after", [fill(), px(0.0)]); + self.ui.close(); + room(&mut self.ui, "below", [fill(), share(0.6)]); self.ui.close(); self.ui.theme = chrome; if let Some(saved) = saved { diff --git a/crates/snowbound/src/symbol.rs b/crates/snowbound/src/symbol.rs index 279191c22e4acbba781933628f8fcdc4dca17122..c136d4f6cae5d684278eb39d770378a48c7af3f0 100644 --- a/crates/snowbound/src/symbol.rs +++ b/crates/snowbound/src/symbol.rs @@ -547,7 +547,7 @@ impl State { ui.close(); ui.close(); - let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + let anchor = Anchor::Below(combo); if ui.popup_open(subsets()) { // Each subset that shows a character, and its first. let all = characters(ui); diff --git a/crates/snowbound/src/sync.rs b/crates/snowbound/src/sync.rs index dd5777b72d980a3d1f1329859aba05a5d7daff25..df25f9c7a7b93f2c17023d36affa0150117aece4 100644 --- a/crates/snowbound/src/sync.rs +++ b/crates/snowbound/src/sync.rs @@ -290,7 +290,7 @@ fn changes_list(ui: &mut Ui, changes: &update::Changes, listed: bool) { // Unfolds to the titles' height, scrolling past a few dozen lines. let list = ui.id("changes"); let rows = ui - .rect(list.child("rows")) + .laid_out(list.child("rows")) .map_or(0.0, |rect| rect[3] - rect[1]); let height = ui.animate(list, if listed { rows.min(line * 9.0) } else { 0.0 }); if listed || height > 0.0 { @@ -533,7 +533,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { let state = sync.state(); let syncing = !facts.offline && state == SyncState::Syncing; let bar = id().child("status").child("bar"); - let shown = ui.rect(bar).is_some() && state == SyncState::UpToDate; + let shown = ui.laid_out(bar).is_some() && state == SyncState::UpToDate; let showing = !facts.offline && shows_sync(ui, button(), syncing, shown); let (mut headline, mut icon) = describe(&sync, facts.offline, !showing); let host = facts.place.first().map_or("the notebook", String::as_str); @@ -731,7 +731,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { ..Spec::default() }, ); - let width = ui.rect(bar).map_or(0.0, |rect| rect[2] - rect[0]); + let width = ui.laid_out(bar).map_or(0.0, |rect| rect[2] - rect[0]); let filled = width * fraction; ui.mark([0.0, 0.0, filled, 6.0], theme.accent, 3.0); // A sheen sweeps the filled part while the sync runs, so a long section still moves. @@ -1075,10 +1075,7 @@ impl State { session.sync = session.section.sync_status()?; let (library, anchor) = match &self.sync_notebook { Some((library, at)) => (Arc::clone(library), Anchor::Point(*at)), - None => ( - Arc::clone(&session.library), - Anchor::Below(ui.rect(button()).unwrap_or_default()), - ), + None => (Arc::clone(&session.library), Anchor::Below(button())), }; let own = Arc::ptr_eq(&library, &session.library); let offline = library::offline(); @@ -1244,7 +1241,7 @@ mod tests { if !ui.popup_open(id()) { ui.open_popup(id()); } - open(ui, Anchor::Below(ui.rect(button()).unwrap_or_default())); + open(ui, Anchor::Below(button())); build(ui, facts); ui.close(); ui.end(); @@ -1352,7 +1349,7 @@ mod tests { .unwrap(); let mapped = buffer.get_mapped_range(..).unwrap(); let bottom = bottom - .unwrap_or(ui.rect(id()).unwrap()[3] + 24.0) + .unwrap_or(ui.laid_out(id()).unwrap()[3] + 24.0) .min(SIZE[1]); let height = (bottom * SCALE) as u32; let pixels: Vec = mapped @@ -1382,7 +1379,7 @@ mod tests { } fn bar(ui: &Ui) -> bool { - ui.rect(id().child("status").child("bar")).is_some() + ui.laid_out(id().child("status").child("bar")).is_some() } #[test] @@ -1547,7 +1544,7 @@ mod tests { let mut ui = ui(Appearance::Light); let mut now = Instant::now(); settle(&mut ui, &mut now, &made()); - let [_, top, _, bottom] = ui.rect(id()).unwrap(); + let [_, top, _, bottom] = ui.laid_out(id()).unwrap(); (name, bottom - top) }) .collect(); @@ -1570,7 +1567,7 @@ mod tests { let mut now = Instant::now(); let facts = Facts { offline, ..made() }; settle(&mut ui, &mut now, &facts); - places.push((ui.rect(switch).unwrap(), ui.rect(controls).unwrap())); + places.push((ui.laid_out(switch).unwrap(), ui.laid_out(controls).unwrap())); } assert!( places.windows(2).all(|pair| pair[0] == pair[1]), @@ -1594,7 +1591,7 @@ mod tests { let mut now = Instant::now(); let facts = facts(); settle(&mut ui, &mut now, &facts); - assert!(ui.rect(id()).is_some(), "{name}"); + assert!(ui.laid_out(id()).is_some(), "{name}"); if let (Some(output), Some(gpu)) = (&output, &mut gpu) { gpu.save(&ui, &output.join(format!("{name}-{theme}.png")), None); } diff --git a/crates/snowbound/src/tags.rs b/crates/snowbound/src/tags.rs index 0cac6b187d23052b6bb093e92d007bdf603cfb4b..fc6e44b392e033661a63333286372360880cf70a 100644 --- a/crates/snowbound/src/tags.rs +++ b/crates/snowbound/src/tags.rs @@ -341,7 +341,7 @@ fn picker( if ui.signal(button).pressed { ui.open_popup(menu); } - Anchor::Below(ui.rect(button).unwrap_or_default()) + Anchor::Below(button) } /// The frame of a dialog titled `title`, centred in the window. diff --git a/crates/snowbound/src/templates.rs b/crates/snowbound/src/templates.rs index 832456771a969b5f5ae8dcc832b3925dc030ca81..e6c3220179dde877af74d68766e752dcbec124fa 100644 --- a/crates/snowbound/src/templates.rs +++ b/crates/snowbound/src/templates.rs @@ -112,6 +112,10 @@ fn strip( shown.push(pick); } shown.push(more); + let tiles = (shown.iter()) + .map(|(_, label)| GAP + tile_width(ui, label)) + .sum::() + - GAP; let mut chosen = None; ui.open( "templates", @@ -177,15 +181,12 @@ fn strip( } ui.close(); } - let width = ui - .rect(ui.id("tiles")) - .map_or(0.0, |rect| rect[2] - rect[0]); let close = ui.leaf( "dismiss", Spec { flags: Flags::FLOAT | Flags::CLICKABLE, size: [px(22.0), px(22.0)], - position: [width + 2.0 * PAD - 14.0, -8.0], + position: [tiles + 2.0 * PAD - 14.0, -8.0], icon: Some(art::CLOSE), color: Some(theme.text_dim), fill: Some(theme.popup), @@ -437,7 +438,8 @@ impl crate::State { self.templates = View::Strip; return; } - let (Some(rect), Some(start)) = (self.ui.rect(crate::page()), editor.body_start()) else { + let (Some(rect), Some(start)) = (self.ui.laid_out(crate::page()), editor.body_start()) + else { return; }; let viewport = self.view.viewport; @@ -503,7 +505,7 @@ impl crate::State { /// The Page Color menu's Show All: every template's art, one of which goes behind the /// open page. fn art_gallery(&mut self, theme: &Theme) { - let Some(rect) = self.ui.rect(crate::page()) else { + let Some(rect) = self.ui.laid_out(crate::page()) else { return; }; let paper = Paper { diff --git a/crates/snowbound/src/themes.rs b/crates/snowbound/src/themes.rs index 6748791ae25b0add1ebb07f95774e2ab65a225e3..359f3ebdd92089615af6ff5ee27c46939906490b 100644 --- a/crates/snowbound/src/themes.rs +++ b/crates/snowbound/src/themes.rs @@ -446,7 +446,7 @@ impl State { ..Item::default() }) .collect(); - let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); + let anchor = Anchor::Over(combo); if let Some(index) = ui::popup::menu(ui, popup("styles"), anchor, &items, None) { dialog.style = index; } @@ -475,7 +475,7 @@ impl State { ..Item::default() }) .collect(); - let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); + let anchor = Anchor::Over(combo); if let Some(index) = ui::popup::menu(ui, popup("fonts"), anchor, &items, Some("Font")) { let font = names[index].to_owned(); change = Some(Change::Font(font)); @@ -495,7 +495,7 @@ impl State { ..Item::default() }) .collect(); - let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); + let anchor = Anchor::Over(combo); if let Some(index) = ui::popup::menu(ui, popup("sizes"), anchor, &items, None) { let size = crate::SIZES[index]; change = Some(Change::Size(size)); @@ -521,7 +521,7 @@ impl State { .iter() .map(|&(color, name)| (canvas::gpu::colorref(color), name)) .collect(); - let anchor = Anchor::Below(ui.rect(button).unwrap_or_default()); + let anchor = Anchor::Below(button); let buttons = ["Automatic", "Theme"]; if let Some(picked) = ui::popup::color_grid(ui, popup("colors"), anchor, &buttons, &swatches, 10) @@ -556,7 +556,7 @@ impl State { ..Item::default() }) .collect(); - let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); + let anchor = Anchor::Over(combo); if let Some(index) = ui::popup::menu(ui, popup(part), anchor, &items, None) { let points = SPACING[index]; change = Some(Change::Spacing(part == "before", points)); @@ -634,7 +634,7 @@ impl State { ..Item::default() }) .collect(); - let anchor = Anchor::Below(ui.rect(ui.id(part)).unwrap_or_default()); + let anchor = Anchor::Below(ui.id(part)); ui::popup::menu(ui, menu, anchor, &items, None) }; let remove = match targets[..] { diff --git a/crates/snowbound/src/unpack.rs b/crates/snowbound/src/unpack.rs index 42e58823cb6bea51dbc887b6cbacd9d01dcee9b2..76d6b49de22c52cb2c5b418abb46b35ffd09bc42 100644 --- a/crates/snowbound/src/unpack.rs +++ b/crates/snowbound/src/unpack.rs @@ -138,7 +138,7 @@ impl State { ..Item::default() }) .collect(); - let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + let anchor = Anchor::Below(combo); if let Some(index) = ui::popup::menu(ui, colors, anchor, &items, None) { dialog.color = Some(SECTION_COLORS[index].0); } diff --git a/crates/ui/examples/popups.rs b/crates/ui/examples/popups.rs index 7f5414c18dc65c555cc0b93e3068bf5c1c226254..d1d5b2a4c0d1385d5bfe0cd560569361a27b761e 100644 --- a/crates/ui/examples/popups.rs +++ b/crates/ui/examples/popups.rs @@ -75,8 +75,7 @@ fn main() { ui::popup::menu(ui, context, Anchor::Point([140.0, 70.0]), &items[..8], None); }), ("combo", fonts, &|ui, [combo, _]| { - let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); - ui::popup::menu(ui, fonts, anchor, &items, Some("Calibri")); + ui::popup::menu(ui, fonts, Anchor::Over(combo), &items, Some("Calibri")); }), ("search", search, &|ui, [combo, _]| { results(ui, search, combo) @@ -138,33 +137,27 @@ fn results(ui: &mut Ui, id: Id, combo: Id) { return; } let theme = ui.theme.clone(); - let [left, top, right, bottom] = ui.rect(combo).unwrap_or_default(); - let anchor = Anchor::Over([left - 4.0, top - 4.0, right + 4.0, bottom + 4.0]); - let width = 300.0; - let open = ui.opening(id, anchor).unwrap_or(1.0); ui.open_as( id, Spec { axis: ui::Axis::Y, - size: [px(width), ui::children()], + size: [px(300.0), ui::children()], fill: Some(theme.popup), border: Some(theme.chip), shadow: Some(theme.shadow), radius: 6.0, pad: [4.0, 4.0], gap: 4.0, - anchor: Some(anchor), + anchor: Some(Anchor::Over(combo)), ..Spec::default() }, ); + // Stands in for the combo, as tall, widening with the panel. ui.leaf( "box", Spec { flags: ui::Flags::STILL, - size: [ - px(right - left + (width - 8.0 - (right - left)) * open), - px(bottom - top), - ], + size: [fill(), px(0.0)], text: Some("Search All Notebooks (⌘E)"), color: Some(theme.text_dim), fill: Some(theme.base), @@ -253,8 +246,7 @@ fn keystroke( let items = font_items(); let id = Id::ROOT.child("fonts"); let build = |ui: &mut Ui, [combo, _]: [Id; 2]| { - let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); - ui::popup::menu(ui, id, anchor, &items, Some("Calibri")); + ui::popup::menu(ui, id, Anchor::Over(combo), &items, Some("Calibri")); }; scene.open(id, Vec::new(), build); let mut panels = vec![paint(device, queue, renderer, &scene.ui)]; @@ -543,10 +535,6 @@ impl Scene { } } -fn below(ui: &Ui, id: Id) -> Anchor { - Anchor::Below(ui.rect(id).unwrap_or_default()) -} - fn key(named: NamedKey) -> Event { Event::Key { key: Key::Named(named), @@ -656,8 +644,7 @@ fn fonts(scene: &mut Scene) { let items = font_items(); let id = Id::ROOT.child("fonts"); scene.open(id, typed("ca"), |ui, [combo, _]| { - let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); - ui::popup::menu(ui, id, anchor, &items, Some("Calibri")); + ui::popup::menu(ui, id, Anchor::Over(combo), &items, Some("Calibri")); }); } @@ -707,16 +694,16 @@ fn colors(scene: &mut Scene) { let swatches = office_colors(); let id = Id::ROOT.child("font color"); scene.open(id, Vec::new(), |ui, [_, split]| { - let anchor = below(ui, split); + let anchor = Anchor::Below(split); ui::popup::colors(ui, id, anchor, "Automatic", &swatches, 10); }); - let cell = scene.ui.rect(id.child(("cell", 15_usize))).unwrap(); + let cell = scene.ui.laid_out(id.child(("cell", 15_usize))).unwrap(); scene .ui .event(Event::PointerMoved([cell[0] + 5.0, cell[1] + 5.0])); for _ in 0..30 { scene.frame(|ui, [_, split]| { - let anchor = below(ui, split); + let anchor = Anchor::Below(split); ui::popup::colors(ui, id, anchor, "Automatic", &swatches, 10); }); } @@ -743,7 +730,7 @@ fn highlight(scene: &mut Scene) { .map(|(r, g, b)| (draw::srgb(r, g, b), "")); let id = Id::ROOT.child("highlight"); scene.open(id, vec![key(NamedKey::ArrowDown)], |ui, [_, split]| { - let anchor = below(ui, split); + let anchor = Anchor::Below(split); ui::popup::colors(ui, id, anchor, "No Color", &swatches, 5); }); } @@ -820,21 +807,16 @@ fn tags(scene: &mut Scene) { ..Item::default() }); let id = Id::ROOT.child("tags"); - scene.open(id, Vec::new(), |ui, [combo, _]| { - let rect = ui.rect(combo).unwrap_or_default(); - let anchor = Anchor::Below([rect[0] + 200.0, rect[1], rect[0] + 222.0, rect[3]]); - ui::popup::menu(ui, id, anchor, &tags, Some("Filter tags")); - }); - let row = scene.ui.rect(id.child("rows").child(3_usize)).unwrap(); + let build = |ui: &mut Ui, [_, split]: [Id; 2]| { + ui::popup::menu(ui, id, Anchor::Below(split), &tags, Some("Filter tags")); + }; + scene.open(id, Vec::new(), build); + let row = scene.ui.laid_out(id.child("rows").child(3_usize)).unwrap(); scene .ui .event(Event::PointerMoved([row[0] + 40.0, row[1] + 8.0])); for _ in 0..3 { - scene.frame(|ui, [combo, _]| { - let rect = ui.rect(combo).unwrap_or_default(); - let anchor = Anchor::Below([rect[0] + 200.0, rect[1], rect[0] + 222.0, rect[3]]); - ui::popup::menu(ui, id, anchor, &tags, Some("Filter tags")); - }); + scene.frame(build); } } diff --git a/crates/ui/src/access/tests.rs b/crates/ui/src/access/tests.rs index 198e97cf4a980facd49187f8210cc99603dc663c..01a175324ce18cc5c1a75830f066bcd977249221 100644 --- a/crates/ui/src/access/tests.rs +++ b/crates/ui/src/access/tests.rs @@ -187,14 +187,13 @@ fn toolbar(ui: &mut Ui, bold: bool) -> Vec<&'static str> { ..popup::Item::default() }, ]; - let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); - popup::menu(ui, font_menu(), anchor, &items, Some("Font")); + popup::menu(ui, font_menu(), Anchor::Over(combo), &items, Some("Font")); let paste = [popup::Item { text: "Keep Text Only", shortcut: "⌘⇧V", ..popup::Item::default() }]; - let anchor = Anchor::Below(ui.rect(ui.id("paste")).unwrap_or_default()); + let anchor = Anchor::Below(ui.id("paste")); popup::menu(ui, more_menu(), anchor, &paste, None); clicked } @@ -256,7 +255,7 @@ fn a_disabled_combo_shows_its_value_but_takes_no_clicks_or_focus() { }); }; build(&mut ui); - let [left, top, right, bottom] = ui.rect(ui.id("font")).unwrap(); + let [left, top, right, bottom] = ui.laid_out(ui.id("font")).unwrap(); let middle = [(left + right) / 2.0, (top + bottom) / 2.0]; ui.event(Event::PointerMoved(middle)); for pressed in [true, false] { @@ -317,7 +316,7 @@ fn a_row_opening_a_menu_says_so_instead_of_naming_keys() { ui.open_popup(menu); for _ in 0..2 { frame(&mut ui, |ui| { - popup::menu(ui, menu, Anchor::Below([0.0; 4]), &items, None); + popup::menu(ui, menu, Anchor::Point([0.0; 2]), &items, None); }); } let tree = tree(&mut ui); @@ -348,7 +347,7 @@ fn swatches_are_named_by_their_colour_names_or_hex() { ui.open_popup(grid); for _ in 0..2 { frame(&mut ui, |ui| { - popup::colors(ui, grid, Anchor::Below([0.0; 4]), "No Color", &swatches, 2); + popup::colors(ui, grid, Anchor::Point([0.0; 2]), "No Color", &swatches, 2); }); } let snapshot = snapshot(&tree(&mut ui)); @@ -782,7 +781,7 @@ fn a_segmented_control_is_a_radio_group_tab_enters_at_its_choice_and_arrows_pick } assert_eq!((ui.focused(), chosen), (segment(0), 0), "an arrow picks"); // A click picks without moving the focus, which stays put on later frames. - let [left, top, right, bottom] = ui.rect(segment(1).unwrap()).unwrap(); + let [left, top, right, bottom] = ui.laid_out(segment(1).unwrap()).unwrap(); ui.event(Event::PointerMoved([ (left + right) / 2.0, (top + bottom) / 2.0, diff --git a/crates/ui/src/layout.rs b/crates/ui/src/layout.rs index 4ccf9e13777784efceb427cf6709a06999c346fe..7e4e7f4c9dabd518821f5fabe695ed762cb7703a 100644 --- a/crates/ui/src/layout.rs +++ b/crates/ui/src/layout.rs @@ -1,5 +1,6 @@ use crate::{ - Anchor, Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, State, fitting, text::Texts, + Anchor, Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, State, fitting, popup::PAD, + text::Texts, }; use std::collections::HashMap; @@ -7,8 +8,9 @@ use std::collections::HashMap; /// ancestors (pre-order), sizes summed from children (post-order), overflow taken back /// (by folding a row's groups for what space sized by ancestors can't give, then from that /// space, then from the least strict boxes first), and positions along each parent's flow. Labels too wide for their -/// solved width wrap or shorten before heights are solved. Boxes are in build order, so -/// index order is pre-order. +/// solved width wrap or shorten before heights are solved. Boxes are in build order, so a +/// parent comes before its children. The interface is solved first, then each popup in +/// turn beside its anchor's box as laid out by then, or as last laid out where not yet. pub(crate) fn solve( nodes: &mut [Built], states: &HashMap, @@ -22,15 +24,97 @@ pub(crate) fn solve( nodes[folded].hidden = true; } } + // The popup each box lies in, by the popup's index; 0 for the interface beneath. + let mut popup = vec![0; nodes.len()]; + for index in 1..nodes.len() { + popup[index] = match nodes[index].anchor { + Some(_) => index, + None => popup[nodes[index].parent], + }; + } + let members = |group: usize| -> Vec { + (0..popup.len()) + .filter(|index| popup[*index] == group) + .collect() + }; + solve_group(nodes, &members(0), states, scale, texts, frame); + for group in 1..nodes.len() { + if popup[group] == group { + resolve(nodes, &popup, group, states); + solve_group(nodes, &members(group), states, scale, texts, frame); + } + } +} + +/// Finds where popup `index` opens from its anchor's box, laid out before it in `popup`'s +/// order, or as last laid out, and what it takes from the box: a popup below or over it is +/// at least as wide, and what holds still in a popup over it stands in for it as tall. +fn resolve(nodes: &mut [Built], popup: &[usize], index: usize, states: &HashMap) { + let node = &nodes[index]; + let anchor = node.anchor.expect("a popup has an anchor"); + let laid_out = |id: Id| { + let found = (1..nodes.len()).find(|at| nodes[*at].id == id && popup[*at] < index); + found + .map(|at| (nodes[at].rect, popup[at])) + .or_else(|| Some((states.get(&id)?.rect?, 0))) + }; + let [pad_x, pad_y] = node.pad; + let around = match anchor { + Anchor::Point([x, y]) => [x, y, x, y], + Anchor::Dialog | Anchor::Top => [0.0; 4], + Anchor::Below(id) | Anchor::Right(id) | Anchor::Over(id) => { + let (rect, holder) = laid_out(id).unwrap_or_default(); + let [left, top, right, bottom] = rect; + match anchor { + Anchor::Right(_) => { + let [left, _, right, _] = if holder == 0 { + rect + } else { + nodes[holder].rect + }; + [left, top - pad_y, right, bottom + pad_y] + } + Anchor::Over(_) => [left - pad_x, top - pad_y, right + pad_x, bottom + pad_y], + _ => [left, top - PAD, right, bottom + PAD], + } + } + }; + let node = &mut nodes[index]; + node.around = around; + if let (Some(Anchor::Below(_) | Anchor::Over(_)), Size::Pixels(width)) = + (node.anchor, node.size[0].size) + { + node.size[0].size = Size::Pixels(width.max(around[2] - around[0])); + } + if let Anchor::Over(_) = anchor { + let height = around[3] - around[1] - 2.0 * pad_y; + for at in index..nodes.len() { + if popup[at] == index && nodes[at].flags.contains(Flags::STILL) { + nodes[at].size[1].size = Size::Pixels(height); + } + } + } +} + +/// Solves the boxes `members`, the interface or a popup and what it holds, in build order. +fn solve_group( + nodes: &mut [Built], + members: &[usize], + states: &HashMap, + scale: f32, + texts: &mut Texts, + frame: u64, +) { for axis in 0..2 { if axis == 1 { - fit_labels(nodes, texts, frame); + fit_labels(nodes, members, texts, frame); } - for node in nodes.iter_mut() { + for &index in members { + let node = &mut nodes[index]; node.computed[axis] = match node.size[axis].size { Size::Pixels(pixels) => match node.anchor { - Some(Anchor::Over(rect)) if axis == 0 => { - let from = rect[2] - rect[0]; + Some(Anchor::Over(_)) if axis == 0 => { + let from = node.around[2] - node.around[0]; from + (pixels - from) * node.open } _ => pixels, @@ -51,8 +135,8 @@ pub(crate) fn solve( Size::Fraction(_) | Size::Children => 0.0, }; } - fit_popups(nodes, axis); - for index in 1..nodes.len() { + fit_popups(nodes, members, axis); + for &index in members.iter().filter(|index| **index != 0) { if let Size::Fraction(fraction) = nodes[index].size[axis].size { if stretches(nodes, index, axis) { nodes[index].computed[axis] = 0.0; @@ -66,15 +150,15 @@ pub(crate) fn solve( nodes[index].computed[axis] = room.max(0.0) * fraction; } } - for index in (0..nodes.len()).rev() { + for &index in members.iter().rev() { if nodes[index].size[axis].size == Size::Children { let content = flow(nodes, index, axis); nodes[index].computed[axis] = content + 2.0 * nodes[index].pad[axis]; } } - yield_popups(nodes, axis); - fit_popups(nodes, axis); - for index in 0..nodes.len() { + yield_popups(nodes, members, axis); + fit_popups(nodes, members, axis); + for &index in members { let node = &nodes[index]; if node.children.is_empty() || (axis == 1 && node.flags.contains(Flags::SCROLL)) { continue; @@ -144,24 +228,30 @@ pub(crate) fn solve( } } let window = nodes[0].computed[axis]; - for index in 0..nodes.len() { + for &index in members { + let node = &nodes[index]; + if let Some(anchor) = node.anchor { + let shown = node.computed[axis]; + let size = match node.size[axis].size { + Size::Pixels(pixels) => pixels, + _ => shown, + }; + nodes[index].relative[axis] = anchor.place(node.around, axis, size, shown, window); + } let mut cursor = nodes[index].pad[axis]; // Where a popup widening over its anchor lays its children out, from where it is. let shift = match (nodes[index].anchor, nodes[index].size[axis].size) { (Some(anchor @ Anchor::Over(_)), Size::Pixels(full)) if axis == 0 => { - anchor.place(axis, full, full, window) - nodes[index].relative[axis] + let around = nodes[index].around; + anchor.place(around, axis, full, full, window) - nodes[index].relative[axis] } _ => 0.0, }; for child in nodes[index].children.clone() { - nodes[child].relative[axis] = if let Some(anchor) = nodes[child].anchor { - let shown = nodes[child].computed[axis]; - let size = match nodes[child].size[axis].size { - Size::Pixels(pixels) => pixels, - _ => shown, - }; - anchor.place(axis, size, shown, window) - } else if nodes[child].flags.contains(Flags::FLOAT) { + if nodes[child].anchor.is_some() { + continue; + } + nodes[child].relative[axis] = if nodes[child].flags.contains(Flags::FLOAT) { nodes[child].position[axis] } else if nodes[child].hidden { nodes[index].pad[axis] @@ -182,13 +272,12 @@ pub(crate) fn solve( } } let snap = |value: f32| (value * scale).round() / scale; - nodes[0].rect = [ - 0.0, - 0.0, - snap(nodes[0].computed[0]), - snap(nodes[0].computed[1]), - ]; - for index in 1..nodes.len() { + for &index in members { + if index == 0 { + let [width, height] = nodes[0].computed; + nodes[0].rect = [0.0, 0.0, snap(width), snap(height)]; + continue; + } let parent = nodes[index].parent; if nodes[index].hidden || nodes[parent].hidden { nodes[index].hidden = true; @@ -229,9 +318,10 @@ fn stretches(nodes: &[Built], index: usize, axis: usize) -> bool { /// Lets each popup sized loosely give way to the window, as far as its strictness lets it: /// a dialog keeps below it the margin it opens under. -fn yield_popups(nodes: &mut [Built], axis: usize) { +fn yield_popups(nodes: &mut [Built], members: &[usize], axis: usize) { let window = nodes[0].computed[axis]; - for node in &mut nodes[1..] { + for &index in members { + let node = &mut nodes[index]; let room = match node.anchor { None => continue, Some(Anchor::Dialog | Anchor::Top) if axis == 1 => window * 3.0 / 4.0, @@ -246,9 +336,10 @@ fn yield_popups(nodes: &mut [Built], axis: usize) { /// Shrinks each popup longer than the window lets it be on `axis`; one cut short vertically /// scrolls what it holds. -fn fit_popups(nodes: &mut [Built], axis: usize) { +fn fit_popups(nodes: &mut [Built], members: &[usize], axis: usize) { let most = fitting(nodes[0].computed[axis]); - for node in &mut nodes[1..] { + for &index in members { + let node = &mut nodes[index]; if node.anchor.is_some() && node.computed[axis] > most { node.computed[axis] = most; if axis == 1 { @@ -374,8 +465,9 @@ fn least(nodes: &[Built], index: usize) -> f32 { own * node.size[0].strictness.clamp(0.0, 1.0) } -fn fit_labels(nodes: &mut [Built], texts: &mut Texts, frame: u64) { - for node in nodes.iter_mut() { +fn fit_labels(nodes: &mut [Built], members: &[usize], texts: &mut Texts, frame: u64) { + for &index in members { + let node = &mut nodes[index]; let Some(label) = node.label.clone() else { continue; }; diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 96a1eff4549271769b04de877627848b405f8239..48c67718855d2ad80f955ac8b65144cad820950f 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -167,7 +167,7 @@ impl Flags { /// The host paints it and receives the events routed to it. pub const CUSTOM: Flags = Flags(32); /// Paints in place while the popup around it opens, as a field standing in for the one - /// it opened from. + /// it opened from, and as tall as that box. pub const STILL: Flags = Flags(64); pub(crate) fn contains(self, other: Flags) -> bool { @@ -219,14 +219,17 @@ fn fitting(room: f32) -> f32 { } /// Where a popup opens, flipping to the far side of its anchor where the window ends first. +/// The box it opens beside is placed where layout puts it in the same frame. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Anchor { - /// Under the rectangle, from its leading edge, as a drop-down opens. - Below([f32; 4]), - /// Past the rectangle's trailing edge, from its top, as a submenu opens. - Right([f32; 4]), - /// Over the rectangle from its corner, as a combo box opens into its own list. - Over([f32; 4]), + /// Under the box, from its leading edge, as a drop-down opens; at least as wide as it. + Below(Id), + /// Past the trailing edge of the box, or of the popup holding it, level with the box's + /// top, as a submenu opens. + Right(Id), + /// Over the box from its corner, the popup's contents covering it, as a combo box opens + /// into its own list; at least as wide as it. + Over(Id), /// At a point, as a context menu opens. Point([f32; 2]), /// Centred across the window near its top, over the interface, which dims, as a dialog @@ -238,24 +241,23 @@ pub enum Anchor { } impl Anchor { - /// Where a popup `size` long on `axis` starts in a window `room` long: past the - /// anchor on the axis it opens along, level with it otherwise. Shown only `shown` long - /// as it opens, it keeps the edge it would have at full size. - fn place(self, axis: usize, size: f32, shown: f32, room: f32) -> f32 { + /// Where a popup `size` long on `axis` starts in a window `room` long: past `around`, the + /// rectangle it opens beside, on the axis it opens along, level with it otherwise. Shown + /// only `shown` long as it opens, it keeps the edge it would have at full size. + fn place(self, around: [f32; 4], axis: usize, size: f32, shown: f32, room: f32) -> f32 { let size = size.min(fitting(room)); let shown = shown.min(size); let most = (room - POPUP_MARGIN - size).max(POPUP_MARGIN); - let (rect, along) = match self { - Anchor::Below(rect) => (rect, Some(1)), - Anchor::Right(rect) => (rect, Some(0)), - Anchor::Over(rect) => (rect, None), - Anchor::Point([x, y]) => ([x, y, x, y], Some(1)), + let along = match self { + Anchor::Below(_) | Anchor::Point(_) => Some(1), + Anchor::Right(_) => Some(0), + Anchor::Over(_) => None, Anchor::Dialog | Anchor::Top if axis == 0 => { return ((room - size) / 2.0).max(POPUP_MARGIN); } Anchor::Dialog | Anchor::Top => return (room / 8.0).clamp(POPUP_MARGIN, most), }; - let [low, high] = [rect[axis], rect[axis + 2]]; + let [low, high] = [around[axis], around[axis + 2]]; let (first, second) = if along == Some(axis) { (high, low - size) } else { @@ -270,42 +272,6 @@ impl Anchor { } } - /// How a popup laid out at `rect` beside the anchor shows `open` of the way open: where it - /// `grows`, it swings out of the anchor's edge as it grows and fades in, and a dialog swings - /// up into place. - fn motion(self, rect: [f32; 4], open: f32, grows: bool) -> Motion { - let pivot = match self { - Anchor::Below(anchor) => [anchor[0], anchor[3]], - Anchor::Right(anchor) => [anchor[2], anchor[1]], - Anchor::Over(anchor) => [anchor[0], anchor[1]], - Anchor::Point(point) => point, - Anchor::Dialog | Anchor::Top => [(rect[0] + rect[2]) / 2.0, rect[1]], - }; - let (from, tilt) = match self { - _ if !grows => (1.0, 0.0), - Anchor::Dialog | Anchor::Top => (0.95, 0.2), - // Over a box, the popup widens out of it in layout instead of growing. - Anchor::Over(_) => (1.0, 0.2), - _ => (0.94, 0.2), - }; - let pivot = [ - pivot[0].clamp(rect[0], rect[2]), - pivot[1].clamp(rect[1], rect[3]), - ]; - // A popup flipped above its anchor swings out of its bottom edge, its top leaning away. - let tilt = if pivot[1] > (rect[1] + rect[3]) / 2.0 { - -tilt - } else { - tilt - }; - Motion { - zoom: from + (1.0 - from) * open, - pivot, - tilt: tilt * (1.0 - open), - opacity: open, - } - } - /// Seconds the popup takes to open and to close. fn durations(self) -> [f32; 2] { if matches!(self, Anchor::Dialog | Anchor::Top) { @@ -534,6 +500,8 @@ struct Built { offset: [f32; 2], cursor: Option, anchor: Option, + /// The rectangle a popup opens beside, as layout resolves its anchor. + around: [f32; 4], /// How far open a popup over a box is, as it widens out of the box. open: f32, motion: Option, @@ -604,12 +572,61 @@ struct Tip { due: Option, } +/// A popup painted: its anchor, the rectangle it opens beside, and its own. +#[derive(Clone, Copy)] +struct Placed { + id: Id, + anchor: Anchor, + around: [f32; 4], + rect: [f32; 4], +} + +impl Placed { + /// How the popup shows `open` of the way open: where it `grows`, it swings out of its + /// anchor's edge as it grows and fades in, and a dialog swings up into place. + fn motion(self, open: f32, grows: bool) -> Motion { + let Placed { + anchor, + around, + rect, + .. + } = self; + let pivot = match anchor { + Anchor::Below(_) | Anchor::Point(_) => [around[0], around[3]], + Anchor::Right(_) => [around[2], around[1]], + Anchor::Over(_) => [around[0], around[1]], + Anchor::Dialog | Anchor::Top => [(rect[0] + rect[2]) / 2.0, rect[1]], + }; + let (from, tilt) = match anchor { + _ if !grows => (1.0, 0.0), + Anchor::Dialog | Anchor::Top => (0.95, 0.2), + // Over a box, the popup widens out of it in layout instead of growing. + Anchor::Over(_) => (1.0, 0.2), + _ => (0.94, 0.2), + }; + let pivot = [ + pivot[0].clamp(rect[0], rect[2]), + pivot[1].clamp(rect[1], rect[3]), + ]; + // A popup flipped above its anchor swings out of its bottom edge, its top leaning away. + let tilt = if pivot[1] > (rect[1] + rect[3]) / 2.0 { + -tilt + } else { + tilt + }; + Motion { + zoom: from + (1.0 - from) * open, + pivot, + tilt: tilt * (1.0 - open), + opacity: open, + } + } +} + /// A popup's painting as it last showed, fading out since it closed. struct Closing { - id: Id, + placed: Placed, display: Vec, - anchor: Anchor, - rect: [f32; 4], closed: Instant, } @@ -716,9 +733,8 @@ pub struct Ui { still: Vec, /// Where the popups' painting starts in `display`, for edges drawn beneath them. popups_painted: usize, - /// Each popup painted, its anchor and rectangle, and its painting's place after - /// `popups_painted`. - painted: Vec<(Id, Anchor, [f32; 4], std::ops::Range)>, + /// Each popup painted, and its painting's place after `popups_painted`. + painted: Vec<(Placed, std::ops::Range)>, closing: Vec, tip: Option, /// When a tooltip last showed. @@ -739,6 +755,8 @@ pub struct Ui { resume: Option, /// The nodes last sent to assistive technology. sent: HashMap, + /// Scrolling boxes to scroll to a box within each, once laid out. + scrolls: Vec<(Id, Id)>, } impl Ui { @@ -786,6 +804,7 @@ impl Ui { focus_ring: false, resume: None, sent: HashMap::new(), + scrolls: Vec::new(), } } @@ -867,21 +886,27 @@ impl Ui { Some(layout::narrowest(&self.nodes, index)) } - /// The box's rectangle from the latest layout; none for one not laid out yet. - pub fn rect(&self, id: Id) -> Option<[f32; 4]> { + /// Where box `id` lies in the latest layout: this frame's once `end` has run, otherwise + /// the previous frame's, which input is routed against; none for one not laid out yet. + /// For hosts painting, hit-testing or mapping input after layout: a box placed beside + /// another takes an `Anchor`, which layout resolves in the same frame. + pub fn laid_out(&self, id: Id) -> Option<[f32; 4]> { self.states.get(&id).and_then(|state| state.rect) } - /// Scrolls `scroller`, a scrolling box, until `target` within it starts at its top, both - /// as last laid out, easing there. + /// The window's size in logical pixels this frame. + pub fn size(&self) -> [f32; 2] { + let root = self.nodes.first().map(|root| root.size); + root.unwrap_or_default().map(|extent| match extent.size { + Size::Pixels(pixels) => pixels, + _ => 0.0, + }) + } + + /// Scrolls `scroller`, a scrolling box, until `target` within it starts at its top, as + /// this frame lays them out, easing there. pub fn scroll_to(&mut self, scroller: Id, target: Id) { - let (Some(top), Some(start)) = (self.rect(target), self.rect(scroller)) else { - return; - }; - let state = self.states.entry(scroller).or_default(); - let most = (state.content - (start[3] - start[1])).max(0.0); - state.scroll_target = (state.scroll + top[1] - start[1]).clamp(0.0, most); - self.animating |= state.scroll != state.scroll_target; + self.scrolls.push((scroller, target)); } /// The pointer's cursor, or None over a custom box, whose host chooses. @@ -981,7 +1006,7 @@ impl Ui { self.focus_ring = false; if !self.popups.is_empty() { let under = self.popups.iter().rposition(|popup| { - self.rect(popup.id) + self.laid_out(popup.id) .is_some_and(|rect| contains(rect, point)) }); // A press outside every popup only dismisses them. @@ -1385,6 +1410,15 @@ impl Ui { state.scroll = state.scroll.clamp(0.0, most); } } + for (scroller, target) in std::mem::take(&mut self.scrolls) { + let (Some(top), Some(start)) = (self.laid_out(target), self.laid_out(scroller)) else { + continue; + }; + let state = self.states.entry(scroller).or_default(); + let most = (state.content - (start[3] - start[1])).max(0.0); + state.scroll_target = (state.scroll + top[1] - start[1]).clamp(0.0, most); + self.animating |= state.scroll != state.scroll_target; + } self.states .retain(|id, state| state.touched == self.frame || *id == Id::ROOT); self.texts.prune(self.frame); @@ -1415,14 +1449,19 @@ impl Ui { let Some(anchor) = node.anchor else { continue; }; - let (id, rect) = (node.id, node.rect); - if Some(id) == tip { + let placed = Placed { + id: node.id, + anchor, + around: node.around, + rect: node.rect, + }; + if Some(placed.id) == tip { continue; } - if let Some(open) = self.opening(id, anchor) { + if let Some(open) = self.opening(placed.id, anchor) { self.scrim(anchor, open); let grows = self.popup_motion(anchor) == PopupMotion::Grow; - self.nodes[index].motion = Some(anchor.motion(rect, open, grows)); + self.nodes[index].motion = Some(placed.motion(open, grows)); } let from = self.display.len() - self.popups_painted; self.paint(index, None, None); @@ -1435,15 +1474,10 @@ impl Ui { Display::Round(None), ]); } - painted.push(( - id, - anchor, - rect, - from..self.display.len() - self.popups_painted, - )); + painted.push((placed, from..self.display.len() - self.popups_painted)); } - for (id, anchor, rect, range) in std::mem::replace(&mut self.painted, painted) { - if self.painted.iter().any(|(shown, ..)| *shown == id) { + for (placed, range) in std::mem::replace(&mut self.painted, painted) { + if self.painted.iter().any(|(shown, _)| shown.id == placed.id) { continue; } // What held still while it opened goes at once, uncovering what it stood in for. @@ -1460,18 +1494,17 @@ impl Ui { .cloned() .collect(); self.closing.push(Closing { - id, + placed, display, - anchor, - rect, closed: self.now, }); } for closing in std::mem::take(&mut self.closing) { - let motion = self.popup_motion(closing.anchor); + let Placed { id, anchor, .. } = closing.placed; + let motion = self.popup_motion(anchor); let open = match motion { PopupMotion::Grow => { - (1.0 - self.progress(closing.closed, closing.anchor.durations()[1])).powi(3) + (1.0 - self.progress(closing.closed, anchor.durations()[1])).powi(3) } PopupMotion::Cut => 0.0, PopupMotion::Fade([_, close]) => { @@ -1481,13 +1514,13 @@ impl Ui { if open == 0.0 { continue; } - self.scrim(closing.anchor, open); + self.scrim(anchor, open); let grows = motion == PopupMotion::Grow; - let motion = closing.anchor.motion(closing.rect, open, grows); + let motion = closing.placed.motion(open, grows); self.display.push(Display::Motion(Some(motion))); self.display.extend(closing.display.iter().cloned()); self.display.push(Display::Motion(None)); - if !self.painted.iter().any(|(shown, ..)| *shown == closing.id) { + if !self.painted.iter().any(|(shown, _)| shown.id == id) { self.closing.push(closing); } } @@ -2050,6 +2083,7 @@ impl Built { offset: spec.offset, cursor: spec.cursor, anchor: spec.anchor, + around: [0.0; 4], open: 1.0, motion: None, marks: Vec::new(), diff --git a/crates/ui/src/list.rs b/crates/ui/src/list.rs index 93c5c4ed2e14468f984adf3fb2d9d4c405bd1145..6005a02041b4bcd1a28254e904fb175876156b5d 100644 --- a/crates/ui/src/list.rs +++ b/crates/ui/src/list.rs @@ -96,7 +96,7 @@ pub fn list( } low }; - let rect = ui.rect(id); + let rect = ui.laid_out(id); let view = f64::from(match spec.size[1].size { Size::Pixels(pixels) => pixels, _ => rect.map_or(0.0, |rect| rect[3] - rect[1]), diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs index 40c3bdf67c60ff8ec7993888ad4499982b69c15c..06594aff22411a47a77440930f692b2016e47dcd 100644 --- a/crates/ui/src/popup.rs +++ b/crates/ui/src/popup.rs @@ -117,7 +117,7 @@ pub fn menu( .map_or(0.0, |badge| crate::badge_width(ui, badge) + ICON_GAP) }) .collect(); - let window = ui.rect(Id::ROOT).map_or(0.0, |window| window[3]); + let window = ui.size()[1]; let field = if filter.is_some() { ROW + style.pad } else { @@ -154,11 +154,6 @@ pub fn menu( 0.0 } + 2.0 * (style.pad + style.row_pad); - let least = match anchor { - Anchor::Below(rect) => rect[2] - rect[0], - Anchor::Over(rect) => rect[2] - rect[0] + 2.0 * style.pad, - _ => 0.0, - }; choose( ui, id, @@ -166,7 +161,7 @@ pub fn menu( anchor, &[("", items)], filter, - width.max(least).max(NARROWEST), + width.max(NARROWEST), style.row, ) .map(|(_, index)| index) @@ -194,7 +189,7 @@ pub fn submenus(ui: &mut Ui, id: Id, items: &[Item], submenu: impl Fn(usize) -> highlight.is_some_and(opens) && !navigation(ui, &[id], &[NamedKey::ArrowRight]).is_empty(); let under = highlight.filter(|key| { ui.pointer - .zip(ui.rect(rows.child(*key))) + .zip(ui.laid_out(rows.child(*key))) .is_some_and(|(point, rect)| crate::contains(rect, point)) }); let now = ui.now; @@ -225,23 +220,23 @@ pub fn submenus(ui: &mut Ui, id: Id, items: &[Item], submenu: impl Fn(usize) -> } /// Shows `title`, with the `keys` that run it and a `description` under it, in a tooltip -/// below the box built last while the pointer rests on it or on a box inside it: after a -/// delay, or at once while another has just shown. A press or the wheel hides it until the -/// pointer leaves. It names the box to assistive technology, or where the box has no role, -/// the unnamed controls inside it. +/// below the box built last while the pointer rests on it or on a box inside it: +/// after a delay, or at once while another has just shown. A press or the wheel hides it +/// until the pointer leaves. It names the box to assistive technology, or where the box has +/// no role, the unnamed controls inside it. pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>) { tooltip_below(ui, None, title, keys, description); } -/// A `tooltip` below `part`, a window rectangle within the box built last, as a custom -/// box names what it draws. -pub fn tooltip_over(ui: &mut Ui, part: [f32; 4], title: &str, description: Option<&str>) { +/// A `tooltip` below `part`, a box built within the box built last, as a custom box names +/// part of what it draws. +pub fn tooltip_over(ui: &mut Ui, part: Id, title: &str, description: Option<&str>) { tooltip_below(ui, Some(part), title, "", description); } fn tooltip_below( ui: &mut Ui, - part: Option<[f32; 4]>, + part: Option, title: &str, keys: &str, description: Option<&str>, @@ -312,7 +307,6 @@ fn tooltip_below( } else { format!("{title} ({keys})") }; - let [left, top, right, bottom] = part.or(ui.rect(id)).unwrap_or_default(); let theme = &ui.theme; let spec = Spec { axis: Axis::Y, @@ -322,7 +316,7 @@ fn tooltip_below( radius: 4.0, pad: [8.0, 5.0], gap: 3.0, - anchor: Some(Anchor::Below([left, top, right, bottom + PAD])), + anchor: Some(Anchor::Below(part.unwrap_or(id))), ..Spec::default() }; ui.open_as(id.child("tooltip"), spec); @@ -379,8 +373,7 @@ pub fn palette(ui: &mut Ui, id: Id, modes: &[(&str, &[Item])], placeholder: &str (ui.signals.get(&rows.child(*key))).is_some_and(|signal| signal.context.is_some()) }); let chord = chord(ui, id.child("filter"), "k"); - let window = ui.rect(Id::ROOT).unwrap_or_default(); - let width = PALETTE.min(window[2] - 8.0 * PAD).max(NARROWEST); + let width = PALETTE.min(ui.size()[0] - 8.0 * PAD).max(NARROWEST); if let Some((mode, index)) = choose( ui, id, @@ -461,9 +454,9 @@ fn choose( let theme = ui.theme.clone(); let style = theme.menu(); // Over a combo box, the field takes the box's place at once, widening with the popup. - let (flags, height) = match anchor { - Anchor::Over(rect) => (Flags::STILL, rect[3] - rect[1]), - _ => (Flags::default(), ROW), + let flags = match anchor { + Anchor::Over(_) => Flags::STILL, + _ => Flags::default(), }; surface(ui, id, role, anchor, width); if let Some(placeholder) = filter { @@ -475,7 +468,7 @@ fn choose( placeholder, Spec { flags, - size: [fill(), px(height)], + size: [fill(), px(ROW)], fill: Some(theme.base), border: Some(theme.accent), radius: 4.0, @@ -512,7 +505,7 @@ fn choose( }; first.map(|row| matches.key(row)) }); - let window = ui.rect(Id::ROOT).map_or(0.0, |window| window[3]); + let window = ui.size()[1]; let field = if filter.is_some() { ROW + style.pad } else { @@ -1208,7 +1201,7 @@ pub fn color_picker( let mut picked = state(ui, id).picked.unwrap_or_else(|| to_hsl(initial)); let pointer = ui.pointer(); let along = |ui: &Ui, part: Id| { - let [left, top, right, bottom] = ui.rect(part)?; + let [left, top, right, bottom] = ui.laid_out(part)?; let [x, y] = pointer?; Some([ ((x - left) / (right - left)).clamp(0.0, 1.0), @@ -1412,33 +1405,15 @@ fn from_hsl([hue, saturation, lightness]: [f32; 3]) -> [u8; 3] { [red, green, blue].map(|channel| ((channel + base) * 255.0).round().clamp(0.0, 255.0) as u8) } -/// Opens popup `id`'s panel, a `role` `width` wide beside `anchor`; the caller closes it. +/// Opens popup `id`'s panel, a `role` `width` wide beside `anchor`, or as a submenu beside +/// its row; the caller closes it. fn surface(ui: &mut Ui, id: Id, role: Role, anchor: Anchor, width: f32) { let style = ui.theme.menu(); - let pad = style.pad; - // Level with its row, past the menu's edges. let beside = ui .popups .iter() - .position(|popup| popup.id == id) - .and_then(|at| { - let [_, top, _, bottom] = ui.rect(ui.popups[at].beside?)?; - let [left, _, right, _] = ui.rect(ui.popups[at.checked_sub(1)?].id)?; - Some([left, top, right, bottom]) - }); - let anchor = match beside.map_or(anchor, Anchor::Right) { - Anchor::Below([left, top, right, bottom]) => { - Anchor::Below([left, top - PAD, right, bottom + PAD]) - } - // Level with the row it opens from, or with its contents over the box. - Anchor::Right([left, top, right, bottom]) => { - Anchor::Right([left, top - pad, right, bottom + pad]) - } - Anchor::Over([left, top, right, bottom]) => { - Anchor::Over([left - pad, top - pad, right + pad, bottom + pad]) - } - point => point, - }; + .find(|popup| popup.id == id) + .and_then(|popup| popup.beside); let spec = Spec { axis: Axis::Y, size: [px(width), children()], @@ -1446,9 +1421,9 @@ fn surface(ui: &mut Ui, id: Id, role: Role, anchor: Anchor, width: f32) { border: Some(style.border), shadow: Some(ui.theme.shadow), radius: style.radius, - pad: [pad; 2], - gap: pad, - anchor: Some(anchor), + pad: [style.pad; 2], + gap: style.pad, + anchor: Some(beside.map_or(anchor, Anchor::Right)), role: Some(role), ..Spec::default() }; diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 275aa37464230870aa6d5468cada121036823745..7687e9cbe34ad797ac2b5e36b37123046f7d88dc 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -54,8 +54,9 @@ const EDGE: f32 = 20.0; /// The row is as wide as the tabs, and gives up all of that where its own row lacks room. /// Then the tabs scroll sideways under the wheel, either way it turns, fading out at the /// ends they are cut at into the row's `fill`, and the open tab scrolls into view as it -/// opens. A tab given `true` after its colour shows the unread dot in its leading pad. -#[allow(clippy::too_many_arguments)] +/// opens. A tab given `true` after its colour shows the unread dot in its leading pad. The tab +/// `renaming` holds what its builder builds inside it, given the tab's height. +#[allow(clippy::too_many_arguments, clippy::type_complexity)] pub fn section_tabs( ui: &mut Ui, row: Id, @@ -63,6 +64,7 @@ pub fn section_tabs( active: usize, lit: Option, dragged: Option, + mut renaming: Option<(usize, &mut dyn FnMut(&mut Ui, f32))>, section: &Section, height: f32, fill: [f32; 4], @@ -83,7 +85,7 @@ pub fn section_tabs( .collect(); // The last tab's slant and shadow reach past its box. let wide = left + lean(height) + crate::SHADOW[0]; - let view = ui.rect(row).map_or(wide, |rect| rect[2] - rect[0]); + let view = ui.laid_out(row).map_or(wide, |rect| rect[2] - rect[0]); let most = (wide - view).max(0.0); let [scroll, shown, shift] = [row.child("scroll"), row.child("shown"), row.child("offset")]; let held_value = |ui: &Ui, id: Id| ui.states.get(&id).and_then(|state| state.tween); @@ -190,7 +192,7 @@ pub fn section_tabs( let top = if index == active { section } else { &colors }; let fill = mix(colors.tab, top.frame[0], open); let fade = |color: [f32; 4], alpha: f32| [color[0], color[1], color[2], color[3] * alpha]; - let signal = ui.leaf( + let id = ui.open( ("tab", index), Spec { flags: Flags::CLICKABLE | Flags::FLOAT, @@ -215,6 +217,11 @@ pub fn section_tabs( ..Spec::default() }, ); + if let Some((_, build)) = renaming.as_mut().filter(|(at, _)| *at == index) { + build(ui, tall); + } + ui.close(); + let signal = ui.signal(id); if unread { let position = [ x - offset + (TAB_PAD - DOT) / 2.0, @@ -548,7 +555,7 @@ pub fn more_button(ui: &mut Ui, part: impl Hash, menu: Id, enabled: bool) -> Anc if enabled && ui.signal(id).pressed { ui.open_popup(menu); } - Anchor::Below(ui.rect(id).unwrap_or_default()) + Anchor::Below(id) } /// A button showing `icon` and a menu arrow, one control that opens popup `menu`, a toggle @@ -593,15 +600,23 @@ pub fn menu_button( }, ); } + // The menu opens from the button shifted so its icons line up under the button's. + let style = ui.theme.menu(); + let from = ui.id("menu"); + ui.leaf( + "menu", + Spec { + flags: Flags::FLOAT, + size: [px(TOOL + ARROW), px(TOOL)], + position: [(TOOL - crate::ICON) / 2.0 - style.pad - style.row_pad, 0.0], + ..Spec::default() + }, + ); ui.close(); if ui.signal(id).pressed { ui.open_popup(menu); } - let [left, top, right, bottom] = ui.rect(id).unwrap_or_default(); - let menu = ui.theme.menu(); - // The menu's icons line up under the button's. - let shift = menu.pad + menu.row_pad - (TOOL - crate::ICON) / 2.0; - Anchor::Below([left - shift, top, right - shift, bottom]) + Anchor::Below(from) } /// A drop-down box `name`d, `width` wide, showing `text`, that opens popup `menu`; where diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs index 8adada16de9172e687cc3144653c3a23539d7e4a..2d2a061f2f0bcedd0fbb8d8439906edc4f7cc7a2 100644 --- a/crates/ui/src/tests.rs +++ b/crates/ui/src/tests.rs @@ -62,8 +62,8 @@ fn fill_yields_to_strict_siblings_along_the_flow() { ui.close(); ui.close(); }); - assert_eq!(ui.rect(ids[0]), Some([0.0, 0.0, 280.0, 100.0])); - assert_eq!(ui.rect(ids[1]), Some([280.0, 0.0, 400.0, 100.0])); + assert_eq!(ui.laid_out(ids[0]), Some([0.0, 0.0, 280.0, 100.0])); + assert_eq!(ui.laid_out(ids[1]), Some([280.0, 0.0, 400.0, 100.0])); } #[test] @@ -93,7 +93,7 @@ fn padding_wider_than_its_box_leaves_loose_children_empty_not_negative() { ui.close(); ui.close(); }); - assert_eq!(ui.rect(child.unwrap()), Some([8.0, 8.0, 8.0, 8.0])); + assert_eq!(ui.laid_out(child.unwrap()), Some([8.0, 8.0, 8.0, 8.0])); } #[test] @@ -128,8 +128,8 @@ fn children_sum_with_gaps_and_padding() { ui.close(); ui.close(); }); - assert_eq!(ui.rect(column.unwrap()), Some([0.0, 0.0, 58.0, 44.0])); - assert_eq!(ui.rect(second.unwrap()), Some([4.0, 18.0, 34.0, 38.0])); + assert_eq!(ui.laid_out(column.unwrap()), Some([0.0, 0.0, 58.0, 44.0])); + assert_eq!(ui.laid_out(second.unwrap()), Some([4.0, 18.0, 34.0, 38.0])); } /// A row `width` wide of a space, a box that yields all but a quarter of its 80 pixels, and @@ -191,7 +191,7 @@ fn folding_row(ui: &mut Ui, width: f32, priorities: &[u32]) -> Vec Id { /// Builds a frame with a button filling the window under the menu, and returns the /// button's signal and the item chosen. -fn menu_frame(ui: &mut Ui, anchor: Anchor, filter: Option<&str>) -> (Signal, Option) { +fn menu_frame( + ui: &mut Ui, + anchor: impl FnOnce(&mut Ui) -> Anchor, + filter: Option<&str>, +) -> (Signal, Option) { let mut result = (Signal::default(), None); frame(ui, |ui| { let under = ui.leaf( @@ -1137,28 +1141,49 @@ fn menu_frame(ui: &mut Ui, anchor: Anchor, filter: Option<&str>) -> (Signal, Opt ..Spec::default() }, ); + let anchor = anchor(ui); let chosen = popup::menu(ui, menu_id(), anchor, &items(), filter); result = (under, chosen); }); result } -const BELOW: Anchor = Anchor::Below([20.0, 20.0, 100.0, 40.0]); +/// A box floating at `rect` in the window, built at its root, for a popup to open beside. +fn spot(ui: &mut Ui, rect: [f32; 4]) -> Id { + let [left, top, right, bottom] = rect; + let id = Id::ROOT.child(("spot", rect.map(f32::to_bits))); + ui.open_as( + id, + Spec { + flags: Flags::FLOAT, + size: [px(right - left), px(bottom - top)], + position: [left, top], + ..Spec::default() + }, + ); + ui.close(); + id +} -/// A window with the menu open and faded in below `BELOW`. +/// Below a box at (20, 20), 80 by 20. +fn below(ui: &mut Ui) -> Anchor { + Anchor::Below(spot(ui, [20.0, 20.0, 100.0, 40.0])) +} + +/// A window with the menu open and faded in `below`. fn open_menu(filter: Option<&str>) -> Ui { let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); - menu_frame(&mut ui, BELOW, filter); + menu_frame(&mut ui, below, filter); ui.open_popup(menu_id()); for _ in 0..40 { - menu_frame(&mut ui, BELOW, filter); + menu_frame(&mut ui, below, filter); } ui } /// The centre of the menu's row for `item`, from the latest layout. fn row(ui: &Ui, item: usize) -> [f32; 2] { - let rect = ui.rect(menu_id().child("rows").child(item)).unwrap(); + let rect = ui.laid_out(menu_id().child("rows").child(item)).unwrap(); [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0] } @@ -1189,7 +1214,7 @@ fn tip_frame(ui: &mut Ui) -> [Option<[f32; 4]>; 2] { tips[index] = id.child("tooltip"); } }); - tips.map(|tip| ui.rect(tip)) + tips.map(|tip| ui.laid_out(tip)) } /// Frames the pointer rests through for `millis`, returning the tooltips as they end. @@ -1241,10 +1266,18 @@ fn tooltips_wait_then_switch_at_once_and_hide_on_press_until_left() { assert!(rest(&mut ui, 500)[0].is_some()); } +#[test] +fn a_popup_opens_beside_a_box_first_laid_out_in_the_same_frame() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + ui.open_popup(menu_id()); + menu_frame(&mut ui, below, None); + assert_eq!(ui.laid_out(menu_id()).unwrap()[..2], [20.0, 44.0]); +} + #[test] fn popups_open_beside_their_anchor_and_flip_to_stay_in_the_window() { let mut ui = open_menu(None); - let rect = ui.rect(menu_id()).unwrap(); + let rect = ui.laid_out(menu_id()).unwrap(); assert_eq!( [rect[0], rect[1]], [20.0, 44.0], @@ -1252,17 +1285,19 @@ fn popups_open_beside_their_anchor_and_flip_to_stay_in_the_window() { ); assert_eq!(rect[2] - rect[0], 140.0, "no narrower than a menu"); let height = rect[3] - rect[1]; - menu_frame(&mut ui, Anchor::Below([300.0, 260.0, 380.0, 280.0]), None); - menu_frame(&mut ui, Anchor::Below([300.0, 260.0, 380.0, 280.0]), None); + let corner = |ui: &mut Ui| Anchor::Below(spot(ui, [300.0, 260.0, 380.0, 280.0])); + menu_frame(&mut ui, corner, None); + menu_frame(&mut ui, corner, None); assert_eq!( - ui.rect(menu_id()), + ui.laid_out(menu_id()), Some([240.0, 256.0 - height, 380.0, 256.0]), "above, level with the anchor's far edge" ); - menu_frame(&mut ui, Anchor::Right([350.0, 100.0, 390.0, 120.0]), None); - menu_frame(&mut ui, Anchor::Right([350.0, 100.0, 390.0, 120.0]), None); + let edge = |ui: &mut Ui| Anchor::Right(spot(ui, [350.0, 100.0, 390.0, 120.0])); + menu_frame(&mut ui, edge, None); + menu_frame(&mut ui, edge, None); assert_eq!( - ui.rect(menu_id()), + ui.laid_out(menu_id()), Some([210.0, 96.0, 350.0, 96.0 + height]), "a submenu that cannot fit to the right opens to the left, its first row level" ); @@ -1280,7 +1315,8 @@ fn popups_too_large_for_a_narrow_window_shrink_inside_its_margin() { let narrow = [100.0, 300.0]; let menu = |ui: &mut Ui| { sized_frame(ui, narrow, |ui| { - popup::menu(ui, menu_id(), BELOW, &items(), None); + let anchor = below(ui); + popup::menu(ui, menu_id(), anchor, &items(), None); }); }; menu(&mut ui); @@ -1288,14 +1324,14 @@ fn popups_too_large_for_a_narrow_window_shrink_inside_its_margin() { for _ in 0..40 { menu(&mut ui); } - let rect = ui.rect(menu_id()).unwrap(); + let rect = ui.laid_out(menu_id()).unwrap(); assert!(inside(rect, narrow), "{rect:?} leaves the window's margin"); assert_eq!( rect[2] - rect[0], 100.0 - 2.0 * POPUP_MARGIN, "as wide as fits" ); - let row = ui.rect(menu_id().child("rows").child(0_u64)).unwrap(); + let row = ui.laid_out(menu_id().child("rows").child(0_u64)).unwrap(); assert!(row[2] <= rect[2], "its rows narrow with it"); // A dialog taller than a short window scrolls what it holds. @@ -1333,15 +1369,15 @@ fn popups_too_large_for_a_narrow_window_shrink_inside_its_margin() { for _ in 0..40 { build(&mut ui); } - let rect = ui.rect(dialog).unwrap(); + let rect = ui.laid_out(dialog).unwrap(); assert!(inside(rect, short), "{rect:?} leaves the window's margin"); - let first = ui.rect(dialog.child(0)).unwrap(); + let first = ui.laid_out(dialog.child(0)).unwrap(); ui.event(Event::PointerMoved([200.0, 60.0])); ui.event(Event::Wheel([0.0, -100.0])); for _ in 0..40 { build(&mut ui); } - let scrolled = ui.rect(dialog.child(0)).unwrap(); + let scrolled = ui.laid_out(dialog.child(0)).unwrap(); assert!(scrolled[1] < first[1], "the wheel scrolls it"); for layer in ui.layers() { let Layer::Primitives(layer) = layer else { @@ -1361,32 +1397,32 @@ fn popups_too_large_for_a_narrow_window_shrink_inside_its_margin() { #[test] fn popups_show_at_once_by_their_anchor() { let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); - menu_frame(&mut ui, BELOW, None); + menu_frame(&mut ui, below, None); ui.open_popup(menu_id()); - menu_frame(&mut ui, BELOW, None); - menu_frame(&mut ui, BELOW, None); + menu_frame(&mut ui, below, None); + menu_frame(&mut ui, below, None); assert_eq!( - ui.rect(menu_id()).unwrap()[1], + ui.laid_out(menu_id()).unwrap()[1], 44.0, "right under the anchor" ); ui.close_popup(menu_id()); - menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None); + menu_frame(&mut ui, |_| Anchor::Point([50.0, 50.0]), None); ui.open_popup(menu_id()); - menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None); - menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None); - assert_eq!(ui.rect(menu_id()).unwrap()[..2], [50.0, 50.0]); + menu_frame(&mut ui, |_| Anchor::Point([50.0, 50.0]), None); + menu_frame(&mut ui, |_| Anchor::Point([50.0, 50.0]), None); + assert_eq!(ui.laid_out(menu_id()).unwrap()[..2], [50.0, 50.0]); } #[test] fn a_press_outside_dismisses_without_reaching_what_is_beneath() { let mut ui = open_menu(None); click(&mut ui, [300.0, 250.0]); - let (under, chosen) = menu_frame(&mut ui, BELOW, None); + let (under, chosen) = menu_frame(&mut ui, below, None); assert!(!ui.popup_open(menu_id()) && chosen.is_none()); assert!(!under.pressed && !under.clicked); click(&mut ui, [300.0, 250.0]); - let (under, _) = menu_frame(&mut ui, BELOW, None); + let (under, _) = menu_frame(&mut ui, below, None); assert!(under.clicked, "once closed, beneath takes presses again"); } @@ -1394,13 +1430,13 @@ fn a_press_outside_dismisses_without_reaching_what_is_beneath() { fn escape_dismisses_and_returns_the_focus() { let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); let page = Id::ROOT.child("under"); - menu_frame(&mut ui, BELOW, None); + menu_frame(&mut ui, below, None); ui.set_focus(Some(page)); ui.open_popup(menu_id()); - menu_frame(&mut ui, BELOW, None); + menu_frame(&mut ui, below, None); assert_eq!(ui.focused(), Some(menu_id())); ui.event(key(NamedKey::Escape)); - menu_frame(&mut ui, BELOW, None); + menu_frame(&mut ui, below, None); assert!(!ui.popup_open(menu_id())); assert_eq!(ui.focused(), Some(page)); } @@ -1409,11 +1445,11 @@ fn escape_dismisses_and_returns_the_focus() { fn input_over_a_popup_stays_with_it() { let mut ui = open_menu(None); ui.event(Event::PointerMoved(row(&ui, 1))); - let (under, _) = menu_frame(&mut ui, BELOW, None); + let (under, _) = menu_frame(&mut ui, below, None); assert!(!under.hovered); let point = row(&ui, 1); click(&mut ui, point); - let (under, chosen) = menu_frame(&mut ui, BELOW, None); + let (under, chosen) = menu_frame(&mut ui, below, None); assert_eq!(chosen, Some(1)); assert!(!under.pressed && !under.clicked); assert!(!ui.popup_open(menu_id()), "choosing closes it"); @@ -1424,7 +1460,7 @@ fn disabled_items_ignore_the_pointer() { let mut ui = open_menu(None); let point = row(&ui, 2); click(&mut ui, point); - let (_, chosen) = menu_frame(&mut ui, BELOW, None); + let (_, chosen) = menu_frame(&mut ui, below, None); assert_eq!(chosen, None); assert!(ui.popup_open(menu_id())); } @@ -1444,21 +1480,21 @@ fn keys_move_the_highlight_past_disabled_items() { for named in keys { ui.event(key(named)); } - menu_frame(&mut ui, BELOW, None); + menu_frame(&mut ui, below, None); assert_eq!(ui.popups[0].highlight, Some(highlight)); } ui.event(key(NamedKey::Enter)); - assert_eq!(menu_frame(&mut ui, BELOW, None).1, Some(3)); + assert_eq!(menu_frame(&mut ui, below, None).1, Some(3)); } #[test] fn the_pointer_moves_the_highlight_only_when_it_moves() { let mut ui = open_menu(None); ui.event(Event::PointerMoved(row(&ui, 1))); - menu_frame(&mut ui, BELOW, None); + menu_frame(&mut ui, below, None); ui.event(key(NamedKey::ArrowDown)); - menu_frame(&mut ui, BELOW, None); - menu_frame(&mut ui, BELOW, None); + menu_frame(&mut ui, below, None); + menu_frame(&mut ui, below, None); assert_eq!( ui.popups[0].highlight, Some(3), @@ -1483,7 +1519,8 @@ fn headings_are_never_chosen_and_hide_while_filtered() { let build = |ui: &mut Ui| { let mut chosen = None; frame(ui, |ui| { - chosen = popup::menu(ui, menu_id(), BELOW, &items, Some("Filter")); + let anchor = below(ui); + chosen = popup::menu(ui, menu_id(), anchor, &items, Some("Filter")); }); chosen }; @@ -1516,7 +1553,7 @@ fn typing_filters_to_the_best_matches_first() { } let settle = |ui: &mut Ui| { for _ in 0..30 { - menu_frame(ui, BELOW, Some("Filter")); + menu_frame(ui, below, Some("Filter")); } }; settle(&mut ui); @@ -1524,7 +1561,7 @@ fn typing_filters_to_the_best_matches_first() { let shown = |ui: &Ui| { let mut shown: Vec<_> = (0..4_usize) .filter_map(|item| { - let rect = ui.rect(menu_id().child("rows").child(item))?; + let rect = ui.laid_out(menu_id().child("rows").child(item))?; Some((rect[1] as i32, item)) }) .collect(); @@ -1541,20 +1578,20 @@ fn typing_filters_to_the_best_matches_first() { assert_eq!(shown(&ui), [0, 1, 3], "equal matches keep their order"); ui.event(key(NamedKey::ArrowDown)); ui.event(key(NamedKey::Enter)); - assert_eq!(menu_frame(&mut ui, BELOW, Some("Filter")).1, Some(1)); + assert_eq!(menu_frame(&mut ui, below, Some("Filter")).1, Some(1)); } #[test] fn nothing_matching_leaves_nothing_to_choose_and_enter_leaves_the_menu() { let mut ui = open_menu(Some("Filter")); ui.event(typed("z")); - menu_frame(&mut ui, BELOW, Some("Filter")); + menu_frame(&mut ui, below, Some("Filter")); ui.event(key(NamedKey::ArrowDown)); - let (_, chosen) = menu_frame(&mut ui, BELOW, Some("Filter")); + let (_, chosen) = menu_frame(&mut ui, below, Some("Filter")); assert_eq!(chosen, None); assert!(ui.popup_open(menu_id())); ui.event(key(NamedKey::Enter)); - let (_, chosen) = menu_frame(&mut ui, BELOW, Some("Filter")); + let (_, chosen) = menu_frame(&mut ui, below, Some("Filter")); assert_eq!(chosen, None); assert!(!ui.popup_open(menu_id())); } @@ -1579,7 +1616,7 @@ fn the_palette_centres_near_the_windows_top_and_chooses_the_best_match() { for _ in 0..40 { build(&mut ui); } - let rect = ui.rect(palette).unwrap(); + let rect = ui.laid_out(palette).unwrap(); assert_eq!(rect[0] + rect[2], 400.0, "centred"); assert_eq!(rect[1], 300.0 / 8.0, "an eighth down"); ui.event(typed("a")); @@ -1704,7 +1741,7 @@ fn the_palette_opens_an_items_actions_on_command_k_or_a_right_click() { ui.popup_open(palette) && !ui.popup_open(actions), "Escape goes back to the palette" ); - let row = ui.rect(palette.child("rows").child(3_u64)).unwrap(); + let row = ui.laid_out(palette.child("rows").child(3_u64)).unwrap(); ui.event(Event::PointerMoved([row[0] + 4.0, row[1] + 4.0])); ui.event(Event::Button { button: MouseButton::Right, @@ -1718,7 +1755,7 @@ fn the_palette_opens_an_items_actions_on_command_k_or_a_right_click() { ui.event(typed("c")); build(&mut ui); assert_eq!( - ui.rect(palette.child("rows").child(0_u64)), + ui.laid_out(palette.child("rows").child(0_u64)), None, "a repeated item shows only while unfiltered" ); @@ -1734,7 +1771,8 @@ fn colour_grids_move_in_two_dimensions_and_choose_a_swatch_or_none() { let build = |ui: &mut Ui| { let mut chosen = None; frame(ui, |ui| { - chosen = popup::colors(ui, grid, BELOW, "Automatic", &swatches, 3); + let anchor = below(ui); + chosen = popup::colors(ui, grid, anchor, "Automatic", &swatches, 3); }); chosen }; @@ -1755,7 +1793,7 @@ fn colour_grids_move_in_two_dimensions_and_choose_a_swatch_or_none() { for _ in 0..40 { build(&mut ui); } - let rect = ui.rect(grid.child(("cell", 0_usize))).unwrap(); + let rect = ui.laid_out(grid.child(("cell", 0_usize))).unwrap(); click(&mut ui, [rect[0] + 4.0, rect[1] + 4.0]); assert_eq!(build(&mut ui), Some(None)); } @@ -1767,7 +1805,8 @@ fn table_pickers_choose_columns_and_rows_by_keys_or_a_click() { let build = |ui: &mut Ui| { let mut chosen = None; frame(ui, |ui| { - chosen = popup::table_picker(ui, grid, BELOW, [4, 3]); + let anchor = below(ui); + chosen = popup::table_picker(ui, grid, anchor, [4, 3]); }); chosen }; @@ -1789,7 +1828,7 @@ fn table_pickers_choose_columns_and_rows_by_keys_or_a_click() { for _ in 0..40 { build(&mut ui); } - let rect = ui.rect(grid.child(("cell", 5_usize))).unwrap(); + let rect = ui.laid_out(grid.child(("cell", 5_usize))).unwrap(); click(&mut ui, [rect[0] + 4.0, rect[1] + 4.0]); assert_eq!(build(&mut ui), Some([2, 2])); } @@ -1804,7 +1843,7 @@ fn popups_opened_within_a_popup_stay_above_it() { menu_id(), Spec { size: [px(100.0), px(100.0)], - anchor: Some(BELOW), + anchor: Some(Anchor::Point([20.0, 44.0])), ..Spec::default() }, ); @@ -1812,13 +1851,8 @@ fn popups_opened_within_a_popup_stay_above_it() { ui.open_popup(submenu); } ui.close(); - popup::menu( - ui, - submenu, - Anchor::Right([100.0, 50.0, 120.0, 70.0]), - &items(), - None, - ); + let anchor = Anchor::Right(spot(ui, [100.0, 50.0, 120.0, 70.0])); + popup::menu(ui, submenu, anchor, &items(), None); }) }; build(&mut ui, true); @@ -1837,11 +1871,12 @@ fn a_submenu_opens_beside_its_row_once_the_pointer_rests_and_closes_with_its_cho let build = |ui: &mut Ui| { let mut chosen = None; frame(ui, |ui| { - chosen = popup::menu(ui, menu_id(), BELOW, &entries, None); + let anchor = below(ui); + chosen = popup::menu(ui, menu_id(), anchor, &entries, None); popup::submenus(ui, menu_id(), &entries, |item| { (item == 1).then_some(submenu) }); - popup::menu(ui, submenu, BELOW, &items(), None); + popup::menu(ui, submenu, anchor, &items(), None); }); chosen }; @@ -1860,7 +1895,7 @@ fn a_submenu_opens_beside_its_row_once_the_pointer_rests_and_closes_with_its_cho build(&mut ui); } assert!(ui.popup_open(submenu), "once it rests"); - let [menu, beside] = [menu_id(), submenu].map(|id| ui.rect(id).unwrap()); + let [menu, beside] = [menu_id(), submenu].map(|id| ui.laid_out(id).unwrap()); assert!( beside[0] >= menu[2] - 1.0, "beside the menu, not below the anchor" @@ -1878,7 +1913,7 @@ fn a_submenu_opens_beside_its_row_once_the_pointer_rests_and_closes_with_its_cho assert_eq!(build(&mut ui), None, "a click opens it, not a choice"); build(&mut ui); assert!(ui.popup_open(menu_id()) && ui.popup_open(submenu)); - let first = ui.rect(submenu.child("rows").child(0_u64)).unwrap(); + let first = ui.laid_out(submenu.child("rows").child(0_u64)).unwrap(); click(&mut ui, [first[0] + 4.0, first[1] + 4.0]); build(&mut ui); assert!( @@ -1967,7 +2002,7 @@ fn list_frame( } fn row_top(ui: &Ui, key: u64) -> Option { - ui.rect(list_id().child(key)).map(|rect| rect[1]) + ui.laid_out(list_id().child(key)).map(|rect| rect[1]) } /// Frames until the list's animations end. @@ -2071,7 +2106,8 @@ fn a_menu_runs_to_the_window_edge_before_it_scrolls() { .collect(); let build = |ui: &mut Ui, height: f32| { sized_frame(ui, [400.0, height], |ui| { - popup::menu(ui, menu_id(), BELOW, &items, None); + let anchor = below(ui); + popup::menu(ui, menu_id(), anchor, &items, None); }) }; let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); @@ -2080,9 +2116,9 @@ fn a_menu_runs_to_the_window_edge_before_it_scrolls() { for _ in 0..3 { build(&mut ui, 800.0); } - let bar = |ui: &Ui| ui.rect(menu_id().child("rows").child("bar")); + let bar = |ui: &Ui| ui.laid_out(menu_id().child("rows").child("bar")); assert!(bar(&ui).is_none(), "twenty rows fit a tall window whole"); - assert!(ui.rect(menu_id().child("rows").child(19u64)).is_some()); + assert!(ui.laid_out(menu_id().child("rows").child(19u64)).is_some()); for _ in 0..3 { build(&mut ui, 300.0); } @@ -2101,7 +2137,8 @@ fn a_long_menu_scrolls_by_dragging_its_thumb() { .collect(); let build = |ui: &mut Ui| { frame(ui, |ui| { - popup::menu(ui, menu_id(), BELOW, &items, None); + let anchor = below(ui); + popup::menu(ui, menu_id(), anchor, &items, None); }) }; let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); @@ -2109,9 +2146,9 @@ fn a_long_menu_scrolls_by_dragging_its_thumb() { ui.open_popup(menu_id()); build(&mut ui); build(&mut ui); - let thumb = ui.rect(menu_id().child("rows").child("bar")).unwrap(); + let thumb = ui.laid_out(menu_id().child("rows").child("bar")).unwrap(); let top = ui - .rect(menu_id().child("rows").child(0u64)) + .laid_out(menu_id().child("rows").child(0u64)) .map(|rect| rect[1]); let at = Instant::now(); let x = (thumb[0] + thumb[2]) / 2.0; @@ -2123,7 +2160,7 @@ fn a_long_menu_scrolls_by_dragging_its_thumb() { build(&mut ui); assert!(ui.popup_open(menu_id())); assert_ne!( - ui.rect(menu_id().child("rows").child(0u64)) + ui.laid_out(menu_id().child("rows").child(0u64)) .map(|rect| rect[1]), top ); @@ -2141,7 +2178,8 @@ fn a_long_menu_fades_the_ends_its_rows_are_cut_at_and_its_thumb_reaches_its_end( .collect(); let build = |ui: &mut Ui| { frame(ui, |ui| { - popup::menu(ui, menu_id(), BELOW, &items, None); + let anchor = below(ui); + popup::menu(ui, menu_id(), anchor, &items, None); }) }; let rows = menu_id().child("rows"); @@ -2152,7 +2190,7 @@ fn a_long_menu_fades_the_ends_its_rows_are_cut_at_and_its_thumb_reaches_its_end( build(&mut ui); build(&mut ui); assert_eq!(fade(&ui), [0.0, 12.0], "cut only at the bottom"); - let thumb = ui.rect(rows.child("bar")).unwrap(); + let thumb = ui.laid_out(rows.child("bar")).unwrap(); let x = (thumb[0] + thumb[2]) / 2.0; ui.event(Event::PointerMoved([x, thumb[1] + 2.0])); press(&mut ui, Instant::now(), true); @@ -2161,8 +2199,8 @@ fn a_long_menu_fades_the_ends_its_rows_are_cut_at_and_its_thumb_reaches_its_end( build(&mut ui); build(&mut ui); assert_eq!(fade(&ui), [12.0, 0.0], "cut only at the top"); - let list = ui.rect(rows).unwrap(); - let thumb = ui.rect(rows.child("bar")).unwrap(); + let list = ui.laid_out(rows).unwrap(); + let thumb = ui.laid_out(rows.child("bar")).unwrap(); assert_eq!( thumb[3], list[3] - 4.0, @@ -2187,7 +2225,8 @@ fn a_command_menu_opens_at_its_top_and_a_picker_on_its_current_value() { .collect(); let build = |ui: &mut Ui| { frame(ui, |ui| { - popup::menu(ui, menu_id(), BELOW, &items, None); + let anchor = below(ui); + popup::menu(ui, menu_id(), anchor, &items, None); }) }; let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); @@ -2197,8 +2236,8 @@ fn a_command_menu_opens_at_its_top_and_a_picker_on_its_current_value() { build(&mut ui); } assert_eq!(ui.popups[0].highlight, picker.then_some(30)); - let top = ui.rect(rows).unwrap()[1]; - let first = ui.rect(rows.child(0u64)).map(|rect| rect[1]); + let top = ui.laid_out(rows).unwrap()[1]; + let first = ui.laid_out(rows.child(0u64)).map(|rect| rect[1]); assert_eq!(first == Some(top), !picker, "{first:?} under {top}"); } } @@ -2347,7 +2386,8 @@ fn a_filtered_menu_shows_its_new_results_at_once_from_the_top() { let fonts = fonts(); let build = |ui: &mut Ui| { frame(ui, |ui| { - popup::menu(ui, menu_id(), BELOW, &fonts, Some("Font")); + let anchor = below(ui); + popup::menu(ui, menu_id(), anchor, &fonts, Some("Font")); }) }; let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); @@ -2358,7 +2398,7 @@ fn a_filtered_menu_shows_its_new_results_at_once_from_the_top() { } let results = menu_id().child("rows"); let height = |ui: &Ui| { - let rect = ui.rect(results).unwrap(); + let rect = ui.laid_out(results).unwrap(); rect[3] - rect[1] }; assert!( @@ -2373,15 +2413,18 @@ fn a_filtered_menu_shows_its_new_results_at_once_from_the_top() { for query in ["t", "nr"] { ui.event(typed(query)); build(&mut ui); - let shown = [ui.rect(results), ui.rect(results.child(42_u64))]; + let shown = [ui.laid_out(results), ui.laid_out(results.child(42_u64))]; build(&mut ui); assert!(!ui.wants_frame(), "nothing eases"); - assert_eq!([ui.rect(results), ui.rect(results.child(42_u64))], shown); + assert_eq!( + [ui.laid_out(results), ui.laid_out(results.child(42_u64))], + shown + ); } - let row = ui.rect(results.child(42_u64)).unwrap(); + let row = ui.laid_out(results.child(42_u64)).unwrap(); assert_eq!( row[1], - ui.rect(results).unwrap()[1], + ui.laid_out(results).unwrap()[1], "Times New Roman at the top" ); assert_eq!(height(&ui), popup::MENU_ROW); @@ -2599,7 +2642,7 @@ fn a_dragged_box_lands_past_the_middles_it_crossed_and_the_rest_slide_aside() { /// Clicks the middle of `id`'s box as laid out now, where it must be hit. fn click_box(ui: &mut Ui, id: Id) { - let rect = ui.rect(id).unwrap(); + let rect = ui.laid_out(id).unwrap(); let middle = [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0]; assert_eq!( ui.box_at(middle), @@ -2662,7 +2705,7 @@ fn dialog_frame(ui: &mut Ui, text: &mut String) -> Reported { text, ..popup::Item::default() }); - let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + let anchor = Anchor::Below(combo); reported.scheme = popup::menu(ui, Id::ROOT.child("schemes"), anchor, &items, None); ui.close(); }); @@ -2722,8 +2765,7 @@ fn a_combo_s_field_widens_over_rows_laid_out_where_they_end_and_its_rows_take_cl ); shell::combo(ui, "combo", "Font", "Calibri", 120.0, menu_id(), true); ui.close(); - let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); - chosen = popup::menu(ui, menu_id(), anchor, &fonts, Some("Font")); + chosen = popup::menu(ui, menu_id(), Anchor::Over(combo), &fonts, Some("Font")); }); chosen }; @@ -2731,12 +2773,12 @@ fn a_combo_s_field_widens_over_rows_laid_out_where_they_end_and_its_rows_take_cl ui.open_popup(menu_id()); build(&mut ui); let field = menu_id().child("filter"); - let [box_rect, first] = [ui.rect(combo).unwrap(), ui.rect(field).unwrap()]; + let [box_rect, first] = [ui.laid_out(combo).unwrap(), ui.laid_out(field).unwrap()]; assert_eq!(first[..2], box_rect[..2], "the field starts on the box"); assert!(first[2] - first[0] < box_rect[2] - box_rect[0] + 20.0); - let shown = ui.rect(menu_id()).unwrap(); + let shown = ui.laid_out(menu_id()).unwrap(); let row = menu_id().child("rows").child(3_u64); - let opening = ui.rect(row).unwrap(); + let opening = ui.laid_out(row).unwrap(); assert!( opening[2] > shown[2], "the rows lie out past the popup as it opens" @@ -2770,9 +2812,9 @@ fn a_combo_s_field_widens_over_rows_laid_out_where_they_end_and_its_rows_take_cl build(&mut ui); } assert!(!ui.wants_frame()); - let [panel, last] = [ui.rect(menu_id()).unwrap(), ui.rect(field).unwrap()]; + let [panel, last] = [ui.laid_out(menu_id()).unwrap(), ui.laid_out(field).unwrap()]; assert_eq!(last[2], panel[2] - 4.0, "the field widens across the popup"); - assert_eq!(ui.rect(row), Some(opening), "the rows never move"); + assert_eq!(ui.laid_out(row), Some(opening), "the rows never move"); click_box(&mut ui, menu_id().child("rows").child(3_u64)); assert_eq!(build(&mut ui), Some(3)); } @@ -2781,9 +2823,9 @@ fn a_combo_s_field_widens_over_rows_laid_out_where_they_end_and_its_rows_take_cl fn a_popup_over_a_box_by_the_window_s_edge_widens_away_from_it() { let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK); let fonts = fonts(); - let anchor = Anchor::Over([330.0, 10.0, 390.0, 30.0]); let build = |ui: &mut Ui| { frame(ui, |ui| { + let anchor = Anchor::Over(spot(ui, [330.0, 10.0, 390.0, 30.0])); _ = popup::menu(ui, menu_id(), anchor, &fonts, Some("Font")) }) }; @@ -2791,11 +2833,12 @@ fn a_popup_over_a_box_by_the_window_s_edge_widens_away_from_it() { ui.open_popup(menu_id()); build(&mut ui); let field = menu_id().child("filter"); - let edges = - |ui: &Ui| [ui.rect(field).unwrap(), ui.rect(menu_id()).unwrap()].map(|rect| rect[2]); - let first = ui.rect(field).unwrap(); + let edges = |ui: &Ui| { + [ui.laid_out(field).unwrap(), ui.laid_out(menu_id()).unwrap()].map(|rect| rect[2]) + }; + let first = ui.laid_out(field).unwrap(); let row = menu_id().child("rows").child(3_u64); - let opening = ui.rect(row).unwrap(); + let opening = ui.laid_out(row).unwrap(); assert_eq!( edges(&ui), [390.0, 394.0], @@ -2807,10 +2850,10 @@ fn a_popup_over_a_box_by_the_window_s_edge_widens_away_from_it() { } assert_eq!(edges(&ui), [390.0, 394.0], "the trailing edges hold"); assert!( - ui.rect(field).unwrap()[0] < first[0] - 30.0, + ui.laid_out(field).unwrap()[0] < first[0] - 30.0, "the field widens leftward" ); - assert_eq!(ui.rect(row), Some(opening), "the rows never move"); + assert_eq!(ui.laid_out(row), Some(opening), "the rows never move"); } fn built(ui: &Ui, id: Id) -> &Built { @@ -2835,7 +2878,8 @@ fn split_frame(ui: &mut Ui) -> Signal { text: "Item", ..Default::default() }; - popup::menu(ui, split_menu(), BELOW, &[item], None); + let anchor = below(ui); + popup::menu(ui, split_menu(), anchor, &[item], None); ui.close(); }); signal @@ -2867,24 +2911,24 @@ fn a_split_button_fills_its_button_alone_and_outlines_both_halves_from_its_arrow split_frame(ui); } }; - ui.event(Event::PointerMoved(center(ui.rect(button).unwrap()))); + ui.event(Event::PointerMoved(center(ui.laid_out(button).unwrap()))); settle(&mut ui); // The fill rounds past the button's clip, so it meets the arrow square. - let [left, top, right, bottom] = ui.rect(button).unwrap(); + let [left, top, right, bottom] = ui.laid_out(button).unwrap(); assert!(built(&ui, face).fill.is_some()); - assert_eq!(ui.rect(face), Some([left, top, right + 4.0, bottom])); + assert_eq!(ui.laid_out(face), Some([left, top, right + 4.0, bottom])); assert!(built(&ui, button).flags.contains(Flags::CLIP)); assert!(built(&ui, ring).border.is_none()); - ui.event(Event::PointerMoved(center(ui.rect(arrow).unwrap()))); + ui.event(Event::PointerMoved(center(ui.laid_out(arrow).unwrap()))); settle(&mut ui); assert!(built(&ui, face).fill.is_none()); assert!(built(&ui, ring).border.is_some()); assert_eq!( - ui.rect(ring), - Some([left, top, ui.rect(arrow).unwrap()[2], bottom]) + ui.laid_out(ring), + Some([left, top, ui.laid_out(arrow).unwrap()[2], bottom]) ); // The arrow opens the menu, and the outline stays while it is open. - let point = center(ui.rect(arrow).unwrap()); + let point = center(ui.laid_out(arrow).unwrap()); click(&mut ui, point); assert!(!split_frame(&mut ui).clicked); assert!(ui.popup_open(split_menu())); @@ -2893,7 +2937,7 @@ fn a_split_button_fills_its_button_alone_and_outlines_both_halves_from_its_arrow assert!(built(&ui, ring).border.is_some()); ui.close_popup(split_menu()); settle(&mut ui); - let point = center(ui.rect(button).unwrap()); + let point = center(ui.laid_out(button).unwrap()); click(&mut ui, point); assert!(split_frame(&mut ui).clicked); assert!(!ui.popup_open(split_menu())); @@ -2932,7 +2976,7 @@ fn a_menu_button_opens_its_menu_with_the_icons_under_its_own() { }); }; build(&mut ui); - let button = ui.rect(Id::ROOT.child("bar").child("align")).unwrap(); + let button = ui.laid_out(Id::ROOT.child("bar").child("align")).unwrap(); // One target: the arrow opens the menu as the icon does. click(&mut ui, [button[2] - 3.0, center(button)[1]]); for _ in 0..40 { @@ -2940,7 +2984,7 @@ fn a_menu_button_opens_its_menu_with_the_icons_under_its_own() { } assert!(ui.popup_open(menu_id())); let icon = ui - .rect( + .laid_out( menu_id() .child("rows") .child(0_usize) @@ -2965,7 +3009,8 @@ fn galleries_choose_across_their_groups_by_keys_and_clicks() { let build = |ui: &mut Ui| { let mut chosen = None; frame(ui, |ui| { - chosen = popup::gallery(ui, gallery, BELOW, &groups, &[3], |ui, index| { + let anchor = below(ui); + chosen = popup::gallery(ui, gallery, anchor, &groups, &[3], |ui, index| { ui.leaf( "label", Spec { @@ -2990,7 +3035,7 @@ fn galleries_choose_across_their_groups_by_keys_and_clicks() { for _ in 0..40 { build(&mut ui); } - let cell = |index: usize| ui.rect(gallery.child(("cell", index))).unwrap(); + let cell = |index: usize| ui.laid_out(gallery.child(("cell", index))).unwrap(); // The library starts a row of its own under its heading. assert_eq!(cell(2)[0], cell(0)[0]); assert!(cell(2)[1] > cell(0)[3]); @@ -3007,7 +3052,7 @@ fn desktop_menus_cut_or_fade_without_growing() { Layer::Custom { .. } => None, }) }; - let at = Anchor::Point([50.0, 50.0]); + let at = |_: &mut Ui| Anchor::Point([50.0, 50.0]); let styled = |motion| { let mut theme = Theme::light(); theme.desktop_menu = Some(Menu { @@ -3066,7 +3111,9 @@ fn tab_row(ui: &mut Ui, active: usize) -> Id { ..Spec::default() }, ); - shell::section_tabs(ui, row, &tabs, active, None, None, §ion, 28.0, [0.0; 4]); + shell::section_tabs( + ui, row, &tabs, active, None, None, None, §ion, 28.0, [0.0; 4], + ); ui.close(); }); row @@ -3085,10 +3132,10 @@ fn settle_tabs(ui: &mut Ui, active: usize) -> Id { fn overflowing_tabs_scroll_the_open_one_into_view() { let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); let row = settle_tabs(&mut ui, 0); - let view = ui.rect(row).unwrap(); + let view = ui.laid_out(row).unwrap(); assert_eq!(view[2] - view[0], 200.0); let row = settle_tabs(&mut ui, 11); - let last = ui.rect(shell::tab_id(row, 11)).unwrap(); + let last = ui.laid_out(shell::tab_id(row, 11)).unwrap(); assert!( last[0] >= view[0] && last[2] <= view[2], "{last:?} in {view:?}" @@ -3099,17 +3146,17 @@ fn overflowing_tabs_scroll_the_open_one_into_view() { fn the_wheel_scrolls_overflowing_tabs_sideways_either_way_it_turns() { let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); let row = settle_tabs(&mut ui, 0); - let first = ui.rect(shell::tab_id(row, 0)).unwrap()[0]; + let first = ui.laid_out(shell::tab_id(row, 0)).unwrap()[0]; ui.event(Event::PointerMoved([100.0, 14.0])); ui.event(Event::Wheel([0.0, -40.0])); tab_row(&mut ui, 0); tab_row(&mut ui, 0); - let scrolled = ui.rect(shell::tab_id(row, 0)).unwrap()[0]; + let scrolled = ui.laid_out(shell::tab_id(row, 0)).unwrap()[0]; assert_eq!(first - scrolled, 40.0, "at once, as on the page"); ui.event(Event::Wheel([30.0, 0.0])); tab_row(&mut ui, 0); tab_row(&mut ui, 0); - let back = ui.rect(shell::tab_id(row, 0)).unwrap()[0]; + let back = ui.laid_out(shell::tab_id(row, 0)).unwrap()[0]; assert_eq!(back - scrolled, 30.0); } @@ -3120,10 +3167,11 @@ fn a_colour_picker_applies_its_colour_and_keeps_it_through_hsl() { let build = |ui: &mut Ui| { let mut chosen = None; frame(ui, |ui| { + let anchor = below(ui); chosen = popup::color_picker( ui, picker, - BELOW, + anchor, "Custom Color", [0xd4, 0xf9, 0xf2], |_| [1.0; 4], @@ -3246,7 +3294,7 @@ fn a_loose_dialog_fits_its_contents_to_the_window_and_its_side_stretches_to_its_ ui.close(); }); }; - let height = |ui: &Ui, id: Id| ui.rect(id).map(|rect| rect[3] - rect[1]).unwrap(); + let height = |ui: &Ui, id: Id| ui.laid_out(id).map(|rect| rect[3] - rect[1]).unwrap(); let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); build(&mut ui, 2); ui.open_popup(dialog); @@ -3271,7 +3319,7 @@ fn a_loose_dialog_fits_its_contents_to_the_window_and_its_side_stretches_to_its_ for _ in 0..60 { build(&mut ui, 20); } - let [top, row] = [list, list.child(10_usize)].map(|id| ui.rect(id).unwrap()[1]); + let [top, row] = [list, list.child(10_usize)].map(|id| ui.laid_out(id).unwrap()[1]); assert!( (row - top).abs() < 0.5, "row 10 at {row}, the list's top at {top}" @@ -3316,7 +3364,7 @@ fn a_box_has_no_rectangle_until_it_is_first_laid_out() { ..Spec::default() }, ); - ui.rect(Id::ROOT.child("new")) + ui.laid_out(Id::ROOT.child("new")) }; let mut seen = Vec::new(); frame(&mut ui, |ui| seen.push(build(ui))); diff --git a/crates/ui/src/widgets.rs b/crates/ui/src/widgets.rs index 61d7285b132fd87357c04bdcb288aac6c878caa2..705b7671088bc5879efea9fea4831433cef6ec8c 100644 --- a/crates/ui/src/widgets.rs +++ b/crates/ui/src/widgets.rs @@ -360,7 +360,7 @@ pub fn scrollbar( return system_scrollbar(ui, part, axis, offset, range, view, corner); } let along = usize::from(axis == Axis::Y); - let rect = ui.rect(ui.current())?; + let rect = ui.laid_out(ui.current())?; let length = rect[along + 2] - rect[along]; let cross = rect[3 - along] - rect[1 - along]; let span = range[1] - range[0]; @@ -415,7 +415,7 @@ fn system_scrollbar( corner: bool, ) -> Option { let along = usize::from(axis == Axis::Y); - let rect = ui.rect(ui.current())?; + let rect = ui.laid_out(ui.current())?; let span = range[1] - range[0]; let thickness = ui.scrollers.as_ref()?.thickness; let length = rect[along + 2] - rect[along] - if corner { thickness } else { 0.0 }; @@ -572,7 +572,7 @@ fn field( let modifiers = edit_modifiers(ui.modifiers()); let pointer = ui .pointer() - .zip(ui.rect(id)) + .zip(ui.laid_out(id)) .map(|(pointer, rect)| pointer[0] - rect[0] - pad) .filter(|_| signal.pressed || signal.dragging); let state = ui.state(id); -- 2.54.0