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
@@ -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)); }); } });@@ -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");+}