authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 18:58:23-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 19:55:46-07:00
log29bec7f52888c6acb305478e67c31be37a5d3edd
tree2525b1181c05397ec12505d5d5195a82a4d401fe
parentb27d10a6a06d758d5d362f437f3080857dc9918c
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: menus scroll only once they outgrow the window

A menu capped its list at twelve rows, so the notebook menu scrolled with half the window free. Menus now run to the window less its popup margin, after flipping or shifting to fit; the palette and dialog lists keep the cap. Assisted-by: claude-opus-5.5

2 files changed, 45 insertions(+), 5 deletions(-)

crates/ui/src/popup.rs+15-5
...@@ -20,7 +20,7 @@ pub(crate) const PAD: f32 = 4.0;...@@ -20,7 +20,7 @@ pub(crate) const PAD: f32 = 4.0;
20/// A palette's rows and filter field, and Snowbound's more compact menu rows.20/// A palette's rows and filter field, and Snowbound's more compact menu rows.
21const ROW: f32 = 26.0;21const ROW: f32 = 26.0;
22pub(crate) const MENU_ROW: f32 = 22.0;22pub(crate) const MENU_ROW: f32 = 22.0;
23/// Rows a list shows before it scrolls.23/// Rows a palette or a dialog's list shows before it scrolls; a menu runs to the window's edge.
24const ROWS: f32 = 12.0;24const ROWS: f32 = 12.0;
25/// How long the pointer rests on a row before its submenu opens, as Windows waits by default.25/// How long the pointer rests on a row before its submenu opens, as Windows waits by default.
26const SUBMENU_DELAY: Duration = Duration::from_millis(200);26const SUBMENU_DELAY: Duration = Duration::from_millis(200);
...@@ -115,6 +115,12 @@ pub fn menu(...@@ -115,6 +115,12 @@ pub fn menu(
115 .map_or(0.0, |badge| crate::badge_width(ui, badge) + ICON_GAP)115 .map_or(0.0, |badge| crate::badge_width(ui, badge) + ICON_GAP)
116 })116 })
117 .collect();117 .collect();
118 let window = ui.rect(Id::ROOT).map_or(0.0, |window| window[3]);
119 let field = if filter.is_some() {
120 ROW + style.pad
121 } else {
122 0.0
123 };
118 let mut measure = |text| ui.texts.label(text, style.font_size, ui.frame).size[0];124 let mut measure = |text| ui.texts.label(text, style.font_size, ui.frame).size[0];
119 let [text, shortcut] =125 let [text, shortcut] =
120 items126 items
...@@ -140,7 +146,7 @@ pub fn menu(...@@ -140,7 +146,7 @@ pub fn menu(
140 } else {146 } else {
141 0.0147 0.0
142 } + if icons(items) { ICON + ICON_GAP } else { 0.0 }148 } + if icons(items) { ICON + ICON_GAP } else { 0.0 }
143 + if items.len() as f32 > ROWS {149 + if items.len() as f32 * style.row > window - 4.0 * PAD - field {
144 GUTTER150 GUTTER
145 } else {151 } else {
146 0.0152 0.0
...@@ -510,9 +516,13 @@ fn choose(...@@ -510,9 +516,13 @@ fn choose(
510 0.0516 0.0
511 };517 };
512 let content = matches.count() as f32 * row + matches.space_before(matches.count());518 let content = matches.count() as f32 * row + matches.space_before(matches.count());
513 let view = content519 let room = window - 4.0 * PAD - field;
514 .max(row)520 let most = if role == Role::Menu {
515 .min((ROWS * row).min(window - 4.0 * PAD - field).max(row));521 room
522 } else {
523 (ROWS * row).min(room)
524 };
525 let view = content.max(row).min(most.max(row));
516 let chosen = if matches.count() == 0 {526 let chosen = if matches.count() == 0 {
517 ui.leaf(527 ui.leaf(
518 "empty",528 "empty",
crates/ui/src/tests.rs+30
...@@ -2024,6 +2024,36 @@ fn keys_move_the_selection_and_the_view_eases_after_it() {...@@ -2024,6 +2024,36 @@ fn keys_move_the_selection_and_the_view_eases_after_it() {
2024 assert_eq!((selected, row_top(&ui, 0)), (Some(0), Some(0.0)));2024 assert_eq!((selected, row_top(&ui, 0)), (Some(0), Some(0.0)));
2025}2025}
20262026
2027#[test]
2028fn a_menu_runs_to_the_window_edge_before_it_scrolls() {
2029 let names: Vec<String> = (0..20).map(|index| format!("Item {index}")).collect();
2030 let items: Vec<_> = names
2031 .iter()
2032 .map(|text| popup::Item {
2033 text,
2034 ..popup::Item::default()
2035 })
2036 .collect();
2037 let build = |ui: &mut Ui, height: f32| {
2038 sized_frame(ui, [400.0, height], |ui| {
2039 popup::menu(ui, menu_id(), BELOW, &items, None);
2040 })
2041 };
2042 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
2043 build(&mut ui, 800.0);
2044 ui.open_popup(menu_id());
2045 for _ in 0..3 {
2046 build(&mut ui, 800.0);
2047 }
2048 let bar = |ui: &Ui| ui.rect(menu_id().child("rows").child("bar"));
2049 assert!(bar(&ui).is_none(), "twenty rows fit a tall window whole");
2050 assert!(ui.rect(menu_id().child("rows").child(19u64)).is_some());
2051 for _ in 0..3 {
2052 build(&mut ui, 300.0);
2053 }
2054 assert!(bar(&ui).is_some(), "a short window scrolls them");
2055}
2056
2027#[test]2057#[test]
2028fn a_long_menu_scrolls_by_dragging_its_thumb() {2058fn a_long_menu_scrolls_by_dragging_its_thumb() {
2029 let names: Vec<String> = (0..40).map(|index| format!("Item {index}")).collect();2059 let names: Vec<String> = (0..40).map(|index| format!("Item {index}")).collect();