Fix #555: the font menu scrolls only its list

egui's combo box puts its own scroll area around a combo's list, so the
font menu, which scrolls its families itself, had two scrollbars: once
the families reached their end the wheel scrolled the outer one, taking
the search field and the filters out of view.

The font menu (widgets::combo with searchable) now opens its list in a
popup of its own, drawn like the combo box, without that outer scroll
area: below the button, or above it when there is more room there. The
families list fills the popup's room under the search field and filters,
which stay at the top while the list scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-09 01:54:11 +07:00
parent af7a5239be
commit 1b43d8b8f9
4 changed files with 109 additions and 9 deletions

File diff suppressed because one or more lines are too long

View File

@ -307,12 +307,10 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
widgets::dim_label(ui, tl!("No matching fonts"));
return None;
}
// The rows: only those in view are laid out (there are thousands of families). The list fills
// what the popup shows below the filters (its clip: the popup doesn't grow past its own size,
// and a taller list would scroll inside a scrolling popup), down to the window's bottom.
const BOTTOM_GAP: f32 = 12.0;
let bottom = ui.clip_rect().bottom().min(ui.ctx().content_rect().bottom() - BOTTOM_GAP);
let room = (bottom - ui.next_widget_position().y).max(120.0);
// The rows: only those in view are laid out (there are thousands of families). The list is
// the menu's one scrolling part, under the search field and filters (#555): it fills the
// menu's room (its max rect) below them.
let room = (ui.max_rect().bottom() - ui.next_widget_position().y).clamp(120.0, 600.0);
let mut area = egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room.min(row_h * rows.len() as f32));
// The highlight in view: centred as the menu opens, scrolled just enough as the keys move it.
if let Some(top) = st.highlight.map(|i| i as f32 * row_h) {

View File

@ -270,3 +270,51 @@ fn menus_that_only_pick_a_font_leave_the_document_alone() {
assert_eq!(picked(&mut app, Some(FontPick::Favorite("inter".into()))), None);
assert!(app.ui.favorite_fonts.is_empty());
}
/// #555: the menu's search field and filters stay at its top while the wheel scrolls the families,
/// past the list's end too: the list is the menu's one scrolling part.
#[test]
fn the_search_field_stays_put_while_the_list_scrolls() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let frame = |events: Vec<egui::Event>| {
let input =
egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 500.0))), ..Default::default() };
let mut at = egui::Pos2::ZERO;
let mut out = ctx.run_ui(input, |ui| {
// Low in the window, as in a panel at the bottom of the dock: the menu opens upwards.
ui.add_space(390.0);
at = ui.next_widget_position();
font_menu(ui, "test-scroll", "Inter", 260.0, None, MenuLook::default());
});
out.textures_delta.clear();
let search = out.shapes.iter().find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text() == "Search" => Some(t.pos),
_ => None,
});
(at, search)
};
let (at, _) = frame(vec![]);
let click = at + egui::vec2(40.0, 10.0);
let button = |pressed| egui::Event::PointerButton { pos: click, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(vec![egui::Event::PointerMoved(click), button(true)]);
frame(vec![button(false)]);
let mut field = None;
for _ in 0..5 {
field = frame(vec![]).1;
}
let field = field.expect("the search field shows");
let over_list = egui::pos2(field.x + 40.0, field.y + 120.0);
for dy in [-400.0, -4000.0, -4000.0] {
let wheel = egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, dy),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
};
frame(vec![egui::Event::PointerMoved(over_list), wheel]);
for _ in 0..10 {
assert_eq!(frame(vec![]).1, Some(field), "the field stays where it was");
}
}
}

View File

@ -655,11 +655,13 @@ pub(crate) fn combo<R>(
.stroke(Stroke::new(1.0, t.input_border))
.corner_radius(CornerRadius::same(3))
.show(ui, |ui| {
if searchable {
return searchable_combo(ui, ui.id().with(id), current, width - 4.0, list);
}
egui::ComboBox::from_id_salt(ui.id().with(id))
.selected_text(egui::RichText::new(current).size(12.0))
.width(width - 4.0)
.close_behavior(if searchable { egui::PopupCloseBehavior::CloseOnClickOutside } else { egui::PopupCloseBehavior::CloseOnClick })
.height(if searchable { f32::INFINITY } else { ui.spacing().combo_height })
.height(ui.spacing().combo_height)
.show_ui(ui, list)
.inner
.flatten()
@ -667,6 +669,58 @@ pub(crate) fn combo<R>(
.inner
}
/// A dropdown whose list keeps its own search field and scrolling rows (the font menu): drawn as
/// egui's combo box, but in a popup without the scroll area egui puts around a combo's list, so
/// the field stays at the top and only the rows scroll (#555). A click outside closes it.
fn searchable_combo<R>(ui: &mut Ui, id: egui::Id, current: &str, width: f32, list: impl FnOnce(&mut Ui) -> Option<R>) -> Option<R> {
let popup = id.with("popup");
let open = egui::Popup::is_id_open(ui.ctx(), popup);
let pad = ui.spacing().button_padding;
let icon = Vec2::splat(ui.spacing().icon_width);
let galley = egui::WidgetText::from(egui::RichText::new(current).size(12.0)).into_galley(
ui,
Some(egui::TextWrapMode::Truncate),
width - 2.0 * pad.x - ui.spacing().icon_spacing - icon.x,
egui::TextStyle::Button,
);
let height = (galley.size().y.max(icon.y) + 2.0 * pad.y).max(ui.spacing().interact_size.y);
let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click());
if ui.is_rect_visible(rect) {
let v = if open { ui.visuals().widgets.open } else { *ui.style().interact(&resp) };
ui.painter().rect(rect.expand(v.expansion), v.corner_radius, v.weak_bg_fill, v.bg_stroke, StrokeKind::Inside);
let inner = rect.shrink2(pad);
ui.painter().galley(egui::Align2::LEFT_CENTER.align_size_within_rect(galley.size(), inner).min, galley, v.text_color());
// egui's combo arrow: a downward triangle.
let arrow =
Rect::from_center_size(egui::Align2::RIGHT_CENTER.align_size_within_rect(icon, inner).center(), vec2(icon.x * 0.7, icon.y * 0.45));
ui.painter().add(egui::Shape::convex_polygon(
vec![arrow.left_top(), arrow.right_top(), arrow.center_bottom()],
v.fg_stroke.color,
Stroke::NONE,
));
}
// It opens below the button, or above it when there is more room there, and the list gets
// the room left (the popup's frame aside).
const GAP: f32 = 12.0;
let screen = ui.ctx().content_rect();
let frame = ui.spacing().menu_margin.sum().y + GAP;
let (below, above) = (screen.bottom() - rect.bottom() - frame, rect.top() - screen.top() - frame);
let (align, room) = if below >= above { (egui::RectAlign::BOTTOM_START, below) } else { (egui::RectAlign::TOP_START, above) };
egui::Popup::menu(&resp)
.id(popup)
.width(rect.width())
.align(align)
.align_alternatives(&[])
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| {
ui.set_min_width(ui.available_width());
ui.set_max_height(room);
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
list(ui)
})
.and_then(|r| r.inner)
}
/// A popover anchored to `resp`, opened and closed by `toggle` (usually `resp.clicked()`), and
/// closed by Escape, a click outside it and outside any popup it opened, or a frame in which its
/// anchor isn't shown. Use it instead of `egui::Popup::menu` when the popover holds dropdowns or