From c8d9c237ba5b45bef0c09d35f8a25ba32cdf7534 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 5 Nov 2026 17:54:32 +0530 Subject: [PATCH] feat(ui): stack Open over Update in a table row too narrow for both side by side Long languages (German, French, Czech, Polish, Hungarian, Spanish, Russian, Portuguese) don't fit OPEN | UPDATE in the table's action slot. Their rows now grow to hold Open, naming its version, above Update, instead of dropping Open into the menu. Cards and the details page keep a fixed height and stay side by side, or show the update alone. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/details.rs | 4 +- crates/ui-egui/src/views.rs | 182 ++++++++++++++++++++++----------- crates/ui-egui/src/widgets.rs | 75 +++++++++++--- crates/ui-egui/tests/german.rs | 59 ++++++----- 4 files changed, 220 insertions(+), 100 deletions(-) diff --git a/crates/ui-egui/src/details.rs b/crates/ui-egui/src/details.rs index 4db01ae2..5ee4c115 100644 --- a/crates/ui-egui/src/details.rs +++ b/crates/ui-egui/src/details.rs @@ -319,7 +319,7 @@ impl LauncherUi { // The menu leaves out the links: they are the buttons right below. if !wide { let row_w = ui.available_width(); - ui.horizontal(|ui| self.actions_in(ui, entry, &status, row_w, false)); + ui.horizontal(|ui| self.actions_in(ui, entry, &status, row_w, crate::views::Place::Details)); } link_buttons(ui, app); }); @@ -376,7 +376,7 @@ impl LauncherUi { // The main button and its menu, at the right, level with the name. ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.allocate_ui_with_layout(vec2(ACTION_W, theme::CONTROL_H), egui::Layout::left_to_right(egui::Align::Center), |ui| { - self.actions_in(ui, entry, status, ACTION_W, false); + self.actions_in(ui, entry, status, ACTION_W, crate::views::Place::Details); }); }); } diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index b65a00ed..591d079c 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -75,6 +75,71 @@ enum Primary { Unavailable(&'static str), } +/// Where an app's actions are drawn. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Place { + /// A grid card, at a fixed height. + Card, + /// A table row, which grows to stack an update's split where it can't sit side by side. + Row, + /// The details page, which shows the website and repository links as buttons of its own. + Details, +} + +impl Place { + /// Whether the `⋯` menu holds the website, repository and releases links. + fn links_in_menu(self) -> bool { + self != Self::Details + } +} + +/// What an update's install half says: its full label, shorter ones for less room, and a tooltip. +struct UpdateLabels { + label: String, + shorter: Vec<&'static str>, + tip: Option<&'static str>, +} + +impl UpdateLabels { + /// The labels when `primary` installs an update or retries one; `None` otherwise. + fn of(primary: Primary) -> Option { + match primary { + Primary::Retry(label, shorter) => { + Some(Self { label, shorter, tip: Some(tl!("The download is still here. It's checked again, then installed.")) }) + } + Primary::Update(v) => Some(Self { label: fmt(tl!("Update to {v}"), &[("v", &v)]), shorter: vec![tl!("Update")], tip: None }), + _ => None, + } + } +} + +/// The Open half beside a waiting update: its label, naming the installed version, and what it +/// does in full (its tooltip and accessible name). `None` unless an installed copy waits for an +/// update. +fn open_labels(entry: &AppEntry, status: &AppStatus) -> Option<(String, String)> { + let (installed, latest) = (*status == AppStatus::UpdateAvailable).then(|| entry.installed_label().zip(entry.latest_label())).flatten()?; + let open = fmt(tl!("Open {version}"), &[("version", &installed)]); + let about = fmt(tl!("Open {installed} without updating. {latest} stays available."), &[("installed", &installed), ("latest", &latest)]); + Some((open, about)) +} + +/// How Open (`open`, then plain "Open") and an update (`update`) share `main_w` in `place`: side +/// by side, stacked in a table row, or `None` for the update alone. +fn update_split<'a>(ui: &Ui, place: Place, main_w: f32, open: &'a str, update: &'a UpdateLabels) -> Option> { + let rights: Vec<&str> = std::iter::once(update.label.as_str()).chain(update.shorter.iter().copied()).collect(); + widgets::split_layout(ui, main_w, &[open, tl!("Open")], &rights, place == Place::Row) +} + +/// The width of the main button: what's left of `width` beside the square `⋯` menu. +fn main_width(ui: &Ui, width: f32) -> f32 { + (width - theme::CONTROL_H - ui.spacing().item_spacing.x).max(120.0) +} + +/// The width of a table row's actions, after a pinned row's grip. +const TABLE_ACTIONS_W: f32 = 204.0; +/// The height of a table row. +const TABLE_ROW_H: f32 = 62.0; + /// Height of the header bar (the site's nav). pub(crate) const HEADER_H: f32 = 48.0; /// Height of the footer bar. @@ -592,7 +657,7 @@ impl LauncherUi { ui.add(egui::Label::new(RichText::new(line).size(12.5).color(t.faint)).selectable(false)); } ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { - ui.horizontal(|ui| self.actions(ui, &entry, &status, inner.width())); + ui.horizontal(|ui| self.actions_in(ui, &entry, &status, inner.width(), Place::Card)); }); } @@ -734,7 +799,8 @@ impl LauncherUi { for (i, id) in ids.iter().enumerate() { let Some(entry) = self.entry(id).cloned() else { continue }; let status = entry.status(self.platform, self.caps.manage_apps); - body.row(62.0, |mut row| { + let row_h = self.table_row_h(body.ui_mut(), &entry, &status); + body.row(row_h, |mut row| { row.col(|ui| self.table_app_cell(ui, &entry, id, i < last, t, (fonts.0, fonts.1, rule.0, rule.1))); row.col(|ui| self.table_status_cell(ui, &entry, &status, t)); row.col(|ui| self.table_version_cell(ui, &entry, &status, t)); @@ -746,10 +812,10 @@ impl LauncherUi { // every row's buttons stay in line. row.col(|ui| { if self.settings.is_pinned(id) { - self.actions(ui, &entry, &status, 204.0 + grip_w); + self.actions_in(ui, &entry, &status, TABLE_ACTIONS_W + grip_w, Place::Row); } else { ui.add_space(grip_w); - self.actions(ui, &entry, &status, 204.0); + self.actions_in(ui, &entry, &status, TABLE_ACTIONS_W, Place::Row); } }); }); @@ -889,24 +955,33 @@ impl LauncherUi { } } - /// The main button (or progress) and the … menu, within `width`. - pub(crate) fn actions(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, width: f32) { - self.actions_in(ui, entry, status, width, true); + /// A table row's height: taller where its update's Open and Update stack because they don't + /// fit side by side (a language with long words). Mirrors [`Self::actions_in`]: a running or + /// paused download shows its own controls instead. + fn table_row_h(&self, ui: &Ui, entry: &AppEntry, status: &AppStatus) -> f32 { + let busy = entry.job.is_some() || (entry.paused.is_some() && self.caps.manage_apps); + let stacked = !busy + && UpdateLabels::of(self.primary_action(entry, status)).zip(open_labels(entry, status)).is_some_and(|(update, (open, _))| { + matches!(update_split(ui, Place::Row, main_width(ui, TABLE_ACTIONS_W), &open, &update), Some(widgets::Split::Stacked { .. })) + }); + // The same space above and below as a row's single button has, halved. + if stacked { widgets::STACKED_H + (TABLE_ROW_H - theme::CONTROL_H) / 2.0 } else { TABLE_ROW_H } } - /// The main button and the `⋯` menu beside it. `links_in_menu` is false where the page shows - /// the website, GitHub and releases links as buttons of its own. In the grid and the table - /// (`links_in_menu`), a pinned app's actions start with the handle that reorders it, which takes - /// one square of `width` (the table widens its column by [`Self::grip_w`] for it). - pub(crate) fn actions_in(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, mut width: f32, links_in_menu: bool) { + /// The main button and the `⋯` menu beside it, within `width`, as `place` has them. On the + /// details page the website, GitHub and releases links are buttons of its own, not menu rows. + /// In the grid and the table, a pinned app's actions start with the handle that reorders it, + /// which takes one square of `width` (the table widens its column by [`Self::grip_w`] for it). + pub(crate) fn actions_in(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, mut width: f32, place: Place) { let id = entry.app.id.to_string(); let gap = ui.spacing().item_spacing.x; + let links_in_menu = place.links_in_menu(); if links_in_menu && self.settings.is_pinned(entry.app.id) { self.pin_grip(ui, entry); width -= theme::CONTROL_H + gap; } // One square button (the menu) beside the main one. - let main_w = (width - theme::CONTROL_H - gap).max(120.0); + let main_w = main_width(ui, width); if entry.job.is_some() { self.job_actions(ui, entry, &id, main_w, width); return; @@ -914,7 +989,7 @@ impl LauncherUi { if self.paused_actions(ui, entry, &id, status, width, links_in_menu) { return; } - self.primary_button(ui, entry, &id, status, main_w); + self.primary_button(ui, entry, &id, status, main_w, place); self.menu(ui, entry, status, links_in_menu); } @@ -1071,20 +1146,17 @@ impl LauncherUi { } /// What the main button does: open, install/update, retry, plain download, or nothing. - fn primary_button(&mut self, ui: &mut Ui, entry: &AppEntry, id: &str, status: &AppStatus, main_w: f32) { + fn primary_button(&mut self, ui: &mut Ui, entry: &AppEntry, id: &str, status: &AppStatus, main_w: f32, place: Place) { match self.primary_action(entry, status) { Primary::Install => { if widgets::button(ui, tl!("Install"), Kind::Primary, main_w).clicked() { self.install_app(id); } } - Primary::Retry(label, shorter) => { - let tip = tl!("The download is still here. It's checked again, then installed."); - self.update_button(ui, entry, status, (&label, &shorter), Some(tip), main_w); - } - Primary::Update(v) => { - let label = fmt(tl!("Update to {v}"), &[("v", &v)]); - self.update_button(ui, entry, status, (&label, &[tl!("Update")]), None, main_w); + update @ (Primary::Retry(..) | Primary::Update(_)) => { + if let Some(labels) = UpdateLabels::of(update) { + self.update_button(ui, entry, status, &labels, main_w, place); + } } Primary::SelectedVersion { tag, checked } => { let label = fmt(tl!("Install version {version}"), &[("version", &tag)]); @@ -1112,49 +1184,35 @@ impl LauncherUi { } } - /// The button that installs an update: `label`, or one of the `shorter` ones that fits. While the - /// update waits, an installed copy can still be opened as it is: an Open half, naming its - /// version where there's room, sits before the update half. Where even "Open" and the shortest - /// update label don't fit, the update button shows alone and Open stays in the `⋯` menu. - fn update_button( - &mut self, - ui: &mut Ui, - entry: &AppEntry, - status: &AppStatus, - (label, shorter): (&str, &[&str]), - tip: Option<&str>, - main_w: f32, - ) { + /// The button that installs an update (`update`). While the update waits, an installed copy + /// can still be opened as it is: an Open half, naming its version where there's room, sits + /// before the update half, or above it in a table row too narrow for both side by side. Where + /// they don't fit at all, the update button shows alone and Open stays in the `⋯` menu. + fn update_button(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, update: &UpdateLabels, main_w: f32, place: Place) { let id = entry.app.id; - let rights: Vec<&str> = std::iter::once(label).chain(shorter.iter().copied()).collect(); - let current = (*status == AppStatus::UpdateAvailable).then(|| entry.installed_label().zip(entry.latest_label())).flatten(); - if let Some((installed, latest)) = current { - let open = fmt(tl!("Open {version}"), &[("version", &installed)]); - let lefts = [open.as_str(), tl!("Open")]; - if let Some(fit) = widgets::split_fit(ui, main_w, &lefts, &rights) { - let about = - fmt(tl!("Open {installed} without updating. {latest} stays available."), &[("installed", &installed), ("latest", &latest)]); - let (open, update) = widgets::split_button( - ui, - widgets::Half { label: &about, kind: Kind::Secondary }, - widgets::Half { label, kind: Kind::Primary }, - fit, - ); - if open.on_hover_text(&about).clicked() { - self.launch_app(id); - } - let update = match tip { - Some(tip) => update.on_hover_text(tip), - None => update, - }; - if update.clicked() { - self.install_app(id); - } - return; + if let Some((open, about)) = open_labels(entry, status) + && let Some(split) = update_split(ui, place, main_w, &open, update) + { + let (open, install) = widgets::split_button( + ui, + widgets::Half { label: &about, kind: Kind::Secondary }, + widgets::Half { label: &update.label, kind: Kind::Primary }, + split, + ); + if open.on_hover_text(&about).clicked() { + self.launch_app(id); } + let install = match update.tip { + Some(tip) => install.on_hover_text(tip), + None => install, + }; + if install.clicked() { + self.install_app(id); + } + return; } - let resp = widgets::button(ui, fitted(ui, label, shorter, main_w), Kind::Primary, main_w); - let resp = match tip { + let resp = widgets::button(ui, fitted(ui, &update.label, &update.shorter, main_w), Kind::Primary, main_w); + let resp = match update.tip { Some(tip) => resp.on_hover_text(tip), None => resp, }; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 2888b384..3ee94b28 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -439,7 +439,7 @@ pub struct Half<'a> { pub kind: Kind, } -/// The labels a [`split_button`] shows, and the width each half takes. +/// The labels of a side-by-side [`split_button`], and the width each half takes. #[derive(Clone, Copy, Debug, PartialEq)] pub struct SplitFit<'a> { pub left: &'a str, @@ -448,11 +448,38 @@ pub struct SplitFit<'a> { pub right_w: f32, } +/// How a [`split_button`] is laid out. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Split<'a> { + /// Side by side, left then right. + Side(SplitFit<'a>), + /// One above the other, each the full `width`: for labels too long to sit side by side (a + /// language with long words in a narrow slot), where the place has the height to spare. + Stacked { top: &'a str, bottom: &'a str, width: f32 }, +} + +/// The height of a [`Split::Stacked`] button: two buttons and the seam between them. +pub const STACKED_H: f32 = 2.0 * theme::CONTROL_H + SPLIT_SEAM; + +/// Lays out a [`split_button`] within `width`: side by side when a pair of labels fits +/// ([`split_fit`]); else, where `can_stack`, stacked, with the first pair whose labels each fit the +/// width. `None` when neither fits, so the caller shows a single button instead. +pub fn split_layout<'a>(ui: &Ui, width: f32, lefts: &[&'a str], rights: &[&'a str], can_stack: bool) -> Option> { + if let Some(fit) = split_fit(ui, width, lefts, rights) { + return Some(Split::Side(fit)); + } + if !can_stack { + return None; + } + let top = lefts.iter().copied().find(|l| button_width(ui, l, 0.0) <= width)?; + let bottom = rights.iter().copied().find(|r| button_width(ui, r, 0.0) <= width)?; + Some(Split::Stacked { top, bottom, width }) +} + /// Picks the first pair of labels, longest first and the left one shortened last, that fits two /// halves within `width`: equal halves when both labels fit half the room, else halves sized to -/// their labels with the room left over shared. `None` when even the shortest pair doesn't fit, -/// so the caller shows a single button instead. -pub fn split_fit<'a>(ui: &Ui, width: f32, lefts: &[&'a str], rights: &[&'a str]) -> Option> { +/// their labels with the room left over shared. `None` when even the shortest pair doesn't fit. +fn split_fit<'a>(ui: &Ui, width: f32, lefts: &[&'a str], rights: &[&'a str]) -> Option> { let room = width - SPLIT_SEAM; let half = room / 2.0; let rights: Vec<(&str, f32)> = rights.iter().map(|&r| (r, button_width(ui, r, 0.0))).collect(); @@ -472,14 +499,25 @@ pub fn split_fit<'a>(ui: &Ui, width: f32, lefts: &[&'a str], rights: &[&'a str]) None } -/// Two buttons as one control, `left` then `right` (also the keyboard order), at the widths a -/// [`split_fit`] chose. Returns each half's response. -pub fn split_button(ui: &mut Ui, left: Half<'_>, right: Half<'_>, fit: SplitFit<'_>) -> (Response, Response) { - ui.scope(|ui| { - ui.spacing_mut().item_spacing.x = SPLIT_SEAM; - let l = labelled_button(ui, fit.left, left.label, left.kind, fit.left_w); - let r = labelled_button(ui, fit.right, right.label, right.kind, fit.right_w); - (l, r) +/// Two buttons as one control, laid out as [`split_layout`] chose: `left` first (on top when +/// stacked), which is also the keyboard order. Returns each half's response. +pub fn split_button(ui: &mut Ui, left: Half<'_>, right: Half<'_>, split: Split<'_>) -> (Response, Response) { + ui.scope(|ui| match split { + Split::Side(fit) => { + ui.spacing_mut().item_spacing.x = SPLIT_SEAM; + let l = labelled_button(ui, fit.left, left.label, left.kind, fit.left_w); + let r = labelled_button(ui, fit.right, right.label, right.kind, fit.right_w); + (l, r) + } + Split::Stacked { top, bottom, width } => { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = SPLIT_SEAM; + let l = labelled_button(ui, top, left.label, left.kind, width); + let r = labelled_button(ui, bottom, right.label, right.kind, width); + (l, r) + }) + .inner + } }) .inner } @@ -868,6 +906,19 @@ mod tests { let tight = natural("Open 0.4.1") + natural("Update") + SPLIT_SEAM + 0.5; let f = split_fit(ui, tight, &lefts, &rights).expect("the version and the short update label fit"); assert_eq!((f.left, f.right), ("Open 0.4.1", "Update"), "the update label shortens before Open loses its version"); + + // Too narrow to sit side by side: stacked where the place allows, each label fitting. + let narrow = shortest - 10.0; + assert_eq!(split_layout(ui, narrow, &lefts, &rights, false), None); + match split_layout(ui, narrow, &lefts, &rights, true) { + Some(Split::Stacked { top, bottom, width }) => { + assert!(natural(top) <= width && natural(bottom) <= width, "{top} / {bottom} in {width}"); + assert_eq!(top, "Open 0.4.1", "a stacked Open has the whole width for its version"); + } + other => panic!("expected a stacked split, got {other:?}"), + } + assert!(matches!(split_layout(ui, 400.0, &lefts, &rights, true), Some(Split::Side(_))), "side by side when it fits"); + assert_eq!(split_layout(ui, 20.0, &lefts, &rights, true), None, "nothing fits 20 px"); }) .drop_without_applying_deltas(); } diff --git a/crates/ui-egui/tests/german.rs b/crates/ui-egui/tests/german.rs index f9a4bb68..21283572 100644 --- a/crates/ui-egui/tests/german.rs +++ b/crates/ui-egui/tests/german.rs @@ -194,10 +194,15 @@ fn german_switches_persists_and_keeps_install_actions() { let slot = retry.query_all_by_label("Installieren").next().unwrap().rect().width(); for app in ["PhotoCraft", "VectorCraft"] { let menu = retry.get_by_label(&format!("Weitere Aktionen für {app}")).rect(); - // The main button is the one ending just left of the menu (or overlapping it). + // The main button is the one ending just left of the menu (or overlapping it); a stacked + // Open and Update sit half a button above and below the menu's middle. let main = retry .query_all_by_role(egui::accesskit::Role::Button) - .filter(|n| (n.rect().center().y - menu.center().y).abs() < 1.0 && n.rect().left() < menu.left() && n.rect().right() > menu.left() - 20.0) + .filter(|n| { + (n.rect().center().y - menu.center().y).abs() < STACKED_OFFSET + && n.rect().left() < menu.left() + && n.rect().right() > menu.left() - 20.0 + }) .find(|n| n.rect().height() < 40.0) .unwrap(); assert!(main.rect().width() <= slot + 0.5, "{app}: {:?} wider than Installieren ({slot})", main.rect()); @@ -219,32 +224,38 @@ fn german_switches_persists_and_keeps_install_actions() { for language in &artcraft_launcher_ui_egui::i18n::LANGUAGES { main_buttons_fit_the_narrowest_window(language.code); } - a_table_update_too_narrow_to_split_keeps_open_in_the_menu(); + a_table_update_too_narrow_to_sit_side_by_side_stacks(); } -/// German's "ÖFFNEN | AKTUALISIEREN" doesn't fit the table's action slot, so PhotoCraft's update -/// shows alone there; its installed copy still opens from the `⋯` menu. Called from the one test -/// above: the UI language is process-wide, so the German checks share a test. -fn a_table_update_too_narrow_to_split_keeps_open_in_the_menu() { - let (mut h, _) = harness_language(ViewMode::Table, egui::Theme::Dark, true, "de", Vec::new()); +/// German's "ÖFFNEN | AKTUALISIEREN" doesn't fit side by side in the table's action slot, so +/// PhotoCraft's row stacks them: Open, naming its version, above Update, both clear of the `⋯` +/// menu, in a row tall enough for both. Open only opens. Called from the one test above: the UI +/// language is process-wide, so the German checks share a test. +fn a_table_update_too_narrow_to_sit_side_by_side_stacks() { + let (mut h, sent) = harness_language(ViewMode::Table, egui::Theme::Dark, true, "de", Vec::new()); h.run_steps(8); - // The single button, shortened to fit the table's slot. - let row = h.get_by_label("Weitere Aktionen für PhotoCraft").rect().center().y; - assert!(h.get_all_by_label("Aktualisieren").any(|n| (n.rect().center().y - row).abs() < 1.0), "PhotoCraft's update button"); - assert!(h.query_by_label_contains("ohne Aktualisierung").is_none(), "no split in the German table"); - let before = open_buttons(&h); - h.get_by_label("Weitere Aktionen für PhotoCraft").click(); - h.run_steps(3); - assert_eq!(open_buttons(&h), before + 1, "the menu offers Open"); + let keep = "0.4.1 ohne Aktualisierung öffnen. 0.5.0 bleibt verfügbar."; + let open = h.get_by_label(keep).rect(); + let update = h.get_by_label("Auf 0.5.0 aktualisieren").rect(); + let menu = h.get_by_label("Weitere Aktionen für PhotoCraft").rect(); + assert!(open.bottom() <= update.top() && (open.left() - update.left()).abs() < 0.5, "Open above Update: {open:?} {update:?}"); + assert!(open.right() <= menu.left() && update.right() <= menu.left(), "both clear of the menu {menu:?}: {open:?} {update:?}"); + assert!(open.top() < menu.center().y && update.bottom() > menu.center().y, "the menu sits beside the pair: {menu:?}"); + // Up-to-date LightCraft keeps a one-line row. + let light = h.get_by_label("Weitere Aktionen für LightCraft").rect(); + assert!(update.bottom() - open.top() > light.height() * 1.5, "PhotoCraft's row grew for two buttons"); + + sent.borrow_mut().clear(); + h.get_by_label(keep).click(); + h.run_steps(2); + let sent = sent.borrow(); + assert!(sent.iter().any(|c| matches!(c, Command::Launch { app, .. } if app == "photocraft")), "{sent:?}"); + assert!(!sent.iter().any(|c| matches!(c, Command::Install { .. })), "{sent:?}"); } -/// How many buttons read "Öffnen" (Open). A role query: an open menu's items don't answer a plain -/// label query. -fn open_buttons(h: &Harness<'static, Option>) -> usize { - h.query_all_by_role(egui::accesskit::Role::Button) - .filter(|n| egui_kittest::kittest::NodeT::accesskit_node(n).label().as_deref() == Some("Öffnen")) - .count() -} +/// How far from the `⋯` menu's middle a row's main button can be: a stacked Open and Update sit +/// half a button (and half the seam) above and below it. Rows are further apart than this. +const STACKED_OFFSET: f32 = 20.0; /// At the launcher's narrowest, no main button outgrows its slot or crowds out the `⋯` menu (#956): /// a kept update, a kept install and a plain update, in both views, whichever label `language` has. @@ -260,7 +271,7 @@ fn main_buttons_fit_the_narrowest_window(language: &str) { let menu = h.get_by_label(&fmt(t("More actions for {name}"), &[("name", app)])).rect(); let main = h .query_all_by_role(egui::accesskit::Role::Button) - .filter(|n| (n.rect().center().y - menu.center().y).abs() < 1.0 && n.rect().left() < menu.left()) + .filter(|n| (n.rect().center().y - menu.center().y).abs() < STACKED_OFFSET && n.rect().left() < menu.left()) .max_by(|a, b| a.rect().right().total_cmp(&b.rect().right())) .unwrap() .rect();