Merge pull request: land #562 (fix #555)

Land #562 (fix #555: the font menu scrolls only its list)
This commit is contained in:
BFlat 2026-10-09 02:09:49 +07:00 committed by GitHub
commit c6bea61ffc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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