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();