collective

Browse / github.com/storytold/photocraft / patch

Image Size, Canvas Size: a translated label widens its column instead of running out of the dialog (#9634)

Image Size and Canvas Size: a longer translation runs out of the dialog on the left **TL;DR:** The Image Size and Canvas Size dialogs draw their labels right-aligned in a fixed 90 px column (150 px for Canvas extension color), so a translation wider than that runs past the dialog's left edge. In Italian, "Dimensione immagine:" starts about 43 px outside the dialog; in German, Canvas Size's "Farbe der Zeichenflächenerweiterung:" about 72 px. ## What goes wrong `label()` in `crates/ui-egui/src/sizing.rs` allocates a fixed-width rect and paints the text with `Align2::RIGHT_CENTER` at its right edge. Nothing bounds the text on the left, so a label wider than the column overruns

Status
open
Score
0.00
Adopters
0 other operators
Author
driin0 (imported from pull request #9636, open)
Base
59cb4c49bd236df04d8e37d4764253f29ca6600a
Size
+87 −18 in 1 file
Review
pending
Shared
2026-10-28
Id
6f6f0ffa97dd6335416e25a5

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/sizing.rs

diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs--- a/crates/ui-egui/src/sizing.rs+++ b/crates/ui-egui/src/sizing.rs@@ -159,16 +159,40 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {     } } +/// Narrowest and widest label column: English fits the narrowest; a longer translation widens+/// the column (the dialog grows with it) up to the widest, and past that elides, as+/// `docs/ui-design.md` asks of a label beside a fixed-width control.+const LABEL_MIN_W: f32 = 90.0;+const LABEL_MAX_W: f32 = 240.0;+/// Gap between a label and its field.+const LABEL_GAP: f32 = 6.0;++fn label_font() -> egui::FontId {+    egui::FontId::proportional(12.0)+}++/// Width of a label column that holds every one of `labels` in the current language.+fn label_col(ui: &egui::Ui, labels: &[&str]) -> f32 {+    let widest = labels.iter().map(|s| ui.painter().layout_no_wrap((*s).to_owned(), label_font(), Color32::PLACEHOLDER).size().x).fold(0.0, f32::max);+    (widest + LABEL_GAP).ceil().clamp(LABEL_MIN_W, LABEL_MAX_W)+}+ fn label(ui: &mut egui::Ui, text: &str, width: f32) {     let t = Tokens::get(ui.ctx());-    let (r, _) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover());-    ui.painter().text(pos2(r.right() - 6.0, r.center().y), Align2::RIGHT_CENTER, text, egui::FontId::proportional(12.0), t.text_dim);+    let (r, resp) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover());+    let galley = crate::tab_strip::elided(ui, text, label_font(), t.text_dim, width - LABEL_GAP);+    let elided = galley.elided;+    let rect = Align2::RIGHT_CENTER.anchor_size(pos2(r.right() - LABEL_GAP, r.center().y), galley.size());+    ui.painter().galley(rect.min, galley, t.text_dim);+    if elided {+        resp.on_hover_text(text);+    } } -fn info_row(ui: &mut egui::Ui, k: &str, v: String) {+fn info_row(ui: &mut egui::Ui, k: &str, v: String, label_w: f32) {     let t = Tokens::get(ui.ctx());     ui.horizontal(|ui| {-        label(ui, tl!(k), 90.0);+        label(ui, tl!(k), label_w);         ui.label(egui::RichText::new(v).color(t.text).size(12.0));     }); }@@ -221,8 +245,14 @@ fn image_size(ui: &mut egui::Ui, f: &mut Map<String, Value>) {         f.insert("height".into(), json!(oh));     }     let (w, h) = (num(f, "width").round(), num(f, "height").round());-    info_row(ui, tl!("Image Size:"), crate::i18n::fmt(tl!("{size} (was {old})"), &[("size", &human_bytes(w * h * bpp)), ("old", &human_bytes(ow * oh * bpp))]));-    info_row(ui, tl!("Dimensions:"), format!("{} px × {} px", w as i64, h as i64));+    let label_w = label_col(ui, &[tl!("Image Size:"), tl!("Dimensions:"), tl!("Width:"), tl!("Height:"), tl!("Resolution:")]);+    info_row(+        ui,+        tl!("Image Size:"),+        crate::i18n::fmt(tl!("{size} (was {old})"), &[("size", &human_bytes(w * h * bpp)), ("old", &human_bytes(ow * oh * bpp))]),+        label_w,+    );+    info_row(ui, tl!("Dimensions:"), format!("{} px × {} px", w as i64, h as i64), label_w);     ui.add_space(6.0);     // Photoshop keeps width and height linked while Resample is off.     let mut constrain = !resampling || f.get("__constrain").and_then(Value::as_bool).unwrap_or(true);@@ -231,13 +261,13 @@ fn image_size(ui: &mut egui::Ui, f: &mut Map<String, Value>) {     let unit = f.get("__unit").and_then(Value::as_str).unwrap_or("px").to_string();     let editable = resampling || !matches!(unit.as_str(), "px" | "percent");     let w_row = ui.horizontal(|ui| {-        label(ui, tl!("Width:"), 90.0);+        label(ui, tl!("Width:"), label_w);         let c = ui.add_enabled_ui(editable, |ui| dim_field(ui, f, "width", "__origW", "is-w")).inner;         unit_dropdown(ui, f, "is-unit-w");         c     });     let h_row = ui.horizontal(|ui| {-        label(ui, tl!("Height:"), 90.0);+        label(ui, tl!("Height:"), label_w);         let c = ui.add_enabled_ui(editable, |ui| dim_field(ui, f, "height", "__origH", "is-h")).inner;         unit_dropdown(ui, f, "is-unit-h");         c@@ -290,7 +320,7 @@ fn image_size(ui: &mut egui::Ui, f: &mut Map<String, Value>) {         }     }     ui.horizontal(|ui| {-        label(ui, tl!("Resolution:"), 90.0);+        label(ui, tl!("Resolution:"), label_w);         let mut r = f.get("resolution").and_then(Value::as_f64).unwrap_or(72.0) as f32;         if crate::widgets::value_field(ui, &mut r, 1.0..=30000.0, "", 90.0).accessible_name(tl!("Resolution")).changed() {             let r = r.max(1.0) as f64;@@ -314,7 +344,7 @@ fn image_size(ui: &mut egui::Ui, f: &mut Map<String, Value>) {     });     ui.add_space(4.0);     ui.horizontal(|ui| {-        label(ui, "", 90.0);+        label(ui, "", label_w);         let mut on = resampling;         if crate::widgets::checkbox(ui, &mut on, tl!("Resample")).changed() {             // Back on, it takes the method it had when the dialog opened.@@ -354,10 +384,11 @@ fn canvas_size(ui: &mut egui::Ui, f: &mut Map<String, Value>) {     let (ow, oh) = (num(f, "__origW"), num(f, "__origH"));     let bpp = num(f, "__bytesPerPixel").max(1.0);     let relative = f.get("relative").and_then(Value::as_bool).unwrap_or(false);+    let label_w = label_col(ui, &[tl!("Size:"), tl!("Width:"), tl!("Height:"), tl!("Anchor:")]);     ui.label(egui::RichText::new(tl!("Current Size")).font(crate::theme::semibold(12.0)).color(t.text));-    info_row(ui, tl!("Size:"), human_bytes(ow * oh * bpp));-    info_row(ui, tl!("Width:"), format!("{} px", ow as i64));-    info_row(ui, tl!("Height:"), format!("{} px", oh as i64));+    info_row(ui, tl!("Size:"), human_bytes(ow * oh * bpp), label_w);+    info_row(ui, tl!("Width:"), format!("{} px", ow as i64), label_w);+    info_row(ui, tl!("Height:"), format!("{} px", oh as i64), label_w);     ui.add_space(6.0);     crate::widgets::hairline(ui);     ui.add_space(6.0);@@ -367,17 +398,17 @@ fn canvas_size(ui: &mut egui::Ui, f: &mut Map<String, Value>) {         ui.label(egui::RichText::new(human_bytes(nw.max(0.0) * nh.max(0.0) * bpp)).color(t.text_dim).size(12.0));     });     ui.horizontal(|ui| {-        label(ui, tl!("Width:"), 90.0);+        label(ui, tl!("Width:"), label_w);         dim_field(ui, f, "width", "__origW", "cs-w");         unit_dropdown(ui, f, "cs-unit-w");     });     ui.horizontal(|ui| {-        label(ui, tl!("Height:"), 90.0);+        label(ui, tl!("Height:"), label_w);         dim_field(ui, f, "height", "__origH", "cs-h");         unit_dropdown(ui, f, "cs-unit-h");     });     ui.horizontal(|ui| {-        label(ui, "", 90.0);+        label(ui, "", label_w);         let mut rel = relative;         if crate::widgets::checkbox(ui, &mut rel, tl!("Relative")).changed() {             // Convert between absolute and delta so the resulting canvas is unchanged.@@ -389,7 +420,7 @@ fn canvas_size(ui: &mut egui::Ui, f: &mut Map<String, Value>) {         }     });     ui.horizontal(|ui| {-        label(ui, tl!("Anchor:"), 90.0);+        label(ui, tl!("Anchor:"), label_w);         let cur = f.get("anchor").and_then(Value::as_str).unwrap_or("center").to_string();         if let Some(a) = anchor_grid(ui, &cur, nw - ow, nh - oh) {             f.insert("anchor".into(), json!(a));@@ -397,7 +428,8 @@ fn canvas_size(ui: &mut egui::Ui, f: &mut Map<String, Value>) {     });     ui.add_space(4.0);     ui.horizontal(|ui| {-        label(ui, tl!("Canvas extension color:"), 150.0);+        let ext = tl!("Canvas extension color:");+        label(ui, ext, label_col(ui, &[ext]).max(150.0));         let mut cur = f.get("extensionColor").and_then(Value::as_str).unwrap_or("background").to_string();         let opts = [             ("foreground".to_string(), tl!("Foreground")),@@ -502,6 +534,43 @@ mod tests {         assert_eq!(pt_per_in(&f), 72.27);     } +    /// A translated label wider than the English column ("Dimensione immagine:" in Italian) widens+    /// the column instead of running out of the dialog on the left; one too long for the widest+    /// column elides, and the wider column still fits the room the dialog is given on the right.+    #[test]+    fn translated_labels_stay_inside_the_dialog() {+        let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());+        app.session.execute("file.new", json!({"width": 200, "height": 100})).unwrap();+        let mut failures = Vec::new();+        for command in ["image.imageSize", "image.canvasSize"] {+            let id = open(&mut app, command).unwrap();+            let fields = app.ui.dialog_mut(id).unwrap().fields.clone();+            for lang in crate::i18n::Lang::all() {+                let _language = crate::i18n::language_scope(lang);+                // The test fonts have no CJK, Indic or Arabic faces: tofu boxes stand in for+                // those glyphs, laid out and measured like any other.+                let mut h = egui_kittest::Harness::builder().with_size(vec2(700.0, 400.0)).allow_missing_glyphs().build_ui_state(+                    |ui, (f, room): &mut (Map<String, Value>, Rect)| {+                        *room = ui.max_rect();+                        body(ui, f);+                    },+                    (fields.clone(), Rect::NOTHING),+                );+                PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);+                h.run_steps(2);+                let room = h.state().1;+                let texts: Vec<Rect> =+                    h.output().shapes.iter().filter(|s| matches!(s.shape, egui::Shape::Text(_))).map(|s| s.shape.visual_bounding_rect()).collect();+                assert!(!texts.is_empty(), "{command} {}: draws its labels", lang.code());+                if let Some(r) = texts.iter().find(|r| r.left() < room.left() - 0.5 || r.right() > room.right() + 0.5) {+                    failures.push(format!("{command} {}: text at {r:?} outside the dialog's {room:?}", lang.code()));+                }+            }+            app.ui.dialogs.retain(|d| d.id != id);+        }+        assert!(failures.is_empty(), "{failures:#?}");+    }+     #[test]     fn dialog_maps_to_engine_command() {         let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());