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;
2020/// A palette's rows and filter field, and Snowbound's more compact menu rows.
2121const ROW: f32 = 26.0;
2222pub(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.
2424const ROWS: f32 = 12.0;
2525/// How long the pointer rests on a row before its submenu opens, as Windows waits by default.
2626const SUBMENU_DELAY: Duration = Duration::from_millis(200);
......@@ -115,6 +115,12 @@ pub fn menu(
115115 .map_or(0.0, |badge| crate::badge_width(ui, badge) + ICON_GAP)
116116 })
117117 .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 };
118124 let mut measure = |text| ui.texts.label(text, style.font_size, ui.frame).size[0];
119125 let [text, shortcut] =
120126 items
......@@ -140,7 +146,7 @@ pub fn menu(
140146 } else {
141147 0.0
142148 } + 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 {
144150 GUTTER
145151 } else {
146152 0.0
......@@ -510,9 +516,13 @@ fn choose(
510516 0.0
511517 };
512518 let content = matches.count() as f32 * row + matches.space_before(matches.count());
513 let view = content
514 .max(row)
515 .min((ROWS * row).min(window - 4.0 * PAD - field).max(row));
519 let room = window - 4.0 * PAD - field;
520 let most = if role == Role::Menu {
521 room
522 } else {
523 (ROWS * row).min(room)
524 };
525 let view = content.max(row).min(most.max(row));
516526 let chosen = if matches.count() == 0 {
517527 ui.leaf(
518528 "empty",
crates/ui/src/tests.rs+30
......@@ -2024,6 +2024,36 @@ fn keys_move_the_selection_and_the_view_eases_after_it() {
20242024 assert_eq!((selected, row_top(&ui, 0)), (Some(0), Some(0.0)));
20252025}
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
20272057#[test]
20282058fn a_long_menu_scrolls_by_dragging_its_thumb() {
20292059 let names: Vec<String> = (0..40).map(|index| format!("Item {index}")).collect();