fix(ui): prevent options pane labels from overflowing (#1266) (#1301)

* fix(ui): prevent options pane labels from overflowing (#1266)

Clip each navigation label to its fixed row while preserving the full
accessible name, click region and hover/selection backgrounds.

Cover real painting bounds and row interactions in English and Russian
at 100%, 125% and 150% interface size with 2x native display scaling.

Co-Authored-By: Codex <noreply@openai.com>

* Test: Options nav labels under the absolute interface size (#1142)

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

---------

Co-authored-by: Zaxaerith <189452125+Zaxaerith@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Zaxaerith
2026-10-14 18:17:18 -04:00
committed by GitHub
co-authored by Codex Claude Opus 5.5 Zaxaerith Brandon Thomas
parent ae8048e3f1
commit 5f2e18e1cd
+112 -2
View File
@@ -116,7 +116,8 @@ pub fn page(app: &mut WordApp, ui: &mut Ui) {
ui.painter().rect_filled(r, 4.0, t.hover);
}
let font = if active { semibold(14.0) } else { crate::theme::regular(14.0) };
ui.painter().text(egui::pos2(r.min.x + 12.0, r.center().y), egui::Align2::LEFT_CENTER, label, font, t.text);
// Long translations stay inside the row; WidgetInfo above keeps the full name.
ui.painter().with_clip_rect(r).text(egui::pos2(r.min.x + 12.0, r.center().y), egui::Align2::LEFT_CENTER, label, font, t.text);
if resp.clicked() {
app.ui.options_pane = id.to_string();
}
@@ -711,7 +712,7 @@ fn numeral_picker(app: &mut WordApp, ui: &mut Ui) {
#[cfg(test)]
mod tests {
use egui_kittest::kittest::Queryable;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::json;
use wordcraft_geom::Unit;
@@ -724,6 +725,115 @@ mod tests {
WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default())
}
fn nav_harness(language: &str, percent: u16) -> egui_kittest::Harness<'static, WordApp> {
let mut a = app();
a.run("ui.language", json!({"value": language})).unwrap();
a.run("ui.interfaceSize", json!({"percent": percent})).unwrap();
a.run("file.options", json!({"pane": "general"})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_pixels_per_point(2.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
h.run_steps(4);
h
}
/// #1266: check the actual painted text and renderer clip, including the bold selected
/// label. AccessKit must still expose the full name and the row must keep its fixed size.
#[test]
fn options_nav_labels_stay_inside_their_rows() {
for language in ["en", "ru"] {
for percent in [100, 125, 150] {
let mut h = nav_harness(language, percent);
// An interface size is absolute (#822): `percent` points per 100 screen pixels,
// whatever the display's own scale (2.0 here).
assert_eq!(h.ctx.pixels_per_point(), f32::from(percent) / 100.0);
for selected in ["general", "accessibility"] {
h.state_mut().run("file.options", json!({"pane": selected})).unwrap();
h.run_steps(4);
for id in wordcraft_engine::cmd::options::PANES {
let label = crate::i18n::tr(crate::i18n::Lang::from_pref(language), super::pane_label(id));
let node =
h.query_all_by_label(label).find(|n| n.accesskit_node().toggled().is_some()).expect("pane has its full accessible name");
assert_eq!(
node.accesskit_node().toggled(),
Some(if id == selected { egui::accesskit::Toggled::True } else { egui::accesskit::Toggled::False })
);
let row = node.rect();
assert_eq!(row.size(), egui::vec2(170.0, 30.0));
let (clip, text) = h
.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::Text(text) if text.galley.text() == label && row.contains(text.pos) => Some((s.clip_rect, text)),
_ => None,
})
.expect("navigation label is painted");
let bounds = text.visual_bounding_rect();
assert!(row.contains_rect(bounds.intersect(clip)), "{language} {percent}% {selected}: {label} paints outside {row:?}");
if language == "ru" && id == "accessibility" {
assert_eq!(label, "Специальные возможности");
assert!(bounds.right() > row.right(), "exercise a label wider than the row");
}
if language == "en" && ["general", "display", "save"].contains(&id) {
assert!(clip.contains_rect(bounds), "short labels must remain fully visible");
}
}
}
}
}
}
/// Clipping only the painter must leave the entire row clickable, its hover/selection
/// backgrounds intact, and the selected pane's settings visible at every interface size.
#[test]
fn options_nav_rows_keep_hover_and_selection() {
for language in ["en", "ru"] {
for percent in [100, 125, 150] {
let mut h = nav_harness(language, percent);
for (id, setting) in
[("display", "Spaces"), ("save", "Save documents"), ("accessibility", "Visual effects"), ("general", "Interface")]
{
let lang = crate::i18n::Lang::from_pref(language);
let row = h
.query_all_by_label(crate::i18n::tr(lang, super::pane_label(id)))
.find(|n| n.accesskit_node().toggled().is_some())
.expect("pane has its full accessible name")
.rect();
// Both ends of the row, including its empty left inset, stay clickable.
for x in [row.left() + 2.0, row.right() - 2.0] {
h.state_mut().run("file.options", json!({"pane": "agents"})).unwrap();
h.hover_at(egui::pos2(x, row.center().y));
h.run_steps(4);
let t = crate::theme::Tokens::get(&h.ctx);
let background = |h: &egui_kittest::Harness<'_, WordApp>, color| {
h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.rect == row && r.fill == color))
};
assert!(background(&h, t.hover), "{language} {percent}%: hover covers the row");
for pressed in [true, false] {
h.input_mut().events.push(egui::Event::PointerButton {
pos: egui::pos2(x, row.center().y),
button: egui::PointerButton::Primary,
pressed,
modifiers: Default::default(),
});
h.run_steps(1);
}
h.run_steps(4);
assert_eq!(h.state().ui.options_pane, id);
assert!(background(&h, t.checked), "{language} {percent}%: selection covers the row");
assert!(h.query_by_label(crate::i18n::tr(lang, setting)).is_some(), "{id} settings are shown");
}
}
}
}
}
/// File › Options settings come back after a restart, and a `ui.json` with a wrong value in
/// one setting keeps every other setting (#485).
#[test]