UI: combo boxes join their dropdown arrow (field rounded left, arrow right) (#1083)

* Clippy 1.96: simplify boolean guards, drop a needless drop()

Rust 1.96's clippy flags nonminimal_bool in find_pattern, table_mouse and
table_resize, and drop_non_drop in a ribbon test; `cargo xtask ci` failed at
clippy on main. Same logic, rewritten as clippy suggests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UI: combo boxes join their dropdown arrow (field rounded left, arrow right)

The font and size boxes looked apart from their arrows: the text field was
rounded on all four corners and the arrow on none, leaving a notch where they
meet. The field now rounds only its left corners (in every widget state,
including focus and hover) and the arrow only its right ones, so the two read
as one control. Applies to every combo drawn by widgets::combo/font_combo:
the ribbon's font, size and outline-level boxes, the mini toolbar, and the
Font and Symbol dialogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UI: one divider between a combo's field and its arrow

The field's right border and the arrow's left border stood side by side, a
divider twice as wide as the rest of the outline. The arrow's box is now
painted under the field and reaches one border width beneath it, so the
field's right border is the only line between them (in the field's focus
colour while typing). The test draws with the app's light and dark themes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Todd Pederson
2026-10-14 16:24:40 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 3962d397e5
commit b8e46b244d
+72 -1
View File
@@ -537,6 +537,9 @@ pub fn font_matches(query: &str, items: &[String]) -> Vec<usize> {
exact
}
/// Width of a combo arrow's border, the field's own (the theme's input border is 1 pt).
const ARROW_BORDER: f32 = 1.0;
/// A searching combo's state between frames.
#[derive(Clone, Default)]
struct ComboSearch {
@@ -573,6 +576,14 @@ fn combo_impl(
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
// The field and its arrow share one outline: rounded on the outside, square where they
// meet. The field's frame takes the widget visuals' radius in every state.
let r = ui.visuals().widgets.inactive.corner_radius;
let w = &mut ui.visuals_mut().widgets;
for v in [&mut w.noninteractive, &mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] {
v.corner_radius.ne = 0;
v.corner_radius.se = 0;
}
// Up/Down move the list's highlight: take them before the text field moves its cursor.
let mut step = 0i32;
if s.open && ui.memory(|m| m.has_focus(te_id)) {
@@ -586,6 +597,8 @@ fn combo_impl(
});
}
let te = egui::TextEdit::singleline(&mut text).id(te_id).desired_width(width - 16.0).font(regular(12.0)).margin(vec2(4.0, 3.0));
// The arrow's box is painted here, under the field, once its size is known.
let arrow_bg = ui.painter().add(egui::Shape::Noop);
let resp = ui.add(te);
keytip_badge(ui, resp.rect, "combo", id);
focused = resp.has_focus();
@@ -632,7 +645,13 @@ fn combo_impl(
out = Some(text.clone());
}
let (ar, aresp) = ui.allocate_exact_size(vec2(16.0, resp.rect.height()), Sense::click());
ui.painter().rect(ar, 0.0, if aresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
// It reaches one border width under the field, so the field's right border is the only line
// between them: two borders side by side read as a double-width divider.
let ar_radius = CornerRadius { nw: 0, sw: 0, ne: r.ne, se: r.se };
let under = Rect::from_min_max(pos2(ar.min.x - ARROW_BORDER, ar.min.y), ar.max);
let fill = if aresp.hovered() { t.hover } else { t.input };
let arrow_box = egui::epaint::RectShape::new(under, ar_radius, fill, Stroke::new(ARROW_BORDER, t.input_border), egui::StrokeKind::Inside);
ui.painter().set(arrow_bg, arrow_box);
icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(DROPDOWN, DROPDOWN)), "dropdown", t.icon, t.accent);
// The list drops from the box's left edge, as in Word, not from the arrow (#535).
egui::Popup::menu(&aresp).anchor(resp.rect.union(ar)).show(|ui| {
@@ -963,6 +982,58 @@ mod tests {
assert_eq!(type_into_combo(false, &sizes, "1", &[Enter]).as_deref(), Some("1"));
}
/// The field was rounded on all four corners and the arrow on none, so the two looked apart,
/// and their borders stood side by side as a double-width divider. The field is rounded only
/// on the left, the arrow only on the right, and the arrow's box lies under the field by one
/// border width, so a single line divides them; focused or not.
#[test]
fn combo_field_and_arrow_share_one_outline() {
let sizes: Vec<String> = ["10", "11", "12"].map(String::from).to_vec();
let ctx = egui::Context::default();
let cases = [crate::theme::Appearance::Light, crate::theme::Appearance::Dark].into_iter().flat_map(|a| [(a, false), (a, true)]);
for (appearance, focused) in cases {
crate::theme::apply(&ctx, appearance);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
if focused {
ui.memory_mut(|m| m.request_focus(ui.id().with(("combo_edit", "f"))));
}
combo(ui, "f", 150.0, "12", &sizes, None);
});
// Painted boxes in paint order: (order, rect, corners, border width).
let boxes: Vec<(usize, Rect, CornerRadius, f32)> = std::mem::take(&mut out.shapes)
.into_iter()
.filter_map(|c| match c.shape {
egui::Shape::Rect(r) => Some((r.rect, r.corner_radius, r.stroke.width)),
_ => None,
})
.enumerate()
.map(|(i, (rect, c, w))| (i, rect, c, w))
.collect();
out.drop_without_applying_deltas();
let corners: Vec<CornerRadius> = boxes.iter().map(|b| b.2).collect();
let field = boxes.iter().find(|b| b.2.nw > 0 && b.2.sw == b.2.nw && b.2.ne == 0 && b.2.se == 0);
let field = field.unwrap_or_else(|| panic!("focused {focused}: no field rounded on the left only in {corners:?}"));
let arrow_corners = CornerRadius { nw: 0, sw: 0, ne: field.2.nw, se: field.2.sw };
let arrow = boxes.iter().find(|b| b.2 == arrow_corners);
let arrow = arrow.unwrap_or_else(|| panic!("focused {focused}: no arrow rounded on the right only in {corners:?}"));
assert!(!corners.iter().any(|c| c.nw > 0 && c.ne > 0), "focused {focused}: a part is still rounded all round: {corners:?}");
// One divider as wide as the other borders: the arrow's left border lies exactly under
// the field's right border, and the field is painted over it.
assert!(field.3 > 0.0 && arrow.3 == field.3, "focused {focused}: borders {} and {} differ", field.3, arrow.3);
assert!(
(arrow.1.min.x - (field.1.max.x - field.3)).abs() < 0.01,
"focused {focused}: arrow {:?} and field {:?} borders stand apart",
arrow.1,
field.1
);
assert!(arrow.0 < field.0, "focused {focused}: the arrow's box must be painted under the field");
assert!(
(arrow.1.min.y - field.1.min.y).abs() < 0.01 && (arrow.1.max.y - field.1.max.y).abs() < 0.01,
"focused {focused}: the outlines' tops and bottoms differ"
);
}
}
#[test]
fn a_long_combo_list_builds_only_the_rows_in_view() {
// #121: the font menu built (and so loaded the font of) every installed family.