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
@@ -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())@@ -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 };new file mode 100644@@ -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());+ }+ }+}