collective

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

Table: keep the buttons in view in narrow windows (#317)

[Feature Request] Make table view responsive for smaller viewports ### Description Currently, the table view in Craft Launcher lacks responsiveness on smaller screen widths or resized windows. Specifically, columns such as: * **"Installed..."** (installation path / status) * **"Open"** (action buttons) (you can click the icon or row to open the app) * **Date / Created** take up a significant amount of horizontal space, leading to layout crowding, text wrapping, or unnecessary horizontal scrolling. ### Proposed Solution * **Responsive Column Hiding:** Hide non-essential columns (e.g., date or extended status) on mobile/smaller screens or move them into a

Status
open
Score
0.00
Adopters
0 other operators
Author
atahabilder1 (imported from pull request #380, open)
Base
ba3b0345f0b14f5cb892dcb8b917ca212c124cf9
Size
+118 −40 in 2 files
Review
pending
Shared
2026-10-28
Id
b6f4b3a2cce778c50cb4199c

How it went

No other operator has reported using it yet.

Diff

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

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@@ -51,6 +51,21 @@ pub(crate) const HEADER_H: f32 = 48.0; /// Height of the footer bar. pub(crate) const FOOTER_H: f32 = 34.0; +/// The table's App column never gets narrower than this.+const TABLE_APP_MIN_W: f32 = 220.0;+const TABLE_STATUS_W: f32 = 176.0;+const TABLE_ACTIONS_W: f32 = 206.0;+/// The columns a narrow table can do without, with their widths, kept in this order: Released goes+/// first, then Version, so the action buttons stay in the window.+const TABLE_OPTIONAL: [(TableColumn, f32); 2] = [(TableColumn::Version, 150.0), (TableColumn::Released, 104.0)];++/// A table column a narrow window can drop.+#[derive(Clone, Copy, PartialEq)]+enum TableColumn {+    Version,+    Released,+}+ const CARD_MIN_W: f32 = 320.0; const CARD_H: f32 = 314.0; /// The card's top strip: number tab and links.@@ -538,23 +553,39 @@ 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| {-            TableBuilder::new(ui)+            // A narrow window drops the optional columns that don't fit, rather than push the+            // buttons out of view.+            let spacing = ui.spacing().item_spacing.x;+            let mut room = ui.available_width() - TABLE_APP_MIN_W - TABLE_STATUS_W - TABLE_ACTIONS_W - 2.0 * spacing;+            let shown: Vec<TableColumn> = TABLE_OPTIONAL+                .iter()+                .take_while(|(_, w)| {+                    room -= w + spacing;+                    room >= 0.0+                })+                .map(|(k, _)| *k)+                .collect();+            let mut table = 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(150.0))-                .column(Column::exact(104.0))-                .column(Column::exact(206.0))+                .column(Column::remainder().at_least(TABLE_APP_MIN_W).clip(true))+                .column(Column::exact(TABLE_STATUS_W));+            for (c, w) in TABLE_OPTIONAL {+                if shown.contains(&c) {+                    table = table.column(Column::exact(w));+                }+            }+            table+                .column(Column::exact(TABLE_ACTIONS_W))                 .header(42.0, |mut header| {-                    for (key, label) in [-                        (Some(SortKey::Name), tl!("App")),-                        (Some(SortKey::Status), tl!("Status")),-                        (None, tr_ctx(current(), "column", "Version")),-                        (Some(SortKey::Released), tr_ctx(current(), "column", "Released")),-                        (None, ""),-                    ] {+                    let optional = shown.iter().map(|&c| match c {+                        TableColumn::Version => (None, tr_ctx(current(), "column", "Version")),+                        TableColumn::Released => (Some(SortKey::Released), tr_ctx(current(), "column", "Released")),+                    });+                    let columns =+                        [(Some(SortKey::Name), tl!("App")), (Some(SortKey::Status), tl!("Status"))].into_iter().chain(optional).chain([(None, "")]);+                    for (key, label) in columns {                         header.col(|ui| {                             if key == Some(SortKey::Name) {                                 rule(ui);@@ -563,6 +594,10 @@ impl LauncherUi {                                 // A column that is not sorted still names itself.                                 if !label.is_empty() {                                     widgets::hud(ui, label, t.faint);+                                } else if !shown.contains(&TableColumn::Released) {+                                    // Over the buttons, where the header is empty: the sort menu, once+                                    // Released is hidden, so the table can still be sorted by it.+                                    self.sort_picker(ui);                                 }                                 return;                             };@@ -655,35 +690,43 @@ impl LauncherUi {                                     r.on_hover_text(fmt(tl!("Couldn't check for updates: {f}"), &[("f", f)]));                                 }                             });-                            row.col(|ui| {-                                // The version: what is installed, with an arrow to the new one in the app's colour-                                // when an update waits; the latest for an app not installed yet.-                                let mut mono = |text: String, color| ui.label(RichText::new(text).family(egui::FontFamily::Monospace).color(color));-                                match (status == AppStatus::UpdateAvailable).then(|| entry.installed_label().zip(entry.latest_label())).flatten() {-                                    Some((from, to)) => {-                                        mono(format!("{from} → {to}"), t.app_ink(entry.app));-                                    }-                                    None => match (entry.installed_label(), entry.latest_label()) {-                                        (Some(installed), _) => {-                                            mono(installed, t.ink);-                                        }-                                        (None, Some(latest)) => {-                                            mono(latest, t.faint);+                            for &c in &shown {+                                row.col(|ui| match c {+                                    TableColumn::Version => {+                                        // The version: what is installed, with an arrow to the new one in the app's colour+                                        // when an update waits; the latest for an app not installed yet.+                                        let mut mono =+                                            |text: String, color| ui.label(RichText::new(text).family(egui::FontFamily::Monospace).color(color));+                                        match (status == AppStatus::UpdateAvailable)+                                            .then(|| entry.installed_label().zip(entry.latest_label()))+                                            .flatten()+                                        {+                                            Some((from, to)) => {+                                                mono(format!("{from} → {to}"), t.app_ink(entry.app));+                                            }+                                            None => match (entry.installed_label(), entry.latest_label()) {+                                                (Some(installed), _) => {+                                                    mono(installed, t.ink);+                                                }+                                                (None, Some(latest)) => {+                                                    mono(latest, t.faint);+                                                }+                                                (None, None) => {+                                                    mono("—".into(), t.faint);+                                                }+                                            },                                         }-                                        (None, None) => {-                                            mono("—".into(), t.faint);+                                    }+                                    TableColumn::Released => {+                                        let when = entry.release().and_then(|r| r.published_at);+                                        let r = ui.label(RichText::new(when.map_or_else(|| "—".into(), date_text)).color(t.muted));+                                        if let Some(w) = when {+                                            r.on_hover_text(ago_text(w, now));                                         }-                                    },-                                }-                            });-                            row.col(|ui| {-                                let when = entry.release().and_then(|r| r.published_at);-                                let r = ui.label(RichText::new(when.map_or_else(|| "—".into(), date_text)).color(t.muted));-                                if let Some(w) = when {-                                    r.on_hover_text(ago_text(w, now));-                                }-                            });-                            row.col(|ui| self.actions(ui, &entry, &status, 204.0));+                                    }+                                });+                            }+                            row.col(|ui| self.actions(ui, &entry, &status, TABLE_ACTIONS_W - 2.0));                         });                     }                 });diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs--- a/crates/ui-egui/tests/ui.rs+++ b/crates/ui-egui/tests/ui.rs@@ -1998,3 +1998,38 @@ fn an_apps_page_is_a_card_in_the_middle_with_next_and_previous_arrows_and_closes     h.run_steps(30);     assert!(!showing(&h, "photocraft"), "a click outside did not close it"); }++#[test]+fn a_narrow_table_keeps_each_apps_buttons_in_the_window() {+    let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true);+    for width in [1180.0, 900.0, 720.0] {+        h.set_size(egui::vec2(width, 500.0));+        h.run_steps(6);+        snapshot(&mut h, &format!("table-{width}"));+        let button = h.get_by_label("Update to 0.5.0").rect();+        assert!(button.right() <= width, "at {width} px the button ends at {}", button.right());+        for column in ["App", "Status"] {+            assert!(h.query_by_label(column).is_some(), "at {width} px the {column} column is gone");+        }+    }+}++#[test]+fn a_narrow_table_still_sorts_by_the_columns_it_hides() {+    let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true);+    h.set_size(egui::vec2(1180.0, 500.0));+    h.run_steps(6);+    let menu = egui::accesskit::Role::ComboBox;+    assert!(h.query_by_role(menu).is_none(), "a table with every column sorts from its headers");+    h.set_size(egui::vec2(720.0, 500.0));+    h.run_steps(6);+    assert!(h.query_by_label("Released").is_none());+    h.get_by_role(menu).click();+    h.run_steps(2);+    // A click in the menu's popup goes through AccessKit: the simulated pointer misses it.+    h.get_by_label("Recently updated").click_accesskit();+    h.run_steps(3);+    let settings = &h.state().as_ref().unwrap().settings;+    assert_eq!(settings.sort, artcraft_launcher_ui_egui::settings::SortKey::Released);+    snapshot(&mut h, "table-720-sort-menu");+}