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:
parent
af7a5239be
commit
1b43d8b8f9
File diff suppressed because one or more lines are too long
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue