M6: a drop-down's list follows the pointer and the keys, with a classic scroll bar

This commit is contained in:
Eric Kaden
2026-10-19 18:27:36 -04:00
parent 034a1080b0
commit 5d1303d12e
4 changed files with 298 additions and 62 deletions
+1
View File
@@ -1421,6 +1421,7 @@ mod revert_draft_tests {
calendar: None,
calendar_rect: None,
calendar_text: None,
list: Default::default(),
}
}
+211 -60
View File
@@ -40,6 +40,24 @@ pub struct Focus {
/// The text the calendar last saw, so it moves to a typed date once, when the typing becomes
/// a whole date, and otherwise keeps the month its arrows turned to.
pub calendar_text: Option<String>,
/// Drop-downs: where the open list is scrolled to and which item is current.
pub list: ChoiceList,
}
/// A drop-down's open list: the first item shown, the item the keys or the pointer are on, and
/// where on the scroll bar's thumb a drag took hold.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ChoiceList {
pub first: usize,
pub current: Option<usize>,
pub grab: Option<f32>,
/// Wheel movement not yet a whole row.
pub wheel: f32,
/// The item the pointer was last over: entering another makes that one current, so a
/// resting pointer doesn't take the highlight back from the arrow keys.
pub pointer_on: Option<usize>,
/// Set on the first frame, from the field's value.
pub placed: bool,
}
#[derive(Clone, Debug, Default)]
@@ -93,6 +111,7 @@ fn open_focus(f: &FormField, widget: usize) -> Focus {
calendar: None,
calendar_rect: None,
calendar_text: None,
list: ChoiceList::default(),
}
}
@@ -596,66 +615,15 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f
}
}
FormFieldKind::Combo => {
// The list opens under the field and looks like it, as other viewers draw it: as wide as
// the box, in its text size, black on white, without the field's internal name.
let size = (field_text_size(&ctx.layer_painter(egui::LayerId::background()), f, w, "") * zoom).clamp(6.0, 48.0);
let row = (size * 1.3).ceil();
let resp = egui::Area::new(egui::Id::new(("form-choices", view.id.0))).order(egui::Order::Foreground).fixed_pos(rect.left_bottom()).show(
ctx,
|ui| {
ui.style_mut().visuals = egui::Visuals::light();
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
// A scroll bar that stays shown beside the items, so a longer list says so and
// where it is scrolled to; it replaces the fade at the bottom edge.
ui.spacing_mut().scroll = egui::style::ScrollStyle {
bar_width: 8.0,
dormant_background_opacity: 1.0,
active_background_opacity: 1.0,
interact_background_opacity: 1.0,
dormant_handle_opacity: 1.0,
active_handle_opacity: 1.0,
interact_handle_opacity: 1.0,
..egui::style::ScrollStyle::solid()
};
ui.spacing_mut().scroll.fade.strength = 0.0;
// The bar's track and handle, gray on the white list.
let v = ui.visuals_mut();
v.extreme_bg_color = Color32::from_gray(236);
v.widgets.inactive.bg_fill = Color32::from_gray(168);
v.widgets.hovered.bg_fill = Color32::from_gray(132);
v.widgets.active.bg_fill = Color32::from_gray(100);
egui::Frame::new().fill(Color32::WHITE).stroke(Stroke::new(1.0, Color32::from_gray(64))).show(ui, |ui| {
let width = rect.width().max(24.0);
ui.set_width(width);
egui::ScrollArea::vertical().max_height(row * 9.0).show(ui, |ui| {
for (export, display) in &f.options {
let on = f.value.first() == Some(export);
let (r, item) = ui.allocate_exact_size(vec2(ui.available_width(), row), egui::Sense::click());
item.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, on, display));
let (fill, ink) = if on {
(Color32::from_rgb(0x2F, 0x6F, 0xE0), Color32::WHITE)
} else if item.hovered() {
(Color32::from_gray(222), Color32::BLACK)
} else {
(Color32::WHITE, Color32::BLACK)
};
ui.painter().rect_filled(r, CornerRadius::ZERO, fill);
ui.painter().with_clip_rect(r).text(
egui::pos2(r.min.x + 3.0, r.center().y),
egui::Align2::LEFT_CENTER,
display,
egui::FontId::proportional(size),
ink,
);
if item.clicked() {
edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export.clone()]) });
close = true;
}
}
});
});
},
);
let mut st = view.forms.focus.as_ref().map(|x| x.list).unwrap_or_default();
let (chosen, resp) = choice_list(ctx, view.id.0, f, w, rect, zoom, &mut st);
if let Some(fx) = view.forms.focus.as_mut() {
fx.list = st;
}
if let Some((export, _)) = chosen.and_then(|i| f.options.get(i)) {
edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export.clone()]) });
close = true;
}
let clicked_away = ctx.input(|i| i.pointer.any_click())
&& !resp.response.contains_pointer()
&& !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p));
@@ -732,6 +700,189 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f
edit.or_else(|| view.pending_edit.take())
}
/// Items a drop-down's list shows before it scrolls.
const LIST_ROWS: usize = 9;
/// A drop-down's open list, under the field and drawn like it, as other viewers draw theirs: as
/// wide as the box, its items in the field's text size and alignment, black on white with a blue
/// highlight that follows the pointer and the arrow keys, and a scroll bar with step buttons when
/// there are more than nine items. Returns the item chosen (a click, or Enter on the current one)
/// and the list's response.
fn choice_list(
ctx: &egui::Context,
view: u64,
f: &FormField,
w: &FormWidget,
rect: Rect,
zoom: f32,
st: &mut ChoiceList,
) -> (Option<usize>, egui::InnerResponse<()>) {
let n = f.options.len();
let shown = n.min(LIST_ROWS);
let max_first = n.saturating_sub(shown);
let size = (field_text_size(&ctx.layer_painter(egui::LayerId::background()), f, w, "") * zoom).clamp(6.0, 48.0);
let row = (size * 1.45).round().max(8.0);
let bar = if n > shown { (row * 0.7).round().clamp(12.0, 22.0) } else { 0.0 };
let width = rect.width().max(24.0 + bar);
if !st.placed {
st.current = f.value.first().and_then(|v| f.options.iter().position(|(e, _)| e == v));
st.first = st.current.map_or(0, |c| c.min(max_first));
st.placed = true;
}
// Consumed, so the keys move through the list rather than the page.
let key = |k| ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, k));
let moved = if n == 0 {
None
} else if key(egui::Key::ArrowUp) {
Some(st.current.map_or(0, |c| c.saturating_sub(1)))
} else if key(egui::Key::ArrowDown) {
Some(st.current.map_or(0, |c| (c + 1).min(n - 1)))
} else if key(egui::Key::PageUp) {
Some(st.current.map_or(0, |c| c.saturating_sub(shown)))
} else if key(egui::Key::PageDown) {
Some(st.current.map_or(0, |c| (c + shown).min(n - 1)))
} else if key(egui::Key::Home) {
Some(0)
} else if key(egui::Key::End) {
Some(n - 1)
} else {
None
};
if let Some(m) = moved {
st.current = Some(m);
st.first = if m < st.first {
m
} else if m >= st.first + shown {
m + 1 - shown
} else {
st.first
};
}
let mut chosen = key(egui::Key::Enter).then_some(st.current).flatten();
let align = match f.quadding {
1 => egui::Align::Center,
2 => egui::Align::Max,
_ => egui::Align::Min,
};
let resp = egui::Area::new(egui::Id::new(("form-choices", view))).order(egui::Order::Foreground).fixed_pos(rect.left_bottom()).show(ctx, |ui| {
let (outer, area) = ui.allocate_exact_size(vec2(width, shown as f32 * row + 2.0), egui::Sense::hover());
let p = ui.painter();
p.rect_filled(outer, CornerRadius::ZERO, Color32::WHITE);
// The wheel scrolls the list, a row at a time, and not the page under it.
if area.hovered() && n > shown {
st.wheel += ctx.input(|i| i.smooth_scroll_delta.y);
ctx.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO);
let steps = (st.wheel / row).trunc();
if steps != 0.0 {
st.wheel -= steps * row;
st.first = (st.first as f32 - steps).clamp(0.0, max_first as f32) as usize;
}
}
let mut pointer_on = None;
let font = egui::FontId::proportional(size);
for k in 0..shown {
let i = st.first + k;
let Some((_, display)) = f.options.get(i) else { break };
let r = Rect::from_min_size(outer.min + vec2(1.0, 1.0 + k as f32 * row), vec2(width - 2.0 - bar, row));
let item = ui.interact(r, ui.id().with(("choice", i)), egui::Sense::click());
item.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, st.current == Some(i), display));
if item.hovered() {
pointer_on = Some(i);
if st.pointer_on != Some(i) {
st.current = Some(i);
}
}
if item.clicked() {
chosen = Some(i);
}
let on = st.current == Some(i);
if on {
p.rect_filled(r, CornerRadius::ZERO, Color32::from_rgb(0x1F, 0x65, 0xD6));
}
let galley = p.layout_no_wrap(display.clone(), font.clone(), if on { Color32::WHITE } else { Color32::BLACK });
let inner = r.shrink2(vec2(3.0, 0.0));
let x = match align {
egui::Align::Center => inner.center().x - galley.size().x / 2.0,
egui::Align::Max => inner.max.x - galley.size().x,
egui::Align::Min => inner.min.x,
};
p.with_clip_rect(r).galley(egui::pos2(x, r.center().y - galley.size().y / 2.0), galley, Color32::BLACK);
}
st.pointer_on = pointer_on;
if bar > 0.0 {
scroll_bar(ui, Rect::from_min_max(egui::pos2(outer.max.x - 1.0 - bar, outer.min.y + 1.0), outer.max - vec2(1.0, 1.0)), shown, n, st);
}
p.rect_stroke(outer, CornerRadius::ZERO, Stroke::new(1.0, Color32::BLACK), egui::StrokeKind::Inside);
});
(chosen, resp)
}
/// A list's classic scroll bar: step buttons at each end, a thumb sized to the share of the
/// items shown that can be dragged, and a track that pages when clicked.
fn scroll_bar(ui: &egui::Ui, track: Rect, shown: usize, n: usize, st: &mut ChoiceList) {
let max_first = n.saturating_sub(shown);
if max_first == 0 {
return;
}
let side = track.width();
let up = Rect::from_min_size(track.min, vec2(side, side));
let down = Rect::from_min_max(egui::pos2(track.min.x, track.max.y - side), track.max);
let trough = Rect::from_min_max(egui::pos2(track.min.x, up.max.y), egui::pos2(track.max.x, down.min.y));
let thumb_len = (trough.height() * shown as f32 / n as f32).max(side * 0.75).min(trough.height());
let travel = (trough.height() - thumb_len).max(1.0);
let thumb_at = |first: usize| trough.min.y + travel * first as f32 / max_first as f32;
let id = ui.id().with("choice-bar");
let p = ui.painter();
p.rect_filled(track, CornerRadius::ZERO, Color32::from_gray(240));
for (r, step, pointing_up) in [(up, -1i64, true), (down, 1, false)] {
let b = ui.interact(r, id.with(pointing_up), egui::Sense::click());
if b.clicked() {
st.first = (st.first as i64 + step).clamp(0, max_first as i64) as usize;
}
p.rect_filled(r.shrink(1.0), CornerRadius::ZERO, if b.hovered() { Color32::from_gray(205) } else { Color32::from_gray(222) });
p.rect_stroke(r.shrink(1.0), CornerRadius::ZERO, Stroke::new(1.0, Color32::from_gray(150)), egui::StrokeKind::Inside);
let (c, h) = (r.center(), (side * 0.18).max(2.0));
let tip = if pointing_up { -h } else { h };
let points = vec![egui::pos2(c.x - h * 1.4, c.y - tip / 2.0), egui::pos2(c.x + h * 1.4, c.y - tip / 2.0), egui::pos2(c.x, c.y + tip / 2.0)];
p.add(egui::Shape::convex_polygon(points, Color32::from_gray(40), Stroke::NONE));
}
let thumb = Rect::from_min_size(egui::pos2(trough.min.x, thumb_at(st.first)), vec2(side, thumb_len));
let t = ui.interact(thumb, id.with("thumb"), egui::Sense::drag());
let pointer = ui.input(|i| i.pointer.interact_pos());
if t.drag_started() {
st.grab = pointer.map(|q| q.y - thumb.min.y);
}
if t.dragged()
&& let (Some(q), Some(grab)) = (pointer, st.grab)
{
let share = ((q.y - grab - trough.min.y) / travel).clamp(0.0, 1.0);
st.first = (share * max_first as f32).round() as usize;
}
if t.drag_stopped() {
st.grab = None;
}
let page = ui.interact(trough, id.with("trough"), egui::Sense::click());
if page.clicked()
&& let Some(q) = pointer
&& !thumb.contains(q)
{
st.first = if q.y < thumb.min.y { st.first.saturating_sub(shown) } else { (st.first + shown).min(max_first) };
}
let thumb = Rect::from_min_size(egui::pos2(trough.min.x, thumb_at(st.first)), vec2(side, thumb_len)).shrink2(vec2(1.0, 0.5));
p.rect_filled(thumb, CornerRadius::ZERO, if t.dragged() || t.hovered() { Color32::from_gray(176) } else { Color32::from_gray(196) });
p.rect_stroke(thumb, CornerRadius::ZERO, Stroke::new(1.0, Color32::from_gray(140)), egui::StrokeKind::Inside);
// Grip lines across the middle of the thumb.
if thumb.height() > side {
for k in -1..=1 {
let y = thumb.center().y + k as f32 * 3.0;
p.line_segment(
[egui::pos2(thumb.min.x + side * 0.3, y), egui::pos2(thumb.max.x - side * 0.3, y)],
Stroke::new(1.0, Color32::from_gray(120)),
);
}
}
}
/// Where a field's widget is on screen (tests and automation).
pub fn field_screen_rect(view: &DocView, info: &DocInfo, f: &FormField, widget: usize) -> Option<Rect> {
let w = f.widgets.get(widget)?;
+84
View File
@@ -1138,3 +1138,87 @@ fn a_long_drop_down_list_keeps_its_scroll_bar_beside_the_items() {
// A scroll bar that stays shown takes its own room, so people see there is more below.
assert!(first.right() <= country.right() - 6.0, "{first:?} leaves room for the bar in {country:?}");
}
/// Where `text` is drawn in the last frame, if anywhere.
fn text_rect(h: &Harness<'static, PdfCraftApp>, text: &str) -> Option<egui::Rect> {
fn walk(shape: &egui::Shape, text: &str) -> Option<egui::Rect> {
match shape {
egui::Shape::Vec(shapes) => shapes.iter().find_map(|s| walk(s, text)),
egui::Shape::Text(t) if t.galley.text() == text => Some(egui::Rect::from_min_size(t.pos, t.galley.size())),
_ => None,
}
}
h.output().shapes.iter().find_map(|c| walk(&c.shape, text))
}
fn long_country(h: &mut Harness<'static, PdfCraftApp>) {
let options: Vec<String> = (0..30).map(|i| format!("{i:02}00")).collect();
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps {
name: "country".into(),
props: Box::new(pdfcraft_engine::FieldProps { options: Some(options), ..Default::default() }),
}));
h.run_steps(3);
}
#[test]
fn a_drop_downs_list_aligns_its_items_as_the_field_does() {
let mut h = harness();
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps {
name: "country".into(),
props: Box::new(pdfcraft_engine::FieldProps { quadding: Some(1), ..Default::default() }),
}));
h.run_steps(3);
click_field(&mut h, "country", 0);
let country = field_rect(&h, "country");
let france = text_rect(&h, "France").expect("drawn");
assert!((france.center().x - country.center().x).abs() <= 2.0, "centered as the field is: {france:?} in {country:?}");
}
#[test]
fn the_arrow_keys_move_through_a_drop_downs_list_and_enter_chooses() {
let mut h = harness();
click_field(&mut h, "country", 0);
h.key_press(egui::Key::ArrowDown);
h.run_steps(1);
h.key_press(egui::Key::ArrowDown);
h.run_steps(1);
assert!(value(&h, "country").is_empty(), "the arrows only move through the list");
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(value(&h, "country"), ["fr"]);
assert!(h.state().views[0].forms.focus.is_none(), "Enter closes the list");
// Reopened, the list starts from the field's value.
click_field(&mut h, "country", 0);
h.key_press(egui::Key::ArrowUp);
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(value(&h, "country"), ["ca"]);
}
#[test]
fn a_long_drop_down_list_scrolls_by_its_arrow_button_and_to_its_end() {
let mut h = harness();
long_country(&mut h);
click_field(&mut h, "country", 0);
assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0900").is_none(), "nine rows show");
let country = field_rect(&h, "country");
let first = egui_kittest::kittest::Queryable::get_by_label(&h, "0000").rect();
// The bar's down button, at the bottom of the bar beside the ninth row.
let bar = country.right() - first.right();
let down = egui::pos2(country.right() - bar / 2.0, first.top() + first.height() * 9.0 - bar / 2.0);
h.hover_at(down);
h.run_steps(1);
h.drag_at(down);
h.run_steps(1);
h.drop_at(down);
h.run_steps(2);
assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0900").is_some(), "one row on");
assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_none());
h.key_press(egui::Key::End);
h.run_steps(2);
assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "2900").is_some(), "End goes to the last item");
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(value(&h, "country"), ["2900"]);
}
+2 -2
View File
@@ -4440,8 +4440,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.fields"]
tools = ["form_fill"]
evidence = ["crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices", "crates/ui-egui/tests/forms.rs::a_drop_downs_list_opens_as_wide_as_the_field_without_its_internal_name", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text"]
notes = "combo and list boxes, multi-select lists; editable combo boxes accept free text. A combo box shows its arrow on screen and opens its list under the field, as wide as the box and in its text size, black on white, with a scroll bar shown beside a longer list; the list takes no arrow keys yet"
evidence = ["crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices", "crates/ui-egui/tests/forms.rs::a_drop_downs_list_opens_as_wide_as_the_field_without_its_internal_name", "crates/ui-egui/tests/forms.rs::the_arrow_keys_move_through_a_drop_downs_list_and_enter_chooses", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text"]
notes = "combo and list boxes, multi-select lists; editable combo boxes accept free text. A combo box shows its arrow on screen and opens its list under the field, as wide as the box, in its text size and alignment, black on white with a highlight that follows the pointer; more than nine items get a scroll bar with step buttons, the wheel scrolls it, and the arrow keys, Page Up/Down, Home and End move through it, Enter choosing. The list is drawn in the interface font, not the field's (no serif interface font ships yet)"
[[feature]]
id = "form.push-buttons"