collective

Browse / github.com/storytold/craft-launcher / patch

fix(ui): table columns fit their localized headers

fix(ui): table columns fit their localized headers Every table header now fits its column in every language; the new cross-language test checks all of them. Before <img width="805" height="265" alt="image" src="https://github.com/user-attachments/assets/0b38e8cd-b4bb-4c26-967f-10dd9f4db0ae" /> After <img width="809" height="133" alt="image" src="https://github.com/user-attachments/assets/ae7cbbb9-a7b3-472b-a5e8-fc9f2a609fce" />

Status
open
Score
0.00
Adopters
0 other operators
Author
DzikStar (imported from pull request #477, open)
Base
3fd2d56999fa00a7b90f159cb686a5fc64347e7e
Size
+190 −18 in 3 files
Review
pending
Shared
2026-10-28
Id
0f09f0a850398160133d4a58

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/theme.rs crates/ui-egui/src/views.rs crates/ui-egui/tests/table_headers.rs

diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs--- a/crates/ui-egui/src/theme.rs+++ b/crates/ui-egui/src/theme.rs@@ -239,6 +239,10 @@ pub const HUD_SPACING: f32 = HUD_SIZE * 0.15; /// Buttons: 11 px Geist Mono 700, upper case, 0.12 em apart. pub const BUTTON_SPACING: f32 = HUD_SIZE * 0.12; +/// The room a table header keeps after its label for the sort arrow: the 4 px gap, the arrow and+/// a little slack.+pub const HEADER_ARROW_W: f32 = 12.0;+ /// Inter 500. pub fn medium() -> FontFamily {     FontFamily::Name("medium".into())diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs--- a/crates/ui-egui/src/views.rs+++ b/crates/ui-egui/src/views.rs@@ -510,25 +510,37 @@ impl LauncherUi {             ui.ctx().layer_painter(ui.layer_id()).with_clip_rect(clip).hline(span, y, t.hairline());         };         egui::Frame::new().fill(t.bg).stroke(t.hairline()).inner_margin(egui::Margin::symmetric(16, 0)).show(ui, |ui| {+            // The sort key each header toggles, its label in this language, and the width the+            // table was designed with. A column is never narrower than that, whatever the+            // language: a longer localized header (Polish "Zainstalowana") widens its column+            // instead of being cut off.+            let headers: [(Option<SortKey>, &str, f32); 6] = [+                (Some(SortKey::Name), tl!("App"), 220.0),+                (Some(SortKey::Status), tl!("Status"), 176.0),+                (Some(SortKey::Installed), tr_ctx(current(), "column", "Installed"), 84.0),+                (Some(SortKey::Latest), tr_ctx(current(), "column", "Latest"), 84.0),+                (Some(SortKey::Released), tr_ctx(current(), "column", "Released"), 104.0),+                (None, "", 206.0),+            ];+            // What a header needs in this language: its label plus the sort arrow. The table+            // opens at this width, and each column then holds at least its widest cell — the+            // header claiming the same width below, so the two always agree.+            let header_width = |label: &str, min: f32| {+                if label.is_empty() { min } else { (widgets::hud_galley(ui, label, Color32::PLACEHOLDER).size().x + theme::HEADER_ARROW_W).max(min) }+            };+            let widths: [f32; 6] = headers.map(|(_, label, min)| header_width(label, min));             TableBuilder::new(ui)                 .striped(false)                 .vscroll(false)                 .cell_layout(egui::Layout::left_to_right(egui::Align::Center))-                .column(Column::remainder().at_least(220.0).clip(true))-                .column(Column::exact(176.0))-                .column(Column::exact(84.0))-                .column(Column::exact(84.0))-                .column(Column::exact(104.0))-                .column(Column::exact(206.0))+                .column(Column::remainder().at_least(headers[0].2).clip(true))+                .column(Column::auto_with_initial_suggestion(widths[1]).at_least(headers[1].2))+                .column(Column::auto_with_initial_suggestion(widths[2]).at_least(headers[2].2))+                .column(Column::auto_with_initial_suggestion(widths[3]).at_least(headers[3].2))+                .column(Column::auto_with_initial_suggestion(widths[4]).at_least(headers[4].2))+                .column(Column::auto_with_initial_suggestion(widths[5]).at_least(headers[5].2))                 .header(42.0, |mut header| {-                    for (key, label) in [-                        (Some(SortKey::Name), tl!("App")),-                        (Some(SortKey::Status), tl!("Status")),-                        (Some(SortKey::Installed), tr_ctx(current(), "column", "Installed")),-                        (Some(SortKey::Latest), tr_ctx(current(), "column", "Latest")),-                        (Some(SortKey::Released), tr_ctx(current(), "column", "Released")),-                        (None, ""),-                    ] {+                    for (key, label, _) in headers {                         header.col(|ui| {                             if key == Some(SortKey::Name) {                                 rule(ui);@@ -536,12 +548,17 @@ impl LauncherUi {                             let Some(k) = key else { return };                             let active = k == self.settings.sort;                             let tip = if active { tl!("Click to reverse the order") } else { tl!("Click to sort by this column") };-                            // Measure first so the whole header cell (label + arrow) is the target.-                            let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::click());+                            // The whole cell is the sort target; the label paints at its left.+                            let cell = ui.max_rect();+                            let resp = ui.interact(cell, ui.id().with(("header-sort", label)), Sense::click());                             let hovered = resp.hovered();                             let color = if active || hovered { t.ink_strong } else { t.faint };                             let galley = widgets::hud_galley(ui, label, color);-                            let text_pos = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0);+                            // Claim only the label plus the sort arrow — never the whole cell: an+                            // auto-sized column then measures exactly what the header draws, so a+                            // localized header wider than the column widens it instead of clipping.+                            let _ = ui.allocate_space(vec2(galley.size().x + theme::HEADER_ARROW_W, ui.available_height()));+                            let text_pos = egui::pos2(cell.left(), cell.center().y - galley.size().y / 2.0);                             let arrow_x = text_pos.x + galley.size().x + 4.0;                             let p = ui.painter();                             p.galley(text_pos, galley, color);@@ -554,7 +571,7 @@ impl LauncherUi {                                 None                             };                             if let Some(down) = arrow {-                                let c = egui::pos2(arrow_x + 4.0, rect.center().y);+                                let c = egui::pos2(arrow_x + 4.0, cell.center().y);                                 let (tip_y, base_y) = if down { (c.y + 3.0, c.y - 2.0) } else { (c.y - 3.0, c.y + 2.0) };                                 let pts = vec![egui::pos2(c.x - 3.5, base_y), egui::pos2(c.x + 3.5, base_y), egui::pos2(c.x, tip_y)];                                 let arrow_color = if active { t.accent_ink } else { t.faint };diff --git a/crates/ui-egui/tests/table_headers.rs b/crates/ui-egui/tests/table_headers.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/table_headers.rs@@ -0,0 +1,151 @@+//! Every table column header must be drawn in full, in every language: a localized label longer+//! than the column ("Zainstalowana" - PL) widens its column instead of being cut off at the edge.+//! One test: the UI language is process-wide. `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves+//! screenshots (run with `-- --test-threads=1`).++use std::cell::Cell;+use std::rc::Rc;++use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, LatestRelease, Origin, Source};+use artcraft_launcher_core::release::{Asset, Release};+use artcraft_launcher_core::status::InstallSource;+use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version};+use artcraft_launcher_ui_egui::i18n::{Lang, tr, tr_ctx};+use artcraft_launcher_ui_egui::settings::ViewMode;+use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings, theme};+use egui_kittest::Harness;+use egui_kittest::kittest::Queryable;++const NOW: i64 = 1_791_500_000;++struct Fake {+    events: Vec<Event>,+    clock: Rc<Cell<i64>>,+}++impl Backend for Fake {+    fn send(&mut self, _command: Command) {}+    fn poll(&mut self) -> Vec<Event> {+        std::mem::take(&mut self.events)+    }+    fn capabilities(&self) -> Capabilities {+        Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }+    }+    fn platform(&self) -> Option<Platform> {+        Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })+    }+    fn now(&self) -> i64 {+        self.clock.get()+    }+}++struct NoHost;+impl Host for NoHost {}++fn release(id: &str, v: &str, at: i64) -> Release {+    let name = format!("{id}-{v}-macos-universal.dmg");+    Release {+        tag: format!("v{v}"),+        version: version::parse(v),+        name: format!("v{v}"),+        published_at: Some(at),+        html_url: format!("https://github.com/storytold/{id}/releases/tag/v{v}"),+        notes: String::new(),+        prerelease: false,+        assets: vec![Asset {+            name: name.clone(),+            size: 150 << 20,+            url: format!("https://github.com/storytold/{id}/releases/download/v{v}/{name}"),+            sha256: None,+        }],+    }+}++fn events() -> Vec<Event> {+    let mut ev = Vec::new();+    for (i, app) in SUITE.iter().enumerate() {+        let latest = LatestRelease::Found(release(app.id, "0.5.0", NOW - 40 * 86_400 - i as i64 * 600));+        ev.push(Event::Latest { app: app.id.into(), latest, from: Origin::ArtCraftApi });+        // One app with an update, one up to date: their rows carry the full set of badges and+        // buttons, so the columns are measured against real content.+        let installed = match app.id {+            "photocraft" | "lightcraft" => {+                let v = if app.id == "photocraft" { "0.4.1" } else { "0.5.0" };+                Some(Installed {+                    version: version::parse(v),+                    source: InstallSource::MacApp,+                    location: format!("/Applications/{}.app", app.name),+                    package: None,+                })+            }+            _ => None,+        };+        ev.push(Event::Detected { app: app.id.into(), installed });+    }+    ev.push(Event::CheckFinished { at: NOW - 180, source: Source::Feed, rate_limited_until: None });+    ev+}++fn harness(language: &str) -> Harness<'static, Option<LauncherUi>> {+    let mut backend = Some(Fake { events: events(), clock: Rc::new(Cell::new(NOW)) });+    let settings = Settings { view: ViewMode::Table, notify: false, language: language.into(), ..Settings::default() };+    Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(egui::Theme::Dark).build_ui_state(+        move |ui, state: &mut Option<LauncherUi>| {+            if state.is_none()+                && let Some(b) = backend.take()+            {+                *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings.clone()));+            }+            if let Some(l) = state.as_mut() {+                l.logic(&ui.ctx().clone(), &mut NoHost);+                l.ui(ui, &mut NoHost);+            }+        },+        None,+    )+}++fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {+    let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return };+    let img = h.render().unwrap();+    img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();+}++/// What a table header needs in points: its label in the HUD face, upper case, plus the sort+/// arrow. This mirrors what `views::table` gives the column as its floor and what the header+/// cell claims as its width; the two must always agree.+fn header_needs(ctx: &egui::Context, label: &str) -> f32 {+    let format = egui::TextFormat {+        font_id: egui::FontId::new(theme::HUD_SIZE, theme::mono()),+        color: egui::Color32::PLACEHOLDER,+        extra_letter_spacing: theme::HUD_SPACING,+        ..Default::default()+    };+    let job = egui::text::LayoutJob::single_section(label.to_uppercase(), format);+    ctx.fonts_mut(|f| f.layout_job(job)).size().x + theme::HEADER_ARROW_W+}++#[test]+fn table_headers_fit_in_every_language() {+    artcraft_launcher_ui_egui::i18n::set_test_lang(Lang::EN);+    for lang in Lang::all() {+        let mut h = harness(lang.code());+        h.run_steps(8);+        snapshot(&mut h, &format!("table-headers-{}", lang.code()));+        // A CJK language is drawn in English where the system has no CJK font (`effective_lang`):+        // the headers then read English too.+        let shown = if lang.needs_cjk_font() && !theme::cjk_font_available(lang) { Lang::EN } else { lang };+        for label in [+            tr(shown, "App"),+            tr(shown, "Status"),+            tr_ctx(shown, "column", "Installed"),+            tr_ctx(shown, "column", "Latest"),+            tr_ctx(shown, "column", "Released"),+        ] {+            let needs = header_needs(&h.ctx, label);+            let node = h.get_by_label(label);+            let has = node.rect().width();+            assert!(has + 1.0 >= needs, "{}: \"{label}\" needs {needs:.1} points but its column is {has:.1}", lang.code());+        }+    }+}