From c952b8d298afaf6525ebc3eb759bb68e8d83be2f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 18:33:50 -0800 Subject: [PATCH] Fix #5347: a floating panel's menu scrolls inside its window The main window's menu floor was stored once for the whole egui context, so a menu in a panel's OS window stopped at the main window's bottom bars instead of its own edge. The floor is now kept per window, and a window with no recorded floor uses its bottom edge. A menu list is also never taller than the window, so the last items of a menu stay reachable in a 100 pt window, below the 120 pt minimum. The Digits, Stylistic Sets, Save Selection and Move to Group submenus, the libraries menu, the Color Guide's Limit to Library menu and the Layers panel's Filter popup now scroll the same way. --- .../ui-egui/src/panel_docking/native_tests.rs | 156 ++++++++++++++++++ crates/ui-egui/src/panels/character.rs | 2 +- crates/ui-egui/src/panels/color_guide.rs | 2 +- crates/ui-egui/src/panels/layers/controls.rs | 72 ++++---- crates/ui-egui/src/panels/libraries.rs | 38 +++-- crates/ui-egui/src/panels/library_panel.rs | 14 +- crates/ui-egui/src/panels/opentype.rs | 14 +- crates/ui-egui/src/widgets.rs | 35 +++- crates/ui-egui/src/widgets/dropdowns.rs | 39 +++-- docs/target-app-parity.md | 1 + 10 files changed, 294 insertions(+), 79 deletions(-) diff --git a/crates/ui-egui/src/panel_docking/native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs index a7c661404..7f52af524 100644 --- a/crates/ui-egui/src/panel_docking/native_tests.rs +++ b/crates/ui-egui/src/panel_docking/native_tests.rs @@ -321,6 +321,162 @@ fn a_floated_panel_opens_its_own_os_window_where_the_backend_has_viewports() { assert!(tab, "its tab is drawn in the app window"); } +/// Sets the height of the OS window `viewport`, as a user's resize does. +fn resize_window(h: &mut Harness, viewport: ViewportId, height: f32) { + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().rect.set_height(height); + h.frames(3); +} + +/// A click on the `side` pt square button with the highest `rank` in the OS window `viewport`, and +/// the frames its menu takes to settle. Returns where the button is. +fn open_button_menu(h: &mut Harness, viewport: ViewportId, side: f32, rank: impl Fn(Pos2) -> f32) -> Pos2 { + let ctx = h.ctx.clone(); + let menu = ctx + .viewport_for(viewport, |vp| { + vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).map(|w| (w.rect, w.sense.senses_click())).collect::>() + }) + .into_iter() + .filter(|(rect, clicks)| *clicks && rect.size() == vec2(side, side)) + .map(|(rect, _)| rect.center()) + .max_by(|a, b| rank(*a).total_cmp(&rank(*b))) + .expect("the menu's button"); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([Event::PointerMoved(menu), button(menu, true), button(menu, false)]); + h.frames(8); + menu +} + +/// A click on the ≡ button in the OS window `viewport`, the 16 pt square at the right end of the +/// panel's tab strip. Returns where the button is. +fn open_panel_menu(h: &mut Harness, viewport: ViewportId) -> Pos2 { + open_button_menu(h, viewport, 16.0, |at| at.x) +} + +/// The rect of the text `wanted` in the last frame of the OS window `viewport`: the last one drawn, +/// so a menu's item comes before a panel's text of the same name. +fn text_rect(h: &Harness, viewport: ViewportId, wanted: &str) -> Option { + h.host.borrow().windows.get(&viewport).unwrap().texts.iter().rfind(|(text, _)| text.trim() == wanted).map(|(_, rect)| *rect) +} + +/// Turns the wheel down by `by` points with the pointer at `over` in the OS window `viewport`, and +/// lets the scrolling finish. +fn wheel_down(h: &mut Harness, viewport: ViewportId, over: Pos2, by: f32) { + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ + Event::PointerMoved(over), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -by), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }, + ]); + h.frames(40); +} + +/// A floating panel's window of each height keeps its ≡ menu inside it: the list scrolls, and the +/// wheel brings the last item into the window. The list opens level with the button where the +/// window has room for it, and does not slide up to fit. 100 pt is the shortest a window can be +/// resized to, and there the list takes the whole window. +#[test] +fn a_long_panel_menu_scrolls_inside_a_short_native_window() { + for (height, level_with_the_button) in [(240.0, true), (100.0, false)] { + let mut h = Harness::new(); + let viewport = h.float("layers"); + resize_window(&mut h, viewport, height); + let menu = open_panel_menu(&mut h, viewport); + if level_with_the_button { + let first = text_rect(&h, viewport, "New Layer…").expect("the menu's first item"); + assert!(first.top() >= menu.y - 16.0, "the first item at {first:?} is level with the button at {menu:?}"); + } + let last_item = |h: &Harness| text_rect(h, viewport, "Dock Panel"); + assert!(last_item(&h).is_none_or(|r| r.bottom() > height), "the {height} pt window starts at the top of the list"); + wheel_down(&mut h, viewport, pos2(menu.x - 60.0, height - 30.0), 5000.0); + let item = last_item(&h).unwrap_or_else(|| panic!("the wheel brings the menu's last item into the {height} pt window")); + assert!(item.bottom() <= height, "the last item at {item:?} is inside the {height} pt window"); + } +} + +/// The Layers menu's Save Selection submenu lists every saved selection: in a short window the +/// wheel brings the last one into view. +#[test] +fn a_long_panel_submenu_scrolls_inside_a_short_native_window() { + let mut h = Harness::new(); + h.app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + h.app.run("select.all", json!({})).unwrap(); + // A document keeps at most 25 saved selections. + for n in 0..25 { + h.app.run("select.save", json!({"name": format!("Saved {n}")})).unwrap(); + } + assert_submenu_reaches_its_end(&mut h, "layers", 240.0, "Save Selection", "Saved 0", "Saved 24"); +} + +/// The Swatches menu's Open Swatch Library lists the libraries between its head and tail items. +#[test] +fn the_library_submenu_scrolls_inside_a_short_native_window() { + let mut h = Harness::new(); + assert_submenu_reaches_its_end(&mut h, "swatches", 240.0, "Open Swatch Library", "Default Swatches", "Other Library…"); +} + +/// The Layers panel's Filter popup, under the topmost 24 pt button: the wheel brings its last row +/// into a short window. +#[test] +fn the_layers_filter_popup_scrolls_inside_a_short_native_window() { + let mut h = Harness::new(); + let viewport = h.float("layers"); + resize_window(&mut h, viewport, 160.0); + open_button_menu(&mut h, viewport, 24.0, |at| -at.y); + let over = text_rect(&h, viewport, "By Object").expect("the popup's By Object heading"); + assert!(text_rect(&h, viewport, "Hidden").is_none_or(|end| end.bottom() > 160.0), "the popup opens at the top of its list"); + wheel_down(&mut h, viewport, over.center(), 5000.0); + let end = text_rect(&h, viewport, "Hidden").expect("the wheel brings the popup's last row into the window"); + assert!(end.bottom() <= 160.0, "the last row at {end:?} is inside the window"); +} + +/// The Color Guide's Limit to Library popup, on the leftmost button of the bottom bar: with the +/// window made short while the popup is open, the wheel moves the list up past its Document +/// Swatches row. +#[test] +fn the_color_guide_library_popup_scrolls_inside_a_short_native_window() { + let mut h = Harness::new(); + let viewport = h.float("colorGuide"); + resize_window(&mut h, viewport, 320.0); + open_button_menu(&mut h, viewport, 24.0, |at| at.y * 1000.0 - at.x); + resize_window(&mut h, viewport, 100.0); + let first = text_rect(&h, viewport, "Document Swatches").expect("the popup's Document Swatches row"); + wheel_down(&mut h, viewport, first.center(), 5000.0); + let moved = text_rect(&h, viewport, "Document Swatches").is_none_or(|row| row.top() < first.top()); + assert!(moved, "the wheel scrolls the list that starts at {first:?}"); +} + +/// In an OS window of `panel`, `height` tall, scrolls the ≡ menu until its row `row` shows, clicks +/// the row to open its submenu, turns the wheel over the submenu's item `first`, and checks that its +/// item `last` comes into the window. +fn assert_submenu_reaches_its_end(h: &mut Harness, panel: &str, height: f32, row: &str, first: &str, last: &str) { + let viewport = h.float(panel); + resize_window(h, viewport, height); + let menu = open_panel_menu(h, viewport); + let inside = |h: &Harness| text_rect(h, viewport, row).filter(|rect| rect.bottom() < height - 12.0); + for _ in 0..40 { + if inside(h).is_some() { + break; + } + wheel_down(h, viewport, pos2(menu.x - 60.0, height - 30.0), 40.0); + } + let at = inside(h).unwrap_or_else(|| panic!("the {row} row")).center(); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([Event::PointerMoved(at), button(at, true), button(at, false)]); + h.frames(8); + let over = text_rect(h, viewport, first).unwrap_or_else(|| panic!("the {row} submenu's item {first}")); + assert!(text_rect(h, viewport, last).is_none_or(|end| end.bottom() > height), "the submenu opens at the top of its list"); + for _ in 0..3 { + if text_rect(h, viewport, last).is_some() { + break; + } + wheel_down(h, viewport, pos2(over.center().x, height - 30.0), 5000.0); + } + let end = text_rect(h, viewport, last).unwrap_or_else(|| panic!("the {row} submenu's item {last}")); + assert!(end.bottom() <= height, "{last} at {end:?} is inside the {height} pt window"); +} + #[test] fn keys_typed_in_a_native_window_run_the_main_windows_shortcuts() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 9dd969c5c..9f9c66ba5 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -1321,7 +1321,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if app.session.prefs.show_indic_options { let digits = shared(app, |s| s.digits); ui.add_enabled_ui(has, |ui| { - ui.menu_button(format!(" {}", tl!("Digits")), |ui| digits_items(app, ui, digits)); + ui.menu_button(format!(" {}", tl!("Digits")), |ui| widgets::menu_scroll(ui, |ui| digits_items(app, ui, digits))); }); ui.add_enabled_ui(has, |ui| { ui.menu_button(format!(" {}", tl!("Character Direction")), |ui| char_direction_items(app, ui)); diff --git a/crates/ui-egui/src/panels/color_guide.rs b/crates/ui-egui/src/panels/color_guide.rs index 28534e443..3ef2fc75e 100644 --- a/crates/ui-egui/src/panels/color_guide.rs +++ b/crates/ui-egui/src/panels/color_guide.rs @@ -233,7 +233,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let lr = widgets::icon_button(ui, "library", "", limit.is_some(), 24.0); lr.widget_info(|| widgets::icon_button_info(ui.is_enabled(), limit.is_some(), &tip)); let lr = lr.on_hover_text(tip); - egui::Popup::menu(&lr).show(|ui| limit_menu(app, ui)); + egui::Popup::menu(&lr).show(|ui| widgets::menu_scroll(ui, |ui| limit_menu(app, ui))); ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); // Recolor Artwork with the harmony colours as the new colours: the selected art's, or // (without art) the colours themselves, which OK saves as a colour group. diff --git a/crates/ui-egui/src/panels/layers/controls.rs b/crates/ui-egui/src/panels/layers/controls.rs index ab1ca44a2..ca0e9f99e 100644 --- a/crates/ui-egui/src/panels/layers/controls.rs +++ b/crates/ui-egui/src/panels/layers/controls.rs @@ -12,25 +12,27 @@ pub(super) fn filter_button(app: &mut VectorcraftApp, ui: &mut Ui) { let button = widgets::icon_button(ui, "dc-filter", tl!("Filter"), f.is_on(), 24.0); let mut set = None; egui::Popup::menu(&button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - ui.horizontal(|ui| { - widgets::subheader(ui, tl!("Filter")); - ui.add_space(12.0); - if ui.add_enabled(f.is_on(), egui::Button::new(egui::RichText::new(tl!("Clear All")).size(12.5)).frame(false)).clicked() { - set = Some(json!({"objects": [], "layers": []})); + widgets::menu_scroll(ui, |ui| { + ui.horizontal(|ui| { + widgets::subheader(ui, tl!("Filter")); + ui.add_space(12.0); + if ui.add_enabled(f.is_on(), egui::Button::new(egui::RichText::new(tl!("Clear All")).size(12.5)).frame(false)).clicked() { + set = Some(json!({"objects": [], "layers": []})); + } + }); + widgets::subheader(ui, tl!("By Object")); + for (k, _) in ObjectKind::ALL { + if widgets::check(ui, k.label(), f.objects.contains(&k), true) { + set = Some(json!({"objects": toggled(&ObjectKind::ALL, &f.objects, k)})); + } + } + widgets::subheader(ui, tl!("By Layer")); + for (s, _) in RowState::ALL { + if widgets::check(ui, s.label(), f.layers.contains(&s), true) { + set = Some(json!({"layers": toggled(&RowState::ALL, &f.layers, s)})); + } } }); - widgets::subheader(ui, tl!("By Object")); - for (k, _) in ObjectKind::ALL { - if widgets::check(ui, k.label(), f.objects.contains(&k), true) { - set = Some(json!({"objects": toggled(&ObjectKind::ALL, &f.objects, k)})); - } - } - widgets::subheader(ui, tl!("By Layer")); - for (s, _) in RowState::ALL { - if widgets::check(ui, s.label(), f.layers.contains(&s), true) { - set = Some(json!({"layers": toggled(&RowState::ALL, &f.layers, s)})); - } - } }); if let Some(p) = set { app.run("ui.layersFilter", p).ok(); @@ -245,24 +247,28 @@ pub(super) fn collect_for_export_menu(ui: &mut Ui, enabled: bool, mut run: impl /// the Select menu, then the active document's saved selections: choosing one recalls it. fn save_selection_menu(app: &mut VectorcraftApp, ui: &mut Ui) { ui.menu_button(format!(" {}", tl!("Save Selection")), |ui| { - for (label, id) in [("Save Selection…", "select.save"), ("Update Selection", "select.update"), ("Edit Selection…", "select.editSaved")] { - if widgets::menu_item(ui, label, crate::menus::enabled(app, id), false) { - crate::menus::invoke(app, id, json!({})); - ui.close(); + widgets::menu_scroll(ui, |ui| { + for (label, id) in [("Save Selection…", "select.save"), ("Update Selection", "select.update"), ("Edit Selection…", "select.editSaved")] + { + if widgets::menu_item(ui, label, crate::menus::enabled(app, id), false) { + crate::menus::invoke(app, id, json!({})); + ui.close(); + } } - } - let names: Vec = app.session.active().map(|d| d.doc.saved_selections.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); - if !names.is_empty() { - ui.separator(); - } - for name in names { - if widgets::menu_item_name(ui, &name, true, false) { - let selected = last_selected(app); - app.run("select.recall", json!({ "name": name })).ok(); - see_selection(app, ui, selected); - ui.close(); + let names: Vec = + app.session.active().map(|d| d.doc.saved_selections.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); + if !names.is_empty() { + ui.separator(); } - } + for name in names { + if widgets::menu_item_name(ui, &name, true, false) { + let selected = last_selected(app); + app.run("select.recall", json!({ "name": name })).ok(); + see_selection(app, ui, selected); + ui.close(); + } + } + }); }); } diff --git a/crates/ui-egui/src/panels/libraries.rs b/crates/ui-egui/src/panels/libraries.rs index 33bcbb047..ac92a4279 100644 --- a/crates/ui-egui/src/panels/libraries.rs +++ b/crates/ui-egui/src/panels/libraries.rs @@ -600,27 +600,29 @@ impl Items<'_> { let at = self.group_of(kind, key); let item = json!({ "kind": kind_param(kind), "item": key }); ui.menu_button(format!(" {}", tl!("Move to Group")), |ui| { - if menu_item(ui, "No Group", true, at.is_none()) { - if at.is_some() { - *act = Some(Act::Run("library.moveItem", item.clone())); - } - ui.close(); - } - for (i, g) in self.lib.groups.iter().enumerate() { - if menu_item_name(ui, &g.name, true, at == Some(i)) { - if at != Some(i) { - let mut p = item.clone(); - p["group"] = json!(g.name); - *act = Some(Act::Run("library.moveItem", p)); + widgets::menu_scroll(ui, |ui| { + if menu_item(ui, "No Group", true, at.is_none()) { + if at.is_some() { + *act = Some(Act::Run("library.moveItem", item.clone())); } ui.close(); } - } - ui.separator(); - if menu_item(ui, "New Group…", true, false) { - *act = Some(Act::NewGroup(json!([item]))); - ui.close(); - } + for (i, g) in self.lib.groups.iter().enumerate() { + if menu_item_name(ui, &g.name, true, at == Some(i)) { + if at != Some(i) { + let mut p = item.clone(); + p["group"] = json!(g.name); + *act = Some(Act::Run("library.moveItem", p)); + } + ui.close(); + } + } + ui.separator(); + if menu_item(ui, "New Group…", true, false) { + *act = Some(Act::NewGroup(json!([item]))); + ui.close(); + } + }); }); if menu_item(ui, "Delete", true, false) { *act = Some(Act::Run("library.removeItem", item)); diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 7ba7a8781..4607edbf9 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -795,12 +795,14 @@ fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { /// The libraries menu (a library panel's library button, the Swatches panel's): every library by /// submenu, the open ones checked, between `K`'s head and tail items. pub(crate) fn library_menu(app: &mut VectorcraftApp, ui: &mut Ui) { - K::menu_head(app, ui); - let shown = open_panels(&app.ui); - if let Some(id) = library_items(ui, &K::list(app), &|id| shown.contains(&panel_id(K::KIND, id))) { - open::(app, &id); - } - K::menu_tail(app, ui); + widgets::menu_scroll(ui, |ui| { + K::menu_head(app, ui); + let shown = open_panels(&app.ui); + if let Some(id) = library_items(ui, &K::list(app), &|id| shown.contains(&panel_id(K::KIND, id))) { + open::(app, &id); + } + K::menu_tail(app, ui); + }); } /// Menu rows for `libs`: the top-level ones, then a submenu per [`LibraryRef::submenu`], those diff --git a/crates/ui-egui/src/panels/opentype.rs b/crates/ui-egui/src/panels/opentype.rs index 1f98c74cd..4b5d4054a 100644 --- a/crates/ui-egui/src/panels/opentype.rs +++ b/crates/ui-egui/src/panels/opentype.rs @@ -217,13 +217,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let sets = offered_sets(&st); ui.add_enabled_ui(!sets.is_empty(), |ui| { ui.menu_button(tl!("Stylistic Sets"), |ui| { - for set in &sets { - let flag = Flag::Extra(EXTRA_FEATURES[usize::from(set.number - 1)]); - let on = flag.get(&next); - if widgets::check_name(ui, &set_label(set), on) { - change = Some(Change::Toggle(flag, !on)); + widgets::menu_scroll(ui, |ui| { + for set in &sets { + let flag = Flag::Extra(EXTRA_FEATURES[usize::from(set.number - 1)]); + let on = flag.get(&next); + if widgets::check_name(ui, &set_label(set), on) { + change = Some(Change::Toggle(flag, !on)); + } } - } + }); }); }); if cur.ligatures && vectorcraft_text::ligatures_suppressed_by(st.tracking) && !vectorcraft_text::explicit_ligatures(&st.features) { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e3972c8aa..bb5bf4a72 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -520,12 +520,43 @@ mod menu_room_tests { #[test] fn menu_stops_above_the_bottom_bars() { // Window 1000 pt tall, bars start at 948: a menu opening at y = 100 gets 836 pt, not 888. - assert_eq!(menu_room(948.0, 100.0), 836.0); + assert_eq!(menu_room(948.0, 100.0, 980.0), 836.0); } #[test] fn menu_keeps_a_minimum_height() { - assert_eq!(menu_room(300.0, 290.0), 120.0); + assert_eq!(menu_room(300.0, 290.0, 280.0), 120.0); + } + + /// A native panel window can be 100 pt tall, shorter than the minimum: the list then takes the + /// whole window, so its last items can scroll into view. + #[test] + fn a_window_shorter_than_the_minimum_limits_the_menu() { + assert_eq!(menu_room(100.0, 24.0, 92.0), 92.0); + } + + /// The floor one window records does not reach a menu in another window of the same context. + #[test] + fn the_menu_floor_applies_in_the_window_that_recorded_it() { + let ctx = egui::Context::default(); + let frame = |viewport_id: egui::ViewportId, height: f32, floor: Option| { + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, height))); + let mut seen = 0.0; + let viewports = std::iter::once((viewport_id, egui::ViewportInfo::default())).collect(); + let mut full = ctx.run_ui(egui::RawInput { viewport_id, viewports, screen_rect, ..Default::default() }, |ui| { + if let Some(y) = floor { + set_menu_floor(ui.ctx(), y); + } + seen = dropdowns::menu_floor(ui.ctx()); + }); + full.textures_delta.clear(); + seen + }; + assert_eq!(frame(egui::ViewportId::ROOT, 1000.0, Some(948.0)), 948.0, "the main window stops at its bottom bars"); + let panel = egui::ViewportId::from_hash_of("panel window"); + assert_eq!(frame(panel, 240.0, None), 240.0, "a shorter panel window stops at its own bottom edge"); + assert_eq!(frame(panel, 1400.0, None), 1400.0, "and a taller one too"); + assert_eq!(frame(egui::ViewportId::ROOT, 1000.0, None), 948.0, "the main window keeps its floor"); } } diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs index a9b56e091..d7ca5ab06 100644 --- a/crates/ui-egui/src/widgets/dropdowns.rs +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -252,17 +252,31 @@ pub(crate) fn popover_is_open(ctx: &egui::Context, anchor: egui::Id) -> bool { ctx.data(|d| d.get_temp::(anchor.with("popover"))).is_some_and(|f| f + 1 >= frame) } -/// Records where the bottom bars (status and hint bars) start this frame, so menus can stop above -/// them (#2086). Without bars it is the bottom of the window. +/// Records where the bottom bars (status and hint bars) start this frame in the window being drawn, +/// so its menus can stop above them (#2086). Without bars it is the bottom of the window. pub fn set_menu_floor(ctx: &egui::Context, y: f32) { - ctx.data_mut(|d| d.insert_temp(egui::Id::unique("menu_floor"), y)); + let id = menu_floor_id(ctx); + ctx.data_mut(|d| d.insert_temp(id, y)); } -/// The height a menu list may take: from `top` down to `floor` less a small gap, at least 120 pt. -pub fn menu_room(floor: f32, top: f32) -> f32 { +/// The floor's key: egui viewports share one context, and each OS window has its own bottom edge. +fn menu_floor_id(ctx: &egui::Context) -> egui::Id { + egui::Id::unique("menu_floor").with(ctx.viewport_id()) +} + +/// Where menus of the window being drawn stop: its recorded floor, or its bottom edge when nothing +/// recorded one, as in a floating panel's OS window. +pub(super) fn menu_floor(ctx: &egui::Context) -> f32 { + let id = menu_floor_id(ctx); + ctx.data(|d| d.get_temp::(id)).unwrap_or_else(|| ctx.content_rect().bottom()) +} + +/// The height a menu list may take: from `top` down to `floor` less a small gap, at least 120 pt, +/// and never more than `tallest`, the most that fits in the window. +pub fn menu_room(floor: f32, top: f32, tallest: f32) -> f32 { const BOTTOM_GAP: f32 = 12.0; const MIN_HEIGHT: f32 = 120.0; - (floor - top - BOTTOM_GAP).max(MIN_HEIGHT) + (floor - top - BOTTOM_GAP).max(MIN_HEIGHT).min(tallest) } /// The scroll bar of a menu's list: slim, in a column of its own beside the items, so it never @@ -271,13 +285,14 @@ pub fn list_scroll_style() -> egui::style::ScrollStyle { egui::style::ScrollStyle { bar_width: 6.0, bar_inner_margin: 2.0, ..egui::style::ScrollStyle::solid() } } -/// The body of a menu or popup list: as tall as its items up to the bottom bars of the window, and -/// scrolling only past that, so long menus (Window, Effect, a panel's menu) stay reachable on -/// small windows. Its scroll bar has a column of its own, so it never covers the shortcuts or the -/// submenu arrows (#2086). +/// The body of a menu or popup list: as tall as its items up to the bottom bars of the window it +/// opens in, and scrolling only past that, so long menus (Window, Effect, a panel's menu) stay +/// reachable on small windows and in a floating panel's short OS window. Its scroll bar has a column +/// of its own, so it never covers the shortcuts or the submenu arrows (#2086, #5347). pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R { - let floor = ui.ctx().data(|d| d.get_temp::(egui::Id::unique("menu_floor"))).unwrap_or_else(|| ui.ctx().content_rect().bottom()); - let room = menu_room(floor, ui.next_widget_position().y); + let ctx = ui.ctx(); + let tallest = (ctx.content_rect().height() - egui::Frame::popup(ui.style()).total_margin().sum().y).max(0.0); + let room = menu_room(menu_floor(ctx), ui.next_widget_position().y, tallest); ui.scope(|ui| { ui.spacing_mut().scroll = list_scroll_style(); // The minimum lets the list grow past a popup's default 400 pt size; it still shrinks to diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 070da6ecd..2ffd62a04 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -677,6 +677,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Screen readers get the value of the color and channel sliders that panels and dialogs draw (the Color panel, the Color Picker, Swatch Options, the Navigator's Zoom, the Character panel's variable font axes, the Perspective Grid and flattener options, and the sliders of the effect, tool option and transform dialogs): the number and text of the field beside each one, such as 128 for G or 50% for an opacity, with the slider's range, which extends to include a number past the slider's end. The harmony wheel's Brightness slider, which has no field, reports its brightness in percent. - A layer whose Layer Options › Print is off shows its name italic in the Layers panel, and while its row is dragged, as the reference application shows non-printing layers (#5739); a printing layer stays upright and ticking Print again restores the upright name (`panels::layers` test `a_layer_that_does_not_print_has_its_name_italic`). - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel (in an OS window of its own on the desktop, #4135), and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on a dock group's tabs, which light up under the pointer, its panels join that group, and on the group's upper or lower half they make a new group above or below it (`window.panel.dock {anchor, zone}`); dropped on the icon column they become icons there, the tabbed group's panels too (`window.panel.dock {icon: true}`); dropped anywhere else on the dock, which lights up, they go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`), and a dock group too, a group below it moving up into the room; a second double-click, or a click on another of its tabs, expands it (`window.panel.collapse`, kept with the preferences and in saved workspaces). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. + - A menu in a floating panel's OS window stops at that window's bottom edge and scrolls past it, and is never taller than the window (#5347; `a_long_panel_menu_scrolls_inside_a_short_native_window`). A panel's long submenus (Digits, Stylistic Sets, Save Selection, Move to Group), the libraries menu, the Color Guide's Limit to Library menu and the Layers panel's Filter popup scroll the same way. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Window › Toolbars › New Toolbar… and New Toolbar… in the All Tools drawer's menu ask for a name and make a blank toolbar that floats on its own, moved by its title bar and listed under Window › Toolbars, whose item shows or hides it. The title bar's × hides it, and its double arrow shows its tools in one column or two. Its ••• button opens the All Tools drawer beside it, and tools dragged from the drawer fill it. The toolbars are kept with the workspace they were made in: switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new workspace with a copy of them (; `window.toolbar.new`, `window.toolbar.custom`, the `toolbar` param of `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset`; `tools_added_to_a_new_toolbar_survive_a_workspace_reload`). - Window › Toolbars › Manage Toolbars… and Manage Toolbars… in the All Tools drawer's menu open a dialog that lists the toolbars made with New Toolbar: a new name typed for the selected toolbar renames it, + copies it with its tools (with none selected, + makes a blank toolbar), and the trash can deletes it. Each change shows under Window › Toolbars and stays in the workspace (; `window.toolbar.manage`, `window.toolbar.rename`, `window.toolbar.duplicate`, `window.toolbar.delete`; `manage_toolbars_renames_duplicates_and_deletes`).