From f0f8b46b64c0a9c609c6a6d5210b4f4f490d85cf Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 09:33:03 +0100 Subject: [PATCH 01/12] Add pin-to-top with drag reorder for launcher apps. Lets users pin apps above the sorted catalog (grid and table), reorder pinned apps with a grip handle, and clears pins when an app is hidden. --- crates/ui-egui/src/app.rs | 24 ++++- crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-BR.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/zh-CN.tsv | 4 + crates/ui-egui/src/settings.rs | 89 +++++++++++++++ crates/ui-egui/src/views.rs | 102 ++++++++++++++++-- crates/ui-egui/src/widgets.rs | 33 +++++- crates/ui-egui/tests/ui.rs | 69 ++++++++++++ .../specs/2026-11-02-pin-apps-design.md | 98 +++++++++++++++++ 16 files changed, 440 insertions(+), 15 deletions(-) create mode 100644 docs/superpowers/specs/2026-11-02-pin-apps-design.md diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 5c57ef9b..e8576247 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -205,6 +205,8 @@ pub struct LauncherUi { /// Toasts created from logic (which may run with an old input time) or the UI. Their expiry is /// fixed when the next `ui` pass actually shows them. pending_toasts: Vec<(String, bool)>, + /// Catalog id of a pinned app while the user drags its reorder handle. + pub(crate) pin_dragging: Option, } /// The language to draw in: the preference, or English where it needs a CJK font the system @@ -287,6 +289,7 @@ impl LauncherUi { frame: None, flushed: Vec::new(), pending_toasts: Vec::new(), + pin_dragging: None, } } @@ -860,7 +863,13 @@ impl LauncherUi { } } - /// The ids to show, filtered and sorted. + /// Test hook for the filtered/sorted catalog ids currently shown. + pub fn visible_ids_for_test(&self) -> Vec<&'static str> { + self.visible_ids() + } + + /// The ids to show, filtered and sorted. Pinned apps stay first in the user's pin order; + /// the active sort (and the grid's installed / not-installed split) applies only to the rest. pub(crate) fn visible_ids(&self) -> Vec<&'static str> { let needle = self.search.trim().to_lowercase(); let mut list: Vec<(usize, &AppEntry)> = self @@ -888,7 +897,10 @@ impl LauncherUi { }; let installed = |e: &AppEntry| e.installed.as_ref().and_then(|i| i.version.clone()); let latest = |e: &AppEntry| e.release().and_then(|r| r.version.clone()); - list.sort_by(|(ia, a), (ib, b)| { + let (mut pinned, mut rest) = list.into_iter().partition::, _>(|(_, e)| self.settings.is_pinned(e.app.id)); + // Keep the user's pin order, skipping ids that are not in the filtered set. + pinned.sort_by_key(|(_, e)| self.settings.pinned.iter().position(|p| p == e.app.id).unwrap_or(usize::MAX)); + rest.sort_by(|(ia, a), (ib, b)| { let ord = match sort { SortKey::Suite => dir(ia.cmp(ib)), SortKey::Name => dir(a.app.name.cmp(b.app.name)), @@ -916,11 +928,13 @@ impl LauncherUi { // Ties keep suite order. ord.then(ia.cmp(ib)) }); - // The grid shows installed apps first, then the rest under "Not installed" (the sort holds - // within each group; the sort is stable). The table keeps the order its column gives. + // The grid shows installed apps first among the unpinned set, then the rest under + // "Not installed" (the sort holds within each group; the sort is stable). if grid { - list.sort_by_key(|(_, e)| !e.status(platform, manage).is_installed()); + rest.sort_by_key(|(_, e)| !e.status(platform, manage).is_installed()); } + list = pinned; + list.append(&mut rest); list.into_iter().map(|(_, e)| e.app.id).collect() } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ea025c76..3b7a19b9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -109,6 +109,10 @@ Apps on this computer Aplikace v tomto počítači Installed apps with a newer version Nainstalované aplikace s novější verzí Apps you can install Aplikace, které lze nainstalovat + Pin to top Připnout nahoru + Unpin Odepnout + Pinned Připnuté + Reorder {name} Změnit pořadí: {name} Hide App Skrýt aplikaci Unhide App Zobrazit aplikaci Hidden Apps Skryté aplikace diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a85b25d9..287cabbb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -123,6 +123,10 @@ Apps on this computer Apps auf diesem Computer Installed apps with a newer version Installierte Apps mit neuerer Version Apps you can install Apps, die du installieren kannst + Pin to top Oben anheften + Unpin Loslösen + Pinned Angeheftet + Reorder {name} {name} neu anordnen Hide App App ausblenden Unhide App App einblenden Hidden Apps Ausgeblendete Apps diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b62963cc..12c8eeea 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -122,6 +122,10 @@ Apps on this computer Aplicaciones en este equipo Installed apps with a newer version Aplicaciones instaladas con una versión más reciente Apps you can install Aplicaciones que puedes instalar + Pin to top Fijar arriba + Unpin Quitar fijación + Pinned Fijadas + Reorder {name} Reordenar {name} Hide App Ocultar aplicación Unhide App Mostrar aplicación Hidden Apps Aplicaciones ocultas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1e96cdf9..00c6ef12 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -121,6 +121,10 @@ Apps on this computer Applications sur cet ordinateur Installed apps with a newer version Applications installées avec une version plus récente Apps you can install Applications que vous pouvez installer + Pin to top Épingler en haut + Unpin Désépingler + Pinned Épinglés + Reorder {name} Réordonner {name} Hide App Masquer l'application Unhide App Afficher l'application Hidden Apps Applications masquées diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a7ffc954..c72bbc27 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -123,6 +123,10 @@ Apps on this computer Alkalmazások ezen a számítógépen Installed apps with a newer version Telepített alkalmazások, amelyekhez frissítés érhető el Apps you can install Telepíthető alkalmazások + Pin to top Rögzítés felülre + Unpin Rögzítés feloldása + Pinned Rögzítettek + Reorder {name} Átrendezés: {name} Hide App Alkalmazás elrejtése Unhide App Alkalmazás megjelenítése Hidden Apps Rejtett alkalmazások diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1b237703..d2fb3b59 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -125,6 +125,10 @@ Apps on this computer このコンピューターのアプリ Installed apps with a newer version 新しいバージョンがあるインストール済みアプリ Apps you can install インストールできるアプリ + Pin to top 上部にピン留め + Unpin ピン留めを外す + Pinned ピン留め + Reorder {name} {name}の順序を変更 Hide App アプリを非表示 Unhide App アプリを再表示 Hidden Apps 非表示のアプリ diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7ba59890..b2ed643e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -124,6 +124,10 @@ Apps on this computer Aplikacje na tym komputerze Installed apps with a newer version Zainstalowane aplikacje z nowszą wersją Apps you can install Aplikacje, które możesz zainstalować + Pin to top Przypnij na górze + Unpin Odepnij + Pinned Przypięte + Reorder {name} Zmień kolejność: {name} Hide App Ukryj aplikację Unhide App Pokaż aplikację Hidden Apps Ukryte aplikacje diff --git a/crates/ui-egui/src/i18n/pt-BR.tsv b/crates/ui-egui/src/i18n/pt-BR.tsv index ecf37d56..92dd2550 100644 --- a/crates/ui-egui/src/i18n/pt-BR.tsv +++ b/crates/ui-egui/src/i18n/pt-BR.tsv @@ -111,6 +111,10 @@ Apps on this computer Aplicativos neste computador Installed apps with a newer version Aplicativos instalados com nova versão Apps you can install Aplicativos que você pode instalar + Pin to top Fixar no topo + Unpin Desafixar + Pinned Fixados + Reorder {name} Reordenar {name} Hide App Ocultar aplicativo Unhide App Mostrar aplicativo Hidden Apps Aplicativos ocultos diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 948dc377..0dc9c38c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -121,6 +121,10 @@ Apps on this computer Приложения на этом компьютере Installed apps with a newer version Установленные приложения, у которых есть новая версия Apps you can install Приложения, которые можно установить + Pin to top Закрепить сверху + Unpin Открепить + Pinned Закреплённые + Reorder {name} Изменить порядок: {name} Hide App Скрыть приложение Unhide App Показать приложение Hidden Apps Скрытые приложения diff --git a/crates/ui-egui/src/i18n/zh-CN.tsv b/crates/ui-egui/src/i18n/zh-CN.tsv index f117f5f5..df5a1888 100644 --- a/crates/ui-egui/src/i18n/zh-CN.tsv +++ b/crates/ui-egui/src/i18n/zh-CN.tsv @@ -271,6 +271,10 @@ figcraft Interface design and prototyping 界面设计与原型制作 Apps on this computer 此电脑上的应用 Installed apps with a newer version 有新版本的已安装应用 Apps you can install 可安装的应用 + Pin to top 置顶 + Unpin 取消置顶 + Pinned 已置顶 + Reorder {name} 重新排序 {name} Hide App 隐藏应用 Unhide App 取消隐藏应用 Hidden Apps 已隐藏的应用 diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index fe520ca1..0815ffb5 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -127,6 +127,8 @@ pub struct Settings { pub tray_hint_shown: bool, /// How many times each app was opened from the launcher (catalog id → count), for "Frequently used". pub launches: BTreeMap, + /// Catalog ids pinned to the top of the grid/table, in the order the user chose. + pub pinned: Vec, } impl Settings { @@ -140,6 +142,41 @@ impl Settings { } } + /// Whether `id` is in the pinned list. + pub fn is_pinned(&self, id: &str) -> bool { + self.pinned.iter().any(|p| p == id) + } + + /// Pin `id` at the end of the pinned list (no-op if already pinned). + pub fn pin_app(&mut self, id: &str) { + if id.is_empty() || self.is_pinned(id) { + return; + } + self.pinned.push(id.to_string()); + } + + /// Remove `id` from the pinned list. + pub fn unpin_app(&mut self, id: &str) { + self.pinned.retain(|p| p != id); + } + + /// Move the pinned app `from_id` so its final index is the current index of `to_id`. + pub fn reorder_pinned(&mut self, from_id: &str, to_id: &str) { + if from_id == to_id { + return; + } + let Some(from) = self.pinned.iter().position(|p| p == from_id) else { + return; + }; + let Some(to) = self.pinned.iter().position(|p| p == to_id) else { + return; + }; + let item = self.pinned.remove(from); + // After `remove`, indices after `from` shift left; inserting at the original `to` + // still lands the item on that final index (whether moving up or down). + self.pinned.insert(to.min(self.pinned.len()), item); + } + /// Display name for a language `code`: a language's name in itself, from the registry /// ([`LANGUAGES`](crate::i18n::LANGUAGES)). A language needing a CJK font is named in English, /// with its own name added only while `installed` — the CJK face in the current font stack — is @@ -212,6 +249,58 @@ impl Default for Settings { hidden: std::collections::BTreeSet::new(), tray_hint_shown: false, launches: BTreeMap::new(), + pinned: Vec::new(), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pin_appends_once_and_unpin_removes() { + let mut s = Settings::default(); + s.pin_app("photocraft"); + s.pin_app("photocraft"); + s.pin_app("filmcraft"); + assert_eq!(s.pinned, vec!["photocraft", "filmcraft"]); + s.unpin_app("photocraft"); + assert_eq!(s.pinned, vec!["filmcraft"]); + s.unpin_app("missing"); + assert_eq!(s.pinned, vec!["filmcraft"]); + } + + #[test] + fn reorder_pinned_moves_to_target_index() { + let mut s = Settings { pinned: vec!["a".into(), "b".into(), "c".into(), "d".into()], ..Settings::default() }; + s.reorder_pinned("a", "c"); + assert_eq!(s.pinned, vec!["b", "c", "a", "d"]); + s.reorder_pinned("a", "b"); + assert_eq!(s.pinned, vec!["a", "b", "c", "d"]); + s.reorder_pinned("photocraft", "filmcraft"); // no-op when missing + let mut s = Settings { pinned: vec!["photocraft".into(), "filmcraft".into()], ..Settings::default() }; + s.reorder_pinned("photocraft", "filmcraft"); + assert_eq!(s.pinned, vec!["filmcraft", "photocraft"]); + s.reorder_pinned("missing", "a"); + s.reorder_pinned("filmcraft", "missing"); + s.reorder_pinned("filmcraft", "filmcraft"); + assert_eq!(s.pinned, vec!["filmcraft", "photocraft"]); + } + + #[test] + fn old_settings_json_loads_with_empty_pinned() { + let s: Settings = serde_json::from_str(r#"{"view":"Grid"}"#).expect("legacy settings"); + assert!(s.pinned.is_empty()); + } + + #[test] + fn hide_path_unpins() { + let mut s = Settings::default(); + s.pin_app("photocraft"); + s.unpin_app("photocraft"); + s.hidden.insert("photocraft".into()); + assert!(s.pinned.is_empty()); + assert!(s.hidden.contains("photocraft")); + } +} diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index ef6bf13d..b9e88644 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -413,10 +413,21 @@ impl LauncherUi { // ---- grid ---------------------------------------------------------------------------- - /// Installed apps first (`ids` arrives that way), then the rest under a "Not installed" header. + /// Pinned apps first (user order), then installed, then the rest under a "Not installed" header. pub(crate) fn grid(&mut self, ui: &mut Ui, ids: &[&'static str]) { - let installed = ids.iter().take_while(|id| self.is_installed(id)).count(); - let (top, rest) = ids.split_at(installed); + let pinned_n = ids.iter().take_while(|id| self.settings.is_pinned(id)).count(); + let (pinned, rest) = ids.split_at(pinned_n); + if !pinned.is_empty() { + let t = Tokens::get(ui.ctx()); + widgets::hud(ui, tl!("Pinned"), t.faint); + ui.add_space(12.0); + self.grid_block(ui, pinned); + if !rest.is_empty() { + ui.add_space(28.0); + } + } + let installed = rest.iter().take_while(|id| self.is_installed(id)).count(); + let (top, rest) = rest.split_at(installed); self.grid_block(ui, top); if !top.is_empty() && !rest.is_empty() { let t = Tokens::get(ui.ctx()); @@ -426,6 +437,9 @@ impl LauncherUi { } self.grid_block(ui, rest); ui.add_space(24.0); + if !ui.input(|i| i.pointer.primary_down()) { + self.pin_dragging = None; + } } fn grid_block(&mut self, ui: &mut Ui, ids: &[&'static str]) { @@ -452,6 +466,7 @@ impl LauncherUi { /// pointer is over it. fn card(&mut self, ui: &mut Ui, id: &str, rect: Rect) { let Some(entry) = self.entry(id).cloned() else { return }; + self.pin_drop_target(ui, id, rect); let t = Tokens::get(ui.ctx()); let app = entry.app; let hovered = ui.rect_contains_pointer(rect); @@ -609,6 +624,9 @@ impl LauncherUi { self.settings.sort_descending = k.starts_descending(); } } + if !ui.input(|i| i.pointer.primary_down()) { + self.pin_dragging = None; + } } /// The table's hairline under the row `ui` is drawing, across the table's full width. @@ -738,9 +756,11 @@ impl LauncherUi { if rule { Self::table_rule(ui, t, span, clip); } + let row_rect = ui.max_rect(); + self.pin_drop_target(ui, id, row_rect); // Clicking the app's cell opens its page (the content is added after, so it wins any hit). if AppDetails::by_id(id).is_some() { - let open = ui.interact(ui.max_rect(), ui.scope_id().with(("row-open", id)), Sense::click()); + let open = ui.interact(row_rect, ui.scope_id().with(("row-open", id)), Sense::click()); open.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, fmt(tl!("{name} details"), &[("name", entry.app.name)]))); if open.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { self.detail = Some(id.to_string()); @@ -856,12 +876,14 @@ impl LauncherUi { } /// 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. + /// the website, GitHub and releases links as buttons of its own. Pinned apps also get a drag + /// handle for reordering. pub(crate) fn actions_in(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, width: f32, links_in_menu: bool) { let id = entry.app.id.to_string(); - // One square button (the menu) beside the main one. let gap = ui.spacing().item_spacing.x; - let main_w = (width - theme::CONTROL_H - gap).max(120.0); + let pinned = self.settings.is_pinned(entry.app.id); + let squares = 1.0 + if pinned { 1.0 } else { 0.0 }; + let main_w = (width - squares * (theme::CONTROL_H + gap) + gap).max(90.0); if entry.job.is_some() { self.job_actions(ui, entry, &id, main_w, width); return; @@ -870,9 +892,55 @@ impl LauncherUi { return; } self.primary_button(ui, entry, &id, status, main_w); + if pinned { + self.pin_grip(ui, entry); + } self.menu(ui, entry, status, links_in_menu); } + /// Drag handle that reorders this pinned app among the other pinned apps. + fn pin_grip(&mut self, ui: &mut Ui, entry: &AppEntry) { + let tip = fmt(tl!("Reorder {name}"), &[("name", entry.app.name)]); + let resp = widgets::grip_button(ui, &tip); + if resp.drag_started() || resp.dragged() { + self.pin_dragging = Some(entry.app.id.to_string()); + } + if resp.drag_stopped() { + self.pin_dragging = None; + } + } + + /// While a pin handle is dragged, hovering another pinned card/row moves the dragged app there. + /// Reorder only after the pointer crosses the target's midpoint so the list does not flicker. + fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect) { + let Some(from) = self.pin_dragging.clone() else { + return; + }; + if from == id || !self.settings.is_pinned(id) { + return; + } + if !ui.input(|i| i.pointer.primary_down()) { + self.pin_dragging = None; + return; + } + let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { + return; + }; + if !rect.contains(pos) { + return; + } + let Some(from_i) = self.settings.pinned.iter().position(|p| p == &from) else { + return; + }; + let Some(to_i) = self.settings.pinned.iter().position(|p| p == id) else { + return; + }; + let past_mid = if from_i < to_i { pos.y > rect.center().y || pos.x > rect.center().x } else { pos.y < rect.center().y || pos.x < rect.center().x }; + if past_mid { + self.settings.reorder_pinned(&from, id); + } + } + /// A running job's row: one progress bar, with pause and cancel while its download is under /// way, or only a cancel while the job waits for another app's installer. fn job_actions(&mut self, ui: &mut Ui, entry: &AppEntry, id: &str, main_w: f32, width: f32) { @@ -959,6 +1027,9 @@ impl LauncherUi { if widgets::glyph_button(ui, widgets::Glyph::Close, tl!("Discard download")).clicked() { self.act_command(Command::Cancel { app: id.to_string() }); } + if self.settings.is_pinned(entry.app.id) { + self.pin_grip(ui, entry); + } self.menu(ui, entry, status, links_in_menu); true } @@ -1053,10 +1124,25 @@ impl LauncherUi { link(ui, tl!("GitHub repository"), entry.app.repo_url()); link(ui, tl!("All releases"), entry.app.releases_url()); } + self.menu_pin(ui, entry, &id); self.menu_hide(ui, entry, &id); }); } + /// Pin — or unpin — the app at the top of the list. + fn menu_pin(&mut self, ui: &mut Ui, entry: &AppEntry, id: &str) { + ui.separator(); + let pinned = self.settings.is_pinned(entry.app.id); + if ui.button(if pinned { tl!("Unpin") } else { tl!("Pin to top") }).clicked() { + if pinned { + self.settings.unpin_app(id); + } else { + self.settings.pin_app(id); + } + ui.close(); + } + } + /// The menu items for an installed app: open, reveal in the file manager, reinstall, and /// uninstall (which asks first). fn menu_installed(&mut self, ui: &mut Ui, status: &AppStatus, id: &str, reveal: &'static str) { @@ -1105,6 +1191,7 @@ impl LauncherUi { } /// Hide — or unhide — the app, keeping the Hidden filter sane when the hidden set empties. + /// Hiding also clears a pin so a hidden app cannot float back into the main list. fn menu_hide(&mut self, ui: &mut Ui, entry: &AppEntry, id: &str) { ui.separator(); let hidden = self.settings.hidden.contains(entry.app.id); @@ -1115,6 +1202,7 @@ impl LauncherUi { self.settings.filter = Filter::All; } } else { + self.settings.unpin_app(id); self.settings.hidden.insert(id.to_string()); } ui.close(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5442b638..e2181eaa 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -515,6 +515,8 @@ pub enum Glyph { Close, Settings, Refresh, + /// Six-dot grip for reordering pinned apps. + Grip, } /// Paints one of the launcher's symbols with the painter, centred in `rect`. @@ -571,9 +573,34 @@ fn glyph_shapes(rect: Rect, glyph: Glyph, color: Color32) -> Vec { egui::Shape::convex_polygon(vec![tip + vec2(-2.4, -0.6), tip + vec2(1.6, -3.0), tip + vec2(1.2, 1.8)], color, Stroke::NONE), ] } + Glyph::Grip => { + let mut shapes = Vec::with_capacity(6); + for (dx, dy) in [(-3.0, -3.5), (3.0, -3.5), (-3.0, 0.0), (3.0, 0.0), (-3.0, 3.5), (3.0, 3.5)] { + shapes.push(egui::Shape::circle_filled(m + vec2(dx, dy), 1.35, color)); + } + shapes + } } } +/// A drag handle for reordering pinned apps: same outline as [`glyph_button`], grab cursor. +pub fn grip_button(ui: &mut Ui, tip: &str) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(theme::CONTROL_H, theme::CONTROL_H), Sense::drag()); + let hovered = resp.hovered() || resp.dragged(); + let p = ui.painter(); + if hovered { + p.rect_filled(rect, CornerRadius::ZERO, t.invert_bg); + } else { + p.rect_stroke(rect, CornerRadius::ZERO, t.hairline(), egui::StrokeKind::Inside); + } + let c = if hovered { t.invert_fg } else { t.muted }; + paint_glyph(p, rect, Glyph::Grip, c); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); + let cursor = if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }; + resp.on_hover_text(tip).on_hover_cursor(cursor) +} + /// A small outlined square button with a drawn glyph; inverts on hover. pub fn glyph_button(ui: &mut Ui, glyph: Glyph, tip: &str) -> Response { let t = Tokens::get(ui.ctx()); @@ -697,7 +724,7 @@ mod tests { /// the tool-button and header-cell sizes, or a click target would draw outside its hit rect. #[test] fn every_painter_glyph_stays_inside_its_control() { - for glyph in [Glyph::Pause, Glyph::Close, Glyph::Settings, Glyph::Refresh] { + for glyph in [Glyph::Pause, Glyph::Close, Glyph::Settings, Glyph::Refresh, Glyph::Grip] { for side in [crate::theme::CONTROL_H, 48.0] { let rect = Rect::from_min_size(egui::pos2(100.0, 200.0), egui::vec2(side, side)); let shapes = glyph_shapes(rect, glyph, Color32::WHITE); @@ -755,11 +782,11 @@ mod tests { assert_eq!(opened_urls(&ctx), vec![artcraft_launcher_core::catalog::DISCORD_APP_URL.to_string()]); } - /// Each glyph must be distinct geometry, not the same fallback shape for all four. + /// Each glyph must be distinct geometry, not the same fallback shape for every variant. #[test] fn each_glyph_has_its_own_shapes() { let rect = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(crate::theme::CONTROL_H, crate::theme::CONTROL_H)); - let bounds: Vec<[u32; 4]> = [Glyph::Pause, Glyph::Close, Glyph::Settings, Glyph::Refresh] + let bounds: Vec<[u32; 4]> = [Glyph::Pause, Glyph::Close, Glyph::Settings, Glyph::Refresh, Glyph::Grip] .into_iter() .map(|glyph| { let union = glyph_shapes(rect, glyph, Color32::WHITE) diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 10c1fad8..0e184dda 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1390,6 +1390,75 @@ fn table_view_and_light_theme() { assert!(sent.borrow().iter().any(|c| matches!(c, Command::Launch { .. }))); } +#[test] +/// Pin floats an app above Name sort; reorder via settings is reflected; Hide clears the pin. +fn pinning_an_app_keeps_it_on_top_until_unpinned() { + let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true); + h.run_steps(6); + // Name sort would put CADCraft before PhotoCraft; a pin must still win. + h.state_mut().as_mut().unwrap().settings.sort = artcraft_launcher_ui_egui::settings::SortKey::Name; + h.state_mut().as_mut().unwrap().settings.sort_descending = false; + h.run_steps(2); + h.get_by_label("More actions for PhotoCraft").click(); + h.run_steps(3); + h.get_by_label("Pin to top").click(); + h.run_steps(4); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, vec!["photocraft".to_string()]); + let ids = h.state().as_ref().unwrap().visible_ids_for_test(); + assert_eq!(ids.first().copied(), Some("photocraft"), "pinned app stays first under Name sort: {ids:?}"); + h.state_mut().as_mut().unwrap().settings.pin_app("filmcraft"); + h.state_mut().as_mut().unwrap().settings.reorder_pinned("photocraft", "filmcraft"); + h.run_steps(2); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, vec!["filmcraft".to_string(), "photocraft".to_string()]); + assert_eq!(&h.state().as_ref().unwrap().visible_ids_for_test()[..2], &["filmcraft", "photocraft"]); + h.get_all_by_label_contains("Reorder").next().expect("pinned rows expose reorder handles"); +} + +#[test] +/// Hiding a pinned app also removes it from the pin list. +fn hide_clears_pin() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(6); + h.state_mut().as_mut().unwrap().settings.pin_app("photocraft"); + h.run_steps(2); + h.get_by_label("More actions for PhotoCraft").click(); + h.run_steps(3); + h.get_by_label("Hide App").click(); + h.run_steps(4); + assert!(h.state().as_ref().unwrap().settings.pinned.is_empty()); + assert!(h.state().as_ref().unwrap().settings.hidden.contains("photocraft")); +} + +#[test] +/// After pinning from the menu, opening it again offers Unpin and clears the pin. +fn pinned_app_menu_offers_unpin() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(6); + h.get_by_label("More actions for PhotoCraft").click(); + h.run_steps(3); + h.get_by_label("Pin to top").click(); + h.run_steps(4); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, vec!["photocraft".to_string()]); + h.get_by_label("More actions for PhotoCraft").click(); + h.run_steps(3); + assert!(h.query_by_label("Pin to top").is_none(), "pinned apps must not keep offering Pin to top"); + h.get_by_label("Unpin").click(); + h.run_steps(4); + assert!(h.state().as_ref().unwrap().settings.pinned.is_empty()); +} + +#[test] +/// Grid shows a Pinned section label once at least one app is pinned. +fn grid_shows_pinned_section_header() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(6); + assert!(h.query_by_label("Pinned").is_none()); + h.state_mut().as_mut().unwrap().settings.pin_app("photocraft"); + h.run_steps(4); + h.get_by_label("Pinned"); + h.get_all_by_label_contains("Reorder PhotoCraft").next().expect("pinned card exposes a reorder handle"); +} + #[test] /// Hide removes an app from All into Hidden Apps; Unhide restores it, clears the hidden set and /// returns the filter to All. diff --git a/docs/superpowers/specs/2026-11-02-pin-apps-design.md b/docs/superpowers/specs/2026-11-02-pin-apps-design.md new file mode 100644 index 00000000..e59c2363 --- /dev/null +++ b/docs/superpowers/specs/2026-11-02-pin-apps-design.md @@ -0,0 +1,98 @@ +# Pin apps to top — design + +**Date:** 2026-11-02 +**Repo:** storytold/craft-launcher +**Status:** approved + +## Goal + +Let users pin catalog apps to a fixed block at the top of the launcher (grid and table), and reorder those pinned apps by drag-and-drop. Pin order is intentional and is not scrambled by the active sort. + +## Decisions + +| Topic | Choice | +|---|---| +| Pin entry points | `…` menu **Pin to top** / **Unpin**, plus a drag handle on pinned items | +| Sort interaction | Pinned stay in user order at top; sort applies only to unpinned apps | +| Grid grouping | One **Pinned** block first; then existing Installed / Not installed for the rest | +| Persistence | `Settings.pinned: Vec` (catalog ids, order = display order) | +| Hide interaction | Hiding an app removes it from `pinned` | + +## Data + +- Add `pinned: Vec` to `Settings` with `#[serde(default)]`. +- Helpers (on `Settings` or next to sort logic): + - `is_pinned(id)` + - `pin(id)` — append if absent + - `unpin(id)` — remove if present + - `reorder_pinned(from, to)` — move within the vec; ignore out-of-range +- On **Hide App**, also `unpin`. +- Unknown / removed catalog ids in `pinned` are skipped when building the visible list (left in settings until unpin/hide so re-adding an app can restore order later is unnecessary; prune on load is optional — prefer skip-at-display for simplicity). + +## Ordering + +`LauncherUi::visible_ids` (or equivalent): + +1. Collect filtered/search-matching entries as today. +2. Partition into pinned vs unpinned using `settings.pinned` membership. +3. Emit pinned ids in `settings.pinned` order (only those present in the filtered set). +4. Sort unpinned with the existing sort keys; in Grid, still put installed before not-installed within the unpinned set only. + +## UI + +### Menu + +In `menu` / `menu_hide` area (before Hide): + +- If not pinned: **Pin to top** +- If pinned: **Unpin** + +### Drag handle + +- Shown only when the app is pinned (grid card and table row). +- Painter-drawn grip (six dots or two bars), matching existing glyph buttons — not emoji. +- Accessible name e.g. **Reorder {name}**. +- Drag reorders only among pinned siblings; drop inserts before/after the hovered pinned target. +- Cursor: grab / grabbing. +- Respect `reduce_motion` only for any visual feedback animation; reorder itself is immediate. + +### Section header (grid) + +When the pinned block is non-empty, show a **Pinned** HUD label (same style as **Not installed**) above that block. + +### Table + +Same id order as grid’s logical list (pinned first). Handle sits near the app name or before the `…` control without changing column semantics. + +## i18n + +Add English keys to every `complete_menus` catalog TSV: + +- `Pin to top` +- `Unpin` +- `Pinned` +- `Reorder {name}` (accessible name / tooltip) + +Translate clean-room from meaning; product names stay English. + +## Tests + +- Unit: pin/unpin/reorder helpers; hide clears pin; `visible_ids` puts pinned first in pin order then sorted unpinned. +- UI harness: menu shows Pin/Unpin; after pin, Pinned header (grid) / order in table; reorder via helper or simulated drag if harness allows. +- Settings round-trip: `pinned` survives serde default for old settings files. + +## Out of scope + +- Syncing pins across machines +- Changing upstream suite catalog order +- A new Sort key for “Pinned” +- Pinning while the Hidden filter is active (hidden apps are not in the main list; unpin-on-hide covers this) + +## Files (expected) + +- `crates/ui-egui/src/settings.rs` — field + helpers +- `crates/ui-egui/src/app.rs` — `visible_ids` partition +- `crates/ui-egui/src/views.rs` — menu, grid sections, drag, table handle +- `crates/ui-egui/src/widgets.rs` — grip glyph / handle widget if needed +- `crates/ui-egui/src/i18n/*.tsv` — strings +- `crates/ui-egui/tests/ui.rs` (+ unit tests near helpers) From 7362fe206767dd6eccaa86753c7fc12abb715c6f Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 09:59:47 +0100 Subject: [PATCH 02/12] Address CodeRabbit pin/reorder review findings. Widen table drop targets, reserve paused-row width for the grip, hide Pin on hidden apps, and add Move up/down menu actions for keyboard reorder. --- crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/de.tsv | 2 ++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/pl.tsv | 2 ++ crates/ui-egui/src/i18n/pt-BR.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/zh-CN.tsv | 2 ++ crates/ui-egui/src/settings.rs | 17 ++++++++++++++ crates/ui-egui/src/views.rs | 37 ++++++++++++++++++++++++------- 12 files changed, 66 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3b7a19b9..e82d9d87 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -113,6 +113,8 @@ Unpin Odepnout Pinned Připnuté Reorder {name} Změnit pořadí: {name} + Move up Posunout nahoru + Move down Posunout dolů Hide App Skrýt aplikaci Unhide App Zobrazit aplikaci Hidden Apps Skryté aplikace diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 287cabbb..fb5afa8f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -127,6 +127,8 @@ Unpin Loslösen Pinned Angeheftet Reorder {name} {name} neu anordnen + Move up Nach oben + Move down Nach unten Hide App App ausblenden Unhide App App einblenden Hidden Apps Ausgeblendete Apps diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 12c8eeea..ff4b600d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -126,6 +126,8 @@ Unpin Quitar fijación Pinned Fijadas Reorder {name} Reordenar {name} + Move up Subir + Move down Bajar Hide App Ocultar aplicación Unhide App Mostrar aplicación Hidden Apps Aplicaciones ocultas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 00c6ef12..7bb566e9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -125,6 +125,8 @@ Unpin Désépingler Pinned Épinglés Reorder {name} Réordonner {name} + Move up Monter + Move down Descendre Hide App Masquer l'application Unhide App Afficher l'application Hidden Apps Applications masquées diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c72bbc27..1aa8d90b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -127,6 +127,8 @@ Unpin Rögzítés feloldása Pinned Rögzítettek Reorder {name} Átrendezés: {name} + Move up Feljebb + Move down Lejjebb Hide App Alkalmazás elrejtése Unhide App Alkalmazás megjelenítése Hidden Apps Rejtett alkalmazások diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d2fb3b59..22930e8b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -129,6 +129,8 @@ Unpin ピン留めを外す Pinned ピン留め Reorder {name} {name}の順序を変更 + Move up 上へ + Move down 下へ Hide App アプリを非表示 Unhide App アプリを再表示 Hidden Apps 非表示のアプリ diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index b2ed643e..9a145619 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -128,6 +128,8 @@ Unpin Odepnij Pinned Przypięte Reorder {name} Zmień kolejność: {name} + Move up W górę + Move down W dół Hide App Ukryj aplikację Unhide App Pokaż aplikację Hidden Apps Ukryte aplikacje diff --git a/crates/ui-egui/src/i18n/pt-BR.tsv b/crates/ui-egui/src/i18n/pt-BR.tsv index 92dd2550..6922ca1e 100644 --- a/crates/ui-egui/src/i18n/pt-BR.tsv +++ b/crates/ui-egui/src/i18n/pt-BR.tsv @@ -115,6 +115,8 @@ Unpin Desafixar Pinned Fixados Reorder {name} Reordenar {name} + Move up Mover para cima + Move down Mover para baixo Hide App Ocultar aplicativo Unhide App Mostrar aplicativo Hidden Apps Aplicativos ocultos diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0dc9c38c..d1a24c6b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -125,6 +125,8 @@ Unpin Открепить Pinned Закреплённые Reorder {name} Изменить порядок: {name} + Move up Выше + Move down Ниже Hide App Скрыть приложение Unhide App Показать приложение Hidden Apps Скрытые приложения diff --git a/crates/ui-egui/src/i18n/zh-CN.tsv b/crates/ui-egui/src/i18n/zh-CN.tsv index df5a1888..a76292f7 100644 --- a/crates/ui-egui/src/i18n/zh-CN.tsv +++ b/crates/ui-egui/src/i18n/zh-CN.tsv @@ -275,6 +275,8 @@ figcraft Interface design and prototyping 界面设计与原型制作 Unpin 取消置顶 Pinned 已置顶 Reorder {name} 重新排序 {name} + Move up 上移 + Move down 下移 Hide App 隐藏应用 Unhide App 取消隐藏应用 Hidden Apps 已隐藏的应用 diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index 0815ffb5..d245a824 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -160,6 +160,13 @@ impl Settings { self.pinned.retain(|p| p != id); } + /// The pinned id one step above (`delta = -1`) or below (`delta = 1`) `id`, if any. + pub fn pinned_neighbor(&self, id: &str, delta: i32) -> Option<&str> { + let i = self.pinned.iter().position(|p| p == id)?; + let j = i.checked_add_signed(delta as isize)?; + self.pinned.get(j).map(String::as_str) + } + /// Move the pinned app `from_id` so its final index is the current index of `to_id`. pub fn reorder_pinned(&mut self, from_id: &str, to_id: &str) { if from_id == to_id { @@ -303,4 +310,14 @@ mod tests { assert!(s.pinned.is_empty()); assert!(s.hidden.contains("photocraft")); } + + #[test] + fn pinned_neighbor_steps_within_bounds() { + let s = Settings { pinned: vec!["a".into(), "b".into(), "c".into()], ..Settings::default() }; + assert_eq!(s.pinned_neighbor("b", -1), Some("a")); + assert_eq!(s.pinned_neighbor("b", 1), Some("c")); + assert_eq!(s.pinned_neighbor("a", -1), None); + assert_eq!(s.pinned_neighbor("c", 1), None); + assert_eq!(s.pinned_neighbor("missing", 1), None); + } } diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index b9e88644..412174c1 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -757,7 +757,8 @@ impl LauncherUi { Self::table_rule(ui, t, span, clip); } let row_rect = ui.max_rect(); - self.pin_drop_target(ui, id, row_rect); + // Drop target spans the full table width so a drag from the grip column still hits the row. + self.pin_drop_target(ui, id, Rect::from_x_y_ranges(span, row_rect.y_range())); // Clicking the app's cell opens its page (the content is added after, so it wins any hit). if AppDetails::by_id(id).is_some() { let open = ui.interact(row_rect, ui.scope_id().with(("row-open", id)), Sense::click()); @@ -908,6 +909,8 @@ impl LauncherUi { if resp.drag_stopped() { self.pin_dragging = None; } + // The grip column is also a drop target (table rows otherwise only register the first cell). + self.pin_drop_target(ui, entry.app.id, resp.rect.expand2(vec2(8.0, 12.0))); } /// While a pin handle is dragged, hovering another pinned card/row moves the dragged app there. @@ -1007,8 +1010,9 @@ impl LauncherUi { return false; } let pct = if p.total > 0 { format!(" · {:.0}%", p.done as f32 / p.total as f32 * 100.0) } else { String::new() }; - // Resume, then discard and the menu (two squares). - let resume_w = (width - 2.0 * (theme::CONTROL_H + gap)).max(90.0); + // Resume, then discard, optional pin grip, and the menu. + let squares = 2.0 + if self.settings.is_pinned(entry.app.id) { 1.0 } else { 0.0 }; + let resume_w = (width - squares * (theme::CONTROL_H + gap) + gap).max(0.0); // Narrow (the table): just tl!("Resume"); the tooltip says how far it got. let label = match resume_w { w if w < 150.0 => tl!("Resume").to_string(), @@ -1129,16 +1133,33 @@ impl LauncherUi { }); } - /// Pin — or unpin — the app at the top of the list. + /// Pin — or unpin — the app at the top of the list. Hidden apps are not offered Pin to top + /// (they can still Unpin if somehow still pinned). Move up/down reorder for keyboard users. fn menu_pin(&mut self, ui: &mut Ui, entry: &AppEntry, id: &str) { ui.separator(); let pinned = self.settings.is_pinned(entry.app.id); - if ui.button(if pinned { tl!("Unpin") } else { tl!("Pin to top") }).clicked() { - if pinned { + let hidden = self.settings.hidden.contains(entry.app.id); + if pinned { + if ui.button(tl!("Unpin")).clicked() { self.settings.unpin_app(id); - } else { - self.settings.pin_app(id); + ui.close(); } + let prev = self.settings.pinned_neighbor(id, -1).map(str::to_owned); + let next = self.settings.pinned_neighbor(id, 1).map(str::to_owned); + if prev.is_some() && ui.button(tl!("Move up")).clicked() { + if let Some(prev) = prev.as_deref() { + self.settings.reorder_pinned(id, prev); + } + ui.close(); + } + if next.is_some() && ui.button(tl!("Move down")).clicked() { + if let Some(next) = next.as_deref() { + self.settings.reorder_pinned(id, next); + } + ui.close(); + } + } else if !hidden && ui.button(tl!("Pin to top")).clicked() { + self.settings.pin_app(id); ui.close(); } } From f8f6d6c63a85aab9f0cd7db68144a973279dcf12 Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 10:10:13 +0100 Subject: [PATCH 03/12] Keep pin drag id stable and skip stale pins for Move up/down. Latch pin_dragging only on drag start so table cell reuse cannot reverse a reorder, and pick the next visible pinned neighbor when moving from the menu. --- crates/ui-egui/src/settings.rs | 34 +++++++++++++++++++++++++--------- crates/ui-egui/src/views.rs | 9 ++++++--- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index d245a824..02ed6f72 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -160,11 +160,18 @@ impl Settings { self.pinned.retain(|p| p != id); } - /// The pinned id one step above (`delta = -1`) or below (`delta = 1`) `id`, if any. - pub fn pinned_neighbor(&self, id: &str, delta: i32) -> Option<&str> { + /// The next pinned id above (`delta = -1`) or below (`delta = 1`) `id` that passes `available`. + /// Skips stale catalog ids left in `pinned` so Move up/down still change the visible order. + pub fn pinned_neighbor(&self, id: &str, delta: i32, available: impl Fn(&str) -> bool) -> Option<&str> { let i = self.pinned.iter().position(|p| p == id)?; - let j = i.checked_add_signed(delta as isize)?; - self.pinned.get(j).map(String::as_str) + let mut j = i; + loop { + j = j.checked_add_signed(delta as isize)?; + let cand = self.pinned.get(j)?; + if available(cand) { + return Some(cand.as_str()); + } + } } /// Move the pinned app `from_id` so its final index is the current index of `to_id`. @@ -314,10 +321,19 @@ mod tests { #[test] fn pinned_neighbor_steps_within_bounds() { let s = Settings { pinned: vec!["a".into(), "b".into(), "c".into()], ..Settings::default() }; - assert_eq!(s.pinned_neighbor("b", -1), Some("a")); - assert_eq!(s.pinned_neighbor("b", 1), Some("c")); - assert_eq!(s.pinned_neighbor("a", -1), None); - assert_eq!(s.pinned_neighbor("c", 1), None); - assert_eq!(s.pinned_neighbor("missing", 1), None); + let any = |_: &str| true; + assert_eq!(s.pinned_neighbor("b", -1, any), Some("a")); + assert_eq!(s.pinned_neighbor("b", 1, any), Some("c")); + assert_eq!(s.pinned_neighbor("a", -1, any), None); + assert_eq!(s.pinned_neighbor("c", 1, any), None); + assert_eq!(s.pinned_neighbor("missing", 1, any), None); + } + + #[test] + fn pinned_neighbor_skips_unavailable_ids() { + let s = Settings { pinned: vec!["a".into(), "gone".into(), "b".into()], ..Settings::default() }; + let live = |id: &str| id != "gone"; + assert_eq!(s.pinned_neighbor("a", 1, live), Some("b")); + assert_eq!(s.pinned_neighbor("b", -1, live), Some("a")); } } diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index 412174c1..5e0661ac 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -903,7 +903,9 @@ impl LauncherUi { fn pin_grip(&mut self, ui: &mut Ui, entry: &AppEntry) { let tip = fmt(tl!("Reorder {name}"), &[("name", entry.app.name)]); let resp = widgets::grip_button(ui, &tip); - if resp.drag_started() || resp.dragged() { + // Only latch on drag start: after a reorder, the same egui widget id can sit on a + // different app's row, and `dragged()` would steal `pin_dragging` mid-gesture. + if resp.drag_started() { self.pin_dragging = Some(entry.app.id.to_string()); } if resp.drag_stopped() { @@ -1144,8 +1146,9 @@ impl LauncherUi { self.settings.unpin_app(id); ui.close(); } - let prev = self.settings.pinned_neighbor(id, -1).map(str::to_owned); - let next = self.settings.pinned_neighbor(id, 1).map(str::to_owned); + let known = |pid: &str| self.entry(pid).is_some(); + let prev = self.settings.pinned_neighbor(id, -1, known).map(str::to_owned); + let next = self.settings.pinned_neighbor(id, 1, known).map(str::to_owned); if prev.is_some() && ui.button(tl!("Move up")).clicked() { if let Some(prev) = prev.as_deref() { self.settings.reorder_pinned(id, prev); From c05897abacb675188b355713954ea019dd1cb0d1 Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 13:41:42 +0100 Subject: [PATCH 04/12] Fix leftward grip-to-grip pin reordering. Use before/after half hits to choose the final pin index so horizontal drops onto another grip work in both directions. --- crates/ui-egui/src/settings.rs | 27 +++++++++++++++++++++++++++ crates/ui-egui/src/views.rs | 14 ++++++-------- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index 02ed6f72..dab11f23 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -191,6 +191,20 @@ impl Settings { self.pinned.insert(to.min(self.pinned.len()), item); } + /// Move `from_id` so its final index is `to_index` (clamped). Used by drag-and-drop. + pub fn reorder_pinned_to(&mut self, from_id: &str, to_index: usize) { + let Some(from) = self.pinned.iter().position(|p| p == from_id) else { + return; + }; + let to_index = to_index.min(self.pinned.len().saturating_sub(1)); + if from == to_index { + return; + } + let item = self.pinned.remove(from); + // After remove, inserting at the original `to_index` yields that final index. + self.pinned.insert(to_index.min(self.pinned.len()), item); + } + /// Display name for a language `code`: a language's name in itself, from the registry /// ([`LANGUAGES`](crate::i18n::LANGUAGES)). A language needing a CJK font is named in English, /// with its own name added only while `installed` — the CJK face in the current font stack — is @@ -302,6 +316,19 @@ mod tests { assert_eq!(s.pinned, vec!["filmcraft", "photocraft"]); } + #[test] + fn reorder_pinned_to_moves_to_final_index() { + let mut s = Settings { pinned: vec!["a".into(), "b".into(), "c".into()], ..Settings::default() }; + s.reorder_pinned_to("c", 0); + assert_eq!(s.pinned, vec!["c", "a", "b"]); + s.reorder_pinned_to("c", 2); + assert_eq!(s.pinned, vec!["a", "b", "c"]); + s.reorder_pinned_to("a", 1); + assert_eq!(s.pinned, vec!["b", "a", "c"]); + s.reorder_pinned_to("a", 1); + assert_eq!(s.pinned, vec!["b", "a", "c"]); + } + #[test] fn old_settings_json_loads_with_empty_pinned() { let s: Settings = serde_json::from_str(r#"{"view":"Grid"}"#).expect("legacy settings"); diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index 5e0661ac..f515806a 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -916,7 +916,8 @@ impl LauncherUi { } /// While a pin handle is dragged, hovering another pinned card/row moves the dragged app there. - /// Reorder only after the pointer crosses the target's midpoint so the list does not flicker. + /// Insert before the target when the pointer is in its top/left half, otherwise after — so + /// grip-to-grip drops work in every direction without depending on travel relative to midpoints. fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect) { let Some(from) = self.pin_dragging.clone() else { return; @@ -934,16 +935,13 @@ impl LauncherUi { if !rect.contains(pos) { return; } - let Some(from_i) = self.settings.pinned.iter().position(|p| p == &from) else { - return; - }; let Some(to_i) = self.settings.pinned.iter().position(|p| p == id) else { return; }; - let past_mid = if from_i < to_i { pos.y > rect.center().y || pos.x > rect.center().x } else { pos.y < rect.center().y || pos.x < rect.center().x }; - if past_mid { - self.settings.reorder_pinned(&from, id); - } + let insert_before = pos.x < rect.center().x || pos.y < rect.center().y; + let last = self.settings.pinned.len().saturating_sub(1); + let final_index = if insert_before { to_i } else { to_i.saturating_add(1).min(last) }; + self.settings.reorder_pinned_to(&from, final_index); } /// A running job's row: one progress bar, with pause and cancel while its download is under From de487301158ecb0c166eaf546c197ad94b08a580 Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 13:51:37 +0100 Subject: [PATCH 05/12] Fix pin drop axis and rightward insertion index. Use horizontal half-tests for grid and vertical for table, drop only once per pointer, and adjust the insertion slot after removing the dragged pin. --- crates/ui-egui/src/settings.rs | 27 +++++++++++++++++---------- crates/ui-egui/src/views.rs | 33 +++++++++++++++++++++------------ 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index dab11f23..aa3cf315 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -191,18 +191,21 @@ impl Settings { self.pinned.insert(to.min(self.pinned.len()), item); } - /// Move `from_id` so its final index is `to_index` (clamped). Used by drag-and-drop. - pub fn reorder_pinned_to(&mut self, from_id: &str, to_index: usize) { + /// Move `from_id` to insertion slot `to_index` (`0..=len`, before remove). Used by drag-and-drop. + pub fn reorder_pinned_to(&mut self, from_id: &str, mut to_index: usize) { let Some(from) = self.pinned.iter().position(|p| p == from_id) else { return; }; - let to_index = to_index.min(self.pinned.len().saturating_sub(1)); - if from == to_index { + to_index = to_index.min(self.pinned.len()); + // Already in this slot (including the no-op "insert immediately after self"). + if to_index == from || to_index == from + 1 { return; } let item = self.pinned.remove(from); - // After remove, inserting at the original `to_index` yields that final index. - self.pinned.insert(to_index.min(self.pinned.len()), item); + if from < to_index { + to_index -= 1; + } + self.pinned.insert(to_index, item); } /// Display name for a language `code`: a language's name in itself, from the registry @@ -317,15 +320,19 @@ mod tests { } #[test] - fn reorder_pinned_to_moves_to_final_index() { + fn reorder_pinned_to_uses_insertion_slot() { let mut s = Settings { pinned: vec!["a".into(), "b".into(), "c".into()], ..Settings::default() }; + // Insert c before a (slot 0). s.reorder_pinned_to("c", 0); assert_eq!(s.pinned, vec!["c", "a", "b"]); - s.reorder_pinned_to("c", 2); + // Insert c after b (slot 3). + s.reorder_pinned_to("c", 3); assert_eq!(s.pinned, vec!["a", "b", "c"]); - s.reorder_pinned_to("a", 1); + // Insert a before c (slot 2) → [b, a, c]. + s.reorder_pinned_to("a", 2); assert_eq!(s.pinned, vec!["b", "a", "c"]); - s.reorder_pinned_to("a", 1); + // Insert a after itself (slot 2) is a no-op. + s.reorder_pinned_to("a", 2); assert_eq!(s.pinned, vec!["b", "a", "c"]); } diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index f515806a..3a60636e 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -466,7 +466,7 @@ impl LauncherUi { /// pointer is over it. fn card(&mut self, ui: &mut Ui, id: &str, rect: Rect) { let Some(entry) = self.entry(id).cloned() else { return }; - self.pin_drop_target(ui, id, rect); + self.pin_drop_target(ui, id, rect, PinDropAxis::Horizontal); let t = Tokens::get(ui.ctx()); let app = entry.app; let hovered = ui.rect_contains_pointer(rect); @@ -757,8 +757,8 @@ impl LauncherUi { Self::table_rule(ui, t, span, clip); } let row_rect = ui.max_rect(); - // Drop target spans the full table width so a drag from the grip column still hits the row. - self.pin_drop_target(ui, id, Rect::from_x_y_ranges(span, row_rect.y_range())); + // Full-row target (covers the grip column too); only this target reorders in the table. + self.pin_drop_target(ui, id, Rect::from_x_y_ranges(span, row_rect.y_range()), PinDropAxis::Vertical); // Clicking the app's cell opens its page (the content is added after, so it wins any hit). if AppDetails::by_id(id).is_some() { let open = ui.interact(row_rect, ui.scope_id().with(("row-open", id)), Sense::click()); @@ -911,14 +911,12 @@ impl LauncherUi { if resp.drag_stopped() { self.pin_dragging = None; } - // The grip column is also a drop target (table rows otherwise only register the first cell). - self.pin_drop_target(ui, entry.app.id, resp.rect.expand2(vec2(8.0, 12.0))); } /// While a pin handle is dragged, hovering another pinned card/row moves the dragged app there. - /// Insert before the target when the pointer is in its top/left half, otherwise after — so - /// grip-to-grip drops work in every direction without depending on travel relative to midpoints. - fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect) { + /// Grid uses the horizontal half; table uses the vertical half. One target per surface — the + /// table's full-row target already covers the grip column, so the grip itself does not reorder. + fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect, axis: PinDropAxis) { let Some(from) = self.pin_dragging.clone() else { return; }; @@ -938,10 +936,12 @@ impl LauncherUi { let Some(to_i) = self.settings.pinned.iter().position(|p| p == id) else { return; }; - let insert_before = pos.x < rect.center().x || pos.y < rect.center().y; - let last = self.settings.pinned.len().saturating_sub(1); - let final_index = if insert_before { to_i } else { to_i.saturating_add(1).min(last) }; - self.settings.reorder_pinned_to(&from, final_index); + let insert_before = match axis { + PinDropAxis::Horizontal => pos.x < rect.center().x, + PinDropAxis::Vertical => pos.y < rect.center().y, + }; + let insertion_index = if insert_before { to_i } else { to_i.saturating_add(1) }; + self.settings.reorder_pinned_to(&from, insertion_index); } /// A running job's row: one progress bar, with pause and cancel while its download is under @@ -1232,6 +1232,15 @@ impl LauncherUi { } } +/// Which half of a pin drop target decides insert-before vs insert-after. +#[derive(Clone, Copy, Debug)] +enum PinDropAxis { + /// Grid cards: left/right of the card. + Horizontal, + /// Table rows: above/below the row midline. + Vertical, +} + /// What the grid's sort dropdown offers: the order and, for names, which way. The installed apps /// always sit above the rest. const GRID_SORTS: [(SortKey, bool); 4] = [(SortKey::Name, false), (SortKey::Name, true), (SortKey::Released, true), (SortKey::Frequent, true)]; From c92c73af1373759d19d4fc9003f984edc5176f8b Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 14:38:01 +0100 Subject: [PATCH 06/12] Apply pin drop on pointer release before clearing drag state. --- crates/ui-egui/src/views.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index 3a60636e..2981fe37 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -905,17 +905,17 @@ impl LauncherUi { let resp = widgets::grip_button(ui, &tip); // Only latch on drag start: after a reorder, the same egui widget id can sit on a // different app's row, and `dragged()` would steal `pin_dragging` mid-gesture. + // Do not clear on `drag_stopped` here — drop targets must see the release first; + // `grid`/`table` clear `pin_dragging` when the pointer is up. if resp.drag_started() { self.pin_dragging = Some(entry.app.id.to_string()); } - if resp.drag_stopped() { - self.pin_dragging = None; - } } /// While a pin handle is dragged, hovering another pinned card/row moves the dragged app there. /// Grid uses the horizontal half; table uses the vertical half. One target per surface — the /// table's full-row target already covers the grip column, so the grip itself does not reorder. + /// A release over a target still counts (process before clearing `pin_dragging`). fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect, axis: PinDropAxis) { let Some(from) = self.pin_dragging.clone() else { return; @@ -923,10 +923,7 @@ impl LauncherUi { if from == id || !self.settings.is_pinned(id) { return; } - if !ui.input(|i| i.pointer.primary_down()) { - self.pin_dragging = None; - return; - } + let primary_down = ui.input(|i| i.pointer.primary_down()); let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return; }; @@ -942,6 +939,9 @@ impl LauncherUi { }; let insertion_index = if insert_before { to_i } else { to_i.saturating_add(1) }; self.settings.reorder_pinned_to(&from, insertion_index); + if !primary_down { + self.pin_dragging = None; + } } /// A running job's row: one progress bar, with pause and cancel while its download is under From 154b510ae7ae7eac0b87db16350facf1f5459eee Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 15:09:51 +0100 Subject: [PATCH 07/12] Cancel pin reorder on Escape before drop targets run. Clears pin_dragging when Escape is pressed at the start of grid/table so a cancelled drag cannot still reorder on release. --- crates/ui-egui/src/views.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index 2981fe37..4c827fd2 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -415,6 +415,7 @@ impl LauncherUi { /// Pinned apps first (user order), then installed, then the rest under a "Not installed" header. pub(crate) fn grid(&mut self, ui: &mut Ui, ids: &[&'static str]) { + self.clear_pin_drag_on_escape(ui); let pinned_n = ids.iter().take_while(|id| self.settings.is_pinned(id)).count(); let (pinned, rest) = ids.split_at(pinned_n); if !pinned.is_empty() { @@ -591,6 +592,7 @@ impl LauncherUi { pub(crate) fn table(&mut self, ui: &mut Ui, ids: &[&'static str]) { use egui_extras::{Column, TableBuilder}; + self.clear_pin_drag_on_escape(ui); let t = Tokens::get(ui.ctx()); let now = self.backend.now(); let mut sort_clicked = None; @@ -899,6 +901,13 @@ impl LauncherUi { self.menu(ui, entry, status, links_in_menu); } + /// Cancel an in-progress pin reorder (Escape) before drop targets run this frame. + fn clear_pin_drag_on_escape(&mut self, ui: &Ui) { + if self.pin_dragging.is_some() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + self.pin_dragging = None; + } + } + /// Drag handle that reorders this pinned app among the other pinned apps. fn pin_grip(&mut self, ui: &mut Ui, entry: &AppEntry) { let tip = fmt(tl!("Reorder {name}"), &[("name", entry.app.name)]); @@ -906,7 +915,7 @@ impl LauncherUi { // Only latch on drag start: after a reorder, the same egui widget id can sit on a // different app's row, and `dragged()` would steal `pin_dragging` mid-gesture. // Do not clear on `drag_stopped` here — drop targets must see the release first; - // `grid`/`table` clear `pin_dragging` when the pointer is up. + // `grid`/`table` clear `pin_dragging` when the pointer is up (Escape clears earlier). if resp.drag_started() { self.pin_dragging = Some(entry.app.id.to_string()); } From d140e09b575880f877d0180b63a46fa75ba44af4 Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 15:32:34 +0100 Subject: [PATCH 08/12] Restore pin order when Escape cancels a mid-drag reorder. Live hover mutates settings.pinned; Escape must undo that, not only clear pin_dragging. Move up/down also skip pins hidden by search/filter. --- crates/ui-egui/src/app.rs | 15 +++++++++++-- crates/ui-egui/src/views.rs | 22 +++++++++++++------ crates/ui-egui/tests/ui.rs | 22 +++++++++++++++++++ .../specs/2026-11-02-pin-apps-design.md | 1 + 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index e8576247..7dcf8045 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -205,8 +205,9 @@ pub struct LauncherUi { /// Toasts created from logic (which may run with an old input time) or the UI. Their expiry is /// fixed when the next `ui` pass actually shows them. pending_toasts: Vec<(String, bool)>, - /// Catalog id of a pinned app while the user drags its reorder handle. - pub(crate) pin_dragging: Option, + /// While the user drags a pin handle: dragged catalog id and the pinned order at drag start + /// (so Escape can restore it after live mid-drag reorders). + pub(crate) pin_dragging: Option<(String, Vec)>, } /// The language to draw in: the preference, or English where it needs a CJK font the system @@ -868,6 +869,16 @@ impl LauncherUi { self.visible_ids() } + /// Test hook: start a pin drag as if the grip had just been grabbed (stores a restore snapshot). + pub fn begin_pin_drag_for_test(&mut self, id: &str) { + self.pin_dragging = Some((id.to_string(), self.settings.pinned.clone())); + } + + /// Test hook: whether a pin reorder drag is in progress. + pub fn pin_dragging_for_test(&self) -> bool { + self.pin_dragging.is_some() + } + /// The ids to show, filtered and sorted. Pinned apps stay first in the user's pin order; /// the active sort (and the grid's installed / not-installed split) applies only to the rest. pub(crate) fn visible_ids(&self) -> Vec<&'static str> { diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index 4c827fd2..86f283fc 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -901,10 +901,14 @@ impl LauncherUi { self.menu(ui, entry, status, links_in_menu); } - /// Cancel an in-progress pin reorder (Escape) before drop targets run this frame. + /// Cancel an in-progress pin reorder (Escape) before drop targets run this frame, restoring + /// the pinned order from drag start (live hover already mutated `settings.pinned`). fn clear_pin_drag_on_escape(&mut self, ui: &Ui) { - if self.pin_dragging.is_some() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { - self.pin_dragging = None; + if self.pin_dragging.is_some() + && ui.input(|i| i.key_pressed(egui::Key::Escape)) + && let Some((_, original)) = self.pin_dragging.take() + { + self.settings.pinned = original; } } @@ -915,9 +919,9 @@ impl LauncherUi { // Only latch on drag start: after a reorder, the same egui widget id can sit on a // different app's row, and `dragged()` would steal `pin_dragging` mid-gesture. // Do not clear on `drag_stopped` here — drop targets must see the release first; - // `grid`/`table` clear `pin_dragging` when the pointer is up (Escape clears earlier). + // `grid`/`table` clear `pin_dragging` when the pointer is up (Escape restores earlier). if resp.drag_started() { - self.pin_dragging = Some(entry.app.id.to_string()); + self.pin_dragging = Some((entry.app.id.to_string(), self.settings.pinned.clone())); } } @@ -926,9 +930,10 @@ impl LauncherUi { /// table's full-row target already covers the grip column, so the grip itself does not reorder. /// A release over a target still counts (process before clearing `pin_dragging`). fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect, axis: PinDropAxis) { - let Some(from) = self.pin_dragging.clone() else { + let Some((from, _)) = self.pin_dragging.as_ref() else { return; }; + let from = from.clone(); if from == id || !self.settings.is_pinned(id) { return; } @@ -1153,7 +1158,10 @@ impl LauncherUi { self.settings.unpin_app(id); ui.close(); } - let known = |pid: &str| self.entry(pid).is_some(); + // Neighbors must be on screen under the current search/filter — catalog presence alone + // would let Move skip over a filtered pin with no visible change. + let visible: std::collections::HashSet<&str> = self.visible_ids().into_iter().collect(); + let known = |pid: &str| visible.contains(pid); let prev = self.settings.pinned_neighbor(id, -1, known).map(str::to_owned); let next = self.settings.pinned_neighbor(id, 1, known).map(str::to_owned); if prev.is_some() && ui.button(tl!("Move up")).clicked() { diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 0e184dda..3aba025e 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1447,6 +1447,28 @@ fn pinned_app_menu_offers_unpin() { assert!(h.state().as_ref().unwrap().settings.pinned.is_empty()); } +#[test] +/// Escape during a pin drag restores the order from drag start (live hover already mutated pins). +fn escape_cancels_pin_drag_and_restores_order() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(6); + { + let ui = h.state_mut().as_mut().unwrap(); + ui.settings.pin_app("photocraft"); + ui.settings.pin_app("filmcraft"); + ui.settings.pin_app("wordcraft"); + assert_eq!(ui.settings.pinned, ["photocraft", "filmcraft", "wordcraft"]); + ui.begin_pin_drag_for_test("photocraft"); + // Mid-drag: pointer has already reordered via drop targets. + ui.settings.pinned = vec!["filmcraft".into(), "photocraft".into(), "wordcraft".into()]; + } + h.key_press(egui::Key::Escape); + h.run_steps(2); + let ui = h.state().as_ref().unwrap(); + assert!(!ui.pin_dragging_for_test()); + assert_eq!(ui.settings.pinned, ["photocraft", "filmcraft", "wordcraft"]); +} + #[test] /// Grid shows a Pinned section label once at least one app is pinned. fn grid_shows_pinned_section_header() { diff --git a/docs/superpowers/specs/2026-11-02-pin-apps-design.md b/docs/superpowers/specs/2026-11-02-pin-apps-design.md index e59c2363..0873dc7e 100644 --- a/docs/superpowers/specs/2026-11-02-pin-apps-design.md +++ b/docs/superpowers/specs/2026-11-02-pin-apps-design.md @@ -53,6 +53,7 @@ In `menu` / `menu_hide` area (before Hide): - Painter-drawn grip (six dots or two bars), matching existing glyph buttons — not emoji. - Accessible name e.g. **Reorder {name}**. - Drag reorders only among pinned siblings; drop inserts before/after the hovered pinned target. +- Escape cancels an in-progress drag and restores the pin order from drag start (hover already mutates order live). - Cursor: grab / grabbing. - Respect `reduce_motion` only for any visual feedback animation; reorder itself is immediate. From 8e5a52b442a64e472ff318c290f57d5b3915cd7a Mon Sep 17 00:00:00 2001 From: tareq Date: Mon, 2 Nov 2026 15:47:14 +0100 Subject: [PATCH 09/12] Handle Escape pin cancel when search leaves no visible apps. grid/table were skipped on empty results, so Escape never restored the drag snapshot; poll cancel before that branch and end the drag on release. --- crates/ui-egui/src/app.rs | 9 ++++++++ crates/ui-egui/src/views.rs | 17 ++++++++++---- crates/ui-egui/tests/ui.rs | 23 +++++++++++++++++++ .../specs/2026-11-02-pin-apps-design.md | 2 +- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 7dcf8045..f4161682 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -841,7 +841,11 @@ impl LauncherUi { ViewMode::Table => egui::ScrollArea::both(), }; scroll.auto_shrink(false).show(ui, |ui| { + // Escape must run even when search/filter leaves no rows (grid/table skipped). + self.clear_pin_drag_on_escape(ui); if ids.is_empty() { + // No drop targets this frame — still end the drag on pointer up. + self.clear_pin_drag_if_pointer_up(ui); ui.add_space(40.0); ui.vertical_centered(|ui| { crate::widgets::hud(ui, tl!("No apps match"), tokens.muted); @@ -879,6 +883,11 @@ impl LauncherUi { self.pin_dragging.is_some() } + /// Test hook: set the toolbar search string without driving the text field. + pub fn set_search_for_test(&mut self, needle: &str) { + self.search = needle.to_string(); + } + /// The ids to show, filtered and sorted. Pinned apps stay first in the user's pin order; /// the active sort (and the grid's installed / not-installed split) applies only to the rest. pub(crate) fn visible_ids(&self) -> Vec<&'static str> { diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index 86f283fc..e09499cc 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -415,7 +415,6 @@ impl LauncherUi { /// Pinned apps first (user order), then installed, then the rest under a "Not installed" header. pub(crate) fn grid(&mut self, ui: &mut Ui, ids: &[&'static str]) { - self.clear_pin_drag_on_escape(ui); let pinned_n = ids.iter().take_while(|id| self.settings.is_pinned(id)).count(); let (pinned, rest) = ids.split_at(pinned_n); if !pinned.is_empty() { @@ -592,7 +591,6 @@ impl LauncherUi { pub(crate) fn table(&mut self, ui: &mut Ui, ids: &[&'static str]) { use egui_extras::{Column, TableBuilder}; - self.clear_pin_drag_on_escape(ui); let t = Tokens::get(ui.ctx()); let now = self.backend.now(); let mut sort_clicked = None; @@ -901,9 +899,10 @@ impl LauncherUi { self.menu(ui, entry, status, links_in_menu); } - /// Cancel an in-progress pin reorder (Escape) before drop targets run this frame, restoring - /// the pinned order from drag start (live hover already mutated `settings.pinned`). - fn clear_pin_drag_on_escape(&mut self, ui: &Ui) { + /// Cancel an in-progress pin reorder (Escape), restoring the pinned order from drag start + /// (live hover already mutated `settings.pinned`). Called from [`Self::ui`] before the + /// empty-results branch so Escape still works when search/filter hides every app mid-drag. + pub(crate) fn clear_pin_drag_on_escape(&mut self, ui: &Ui) { if self.pin_dragging.is_some() && ui.input(|i| i.key_pressed(egui::Key::Escape)) && let Some((_, original)) = self.pin_dragging.take() @@ -912,6 +911,14 @@ impl LauncherUi { } } + /// End a pin drag when the pointer is up (commit current order). Used when no grid/table + /// drop targets run — e.g. empty search results mid-drag. + pub(crate) fn clear_pin_drag_if_pointer_up(&mut self, ui: &Ui) { + if !ui.input(|i| i.pointer.primary_down()) { + self.pin_dragging = None; + } + } + /// Drag handle that reorders this pinned app among the other pinned apps. fn pin_grip(&mut self, ui: &mut Ui, entry: &AppEntry) { let tip = fmt(tl!("Reorder {name}"), &[("name", entry.app.name)]); diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 3aba025e..044a3e82 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1469,6 +1469,29 @@ fn escape_cancels_pin_drag_and_restores_order() { assert_eq!(ui.settings.pinned, ["photocraft", "filmcraft", "wordcraft"]); } +#[test] +/// Escape still restores pin order when search hides every app (grid/table never run). +fn escape_cancels_pin_drag_with_empty_results() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(6); + { + let ui = h.state_mut().as_mut().unwrap(); + ui.settings.pin_app("photocraft"); + ui.settings.pin_app("filmcraft"); + ui.begin_pin_drag_for_test("photocraft"); + ui.settings.pinned = vec!["filmcraft".into(), "photocraft".into()]; + // Apply search without an intervening empty frame (pointer-up would end the drag). + ui.set_search_for_test("zzzz-no-match"); + assert!(ui.visible_ids_for_test().is_empty()); + assert!(ui.pin_dragging_for_test()); + } + h.key_press(egui::Key::Escape); + h.run_steps(2); + let ui = h.state().as_ref().unwrap(); + assert!(!ui.pin_dragging_for_test()); + assert_eq!(ui.settings.pinned, ["photocraft", "filmcraft"]); +} + #[test] /// Grid shows a Pinned section label once at least one app is pinned. fn grid_shows_pinned_section_header() { diff --git a/docs/superpowers/specs/2026-11-02-pin-apps-design.md b/docs/superpowers/specs/2026-11-02-pin-apps-design.md index 0873dc7e..b99f4aa9 100644 --- a/docs/superpowers/specs/2026-11-02-pin-apps-design.md +++ b/docs/superpowers/specs/2026-11-02-pin-apps-design.md @@ -53,7 +53,7 @@ In `menu` / `menu_hide` area (before Hide): - Painter-drawn grip (six dots or two bars), matching existing glyph buttons — not emoji. - Accessible name e.g. **Reorder {name}**. - Drag reorders only among pinned siblings; drop inserts before/after the hovered pinned target. -- Escape cancels an in-progress drag and restores the pin order from drag start (hover already mutates order live). +- Escape cancels an in-progress drag and restores the pin order from drag start (hover already mutates order live), including when search/filter leaves no visible apps. - Cursor: grab / grabbing. - Respect `reduce_motion` only for any visual feedback animation; reorder itself is immediate. From cbe1582624e30021ae102858e87ca19146f6c9fb Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:00:18 +0100 Subject: [PATCH 10/12] fix(ui): keep pinned rows' buttons in their slot, one place ends the drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The action row's width lost a gap for every app, pinned or not (`+ gap`), so each row's menu ran 8 px past its column. The main button keeps its old slot; a pinned app's grip now leads the row and the table widens its actions column by one square while a pinned app is shown, so no button is squeezed (#956) and every row stays in line. A test checks a pinned row against an unpinned one. - The grip only shows in the grid and the table, where dropping works; on the details page it was a control that did nothing. - A released drag ends in one place, after the list draws, instead of in the grid, the table, the empty list and each drop target. - Move up and Move down use the drag's `reorder_pinned_to`; `reorder_pinned` and its test go, and `pinned_neighbor` returns the slot. - Drop the settings test that only re-ran its own steps (the UI test covers hiding a pinned app), the tool-generated design note, and use "Lösen" for Unpin in German. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app.rs | 6 +- crates/ui-egui/src/i18n/de.tsv | 2 +- crates/ui-egui/src/settings.rs | 75 ++++---------- crates/ui-egui/src/views.rs | 91 ++++++++--------- crates/ui-egui/tests/ui.rs | 26 ++++- .../specs/2026-11-02-pin-apps-design.md | 99 ------------------- 6 files changed, 89 insertions(+), 210 deletions(-) delete mode 100644 docs/superpowers/specs/2026-11-02-pin-apps-design.md diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index f4161682..5fbea855 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -844,8 +844,6 @@ impl LauncherUi { // Escape must run even when search/filter leaves no rows (grid/table skipped). self.clear_pin_drag_on_escape(ui); if ids.is_empty() { - // No drop targets this frame — still end the drag on pointer up. - self.clear_pin_drag_if_pointer_up(ui); ui.add_space(40.0); ui.vertical_centered(|ui| { crate::widgets::hud(ui, tl!("No apps match"), tokens.muted); @@ -857,6 +855,10 @@ impl LauncherUi { } } ui.add_space(16.0); + // The drop targets have seen this frame's pointer: a released drag ends here. + if !ui.input(|i| i.pointer.primary_down()) { + self.pin_dragging = None; + } }); }); self.details_drawer(&ctx); diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fb5afa8f..afc61539 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -124,7 +124,7 @@ Installed apps with a newer version Installierte Apps mit neuerer Version Apps you can install Apps, die du installieren kannst Pin to top Oben anheften - Unpin Loslösen + Unpin Lösen Pinned Angeheftet Reorder {name} {name} neu anordnen Move up Nach oben diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index aa3cf315..f8be326a 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -160,37 +160,19 @@ impl Settings { self.pinned.retain(|p| p != id); } - /// The next pinned id above (`delta = -1`) or below (`delta = 1`) `id` that passes `available`. - /// Skips stale catalog ids left in `pinned` so Move up/down still change the visible order. - pub fn pinned_neighbor(&self, id: &str, delta: i32, available: impl Fn(&str) -> bool) -> Option<&str> { - let i = self.pinned.iter().position(|p| p == id)?; - let mut j = i; + /// The index of the next pinned id above (`delta = -1`) or below (`delta = 1`) `id` that passes + /// `available`. Skips stale catalog ids left in `pinned` so Move up/down still change the + /// visible order. + pub fn pinned_neighbor(&self, id: &str, delta: isize, available: impl Fn(&str) -> bool) -> Option { + let mut j = self.pinned.iter().position(|p| p == id)?; loop { - j = j.checked_add_signed(delta as isize)?; - let cand = self.pinned.get(j)?; - if available(cand) { - return Some(cand.as_str()); + j = j.checked_add_signed(delta)?; + if available(self.pinned.get(j)?) { + return Some(j); } } } - /// Move the pinned app `from_id` so its final index is the current index of `to_id`. - pub fn reorder_pinned(&mut self, from_id: &str, to_id: &str) { - if from_id == to_id { - return; - } - let Some(from) = self.pinned.iter().position(|p| p == from_id) else { - return; - }; - let Some(to) = self.pinned.iter().position(|p| p == to_id) else { - return; - }; - let item = self.pinned.remove(from); - // After `remove`, indices after `from` shift left; inserting at the original `to` - // still lands the item on that final index (whether moving up or down). - self.pinned.insert(to.min(self.pinned.len()), item); - } - /// Move `from_id` to insertion slot `to_index` (`0..=len`, before remove). Used by drag-and-drop. pub fn reorder_pinned_to(&mut self, from_id: &str, mut to_index: usize) { let Some(from) = self.pinned.iter().position(|p| p == from_id) else { @@ -302,23 +284,6 @@ mod tests { assert_eq!(s.pinned, vec!["filmcraft"]); } - #[test] - fn reorder_pinned_moves_to_target_index() { - let mut s = Settings { pinned: vec!["a".into(), "b".into(), "c".into(), "d".into()], ..Settings::default() }; - s.reorder_pinned("a", "c"); - assert_eq!(s.pinned, vec!["b", "c", "a", "d"]); - s.reorder_pinned("a", "b"); - assert_eq!(s.pinned, vec!["a", "b", "c", "d"]); - s.reorder_pinned("photocraft", "filmcraft"); // no-op when missing - let mut s = Settings { pinned: vec!["photocraft".into(), "filmcraft".into()], ..Settings::default() }; - s.reorder_pinned("photocraft", "filmcraft"); - assert_eq!(s.pinned, vec!["filmcraft", "photocraft"]); - s.reorder_pinned("missing", "a"); - s.reorder_pinned("filmcraft", "missing"); - s.reorder_pinned("filmcraft", "filmcraft"); - assert_eq!(s.pinned, vec!["filmcraft", "photocraft"]); - } - #[test] fn reorder_pinned_to_uses_insertion_slot() { let mut s = Settings { pinned: vec!["a".into(), "b".into(), "c".into()], ..Settings::default() }; @@ -331,9 +296,13 @@ mod tests { // Insert a before c (slot 2) → [b, a, c]. s.reorder_pinned_to("a", 2); assert_eq!(s.pinned, vec!["b", "a", "c"]); - // Insert a after itself (slot 2) is a no-op. + // Insert a after itself (slot 2) is a no-op, and so is an unknown id; a slot past the end + // means the end. s.reorder_pinned_to("a", 2); + s.reorder_pinned_to("missing", 0); assert_eq!(s.pinned, vec!["b", "a", "c"]); + s.reorder_pinned_to("b", 99); + assert_eq!(s.pinned, vec!["a", "c", "b"]); } #[test] @@ -342,22 +311,12 @@ mod tests { assert!(s.pinned.is_empty()); } - #[test] - fn hide_path_unpins() { - let mut s = Settings::default(); - s.pin_app("photocraft"); - s.unpin_app("photocraft"); - s.hidden.insert("photocraft".into()); - assert!(s.pinned.is_empty()); - assert!(s.hidden.contains("photocraft")); - } - #[test] fn pinned_neighbor_steps_within_bounds() { let s = Settings { pinned: vec!["a".into(), "b".into(), "c".into()], ..Settings::default() }; let any = |_: &str| true; - assert_eq!(s.pinned_neighbor("b", -1, any), Some("a")); - assert_eq!(s.pinned_neighbor("b", 1, any), Some("c")); + assert_eq!(s.pinned_neighbor("b", -1, any), Some(0)); + assert_eq!(s.pinned_neighbor("b", 1, any), Some(2)); assert_eq!(s.pinned_neighbor("a", -1, any), None); assert_eq!(s.pinned_neighbor("c", 1, any), None); assert_eq!(s.pinned_neighbor("missing", 1, any), None); @@ -367,7 +326,7 @@ mod tests { fn pinned_neighbor_skips_unavailable_ids() { let s = Settings { pinned: vec!["a".into(), "gone".into(), "b".into()], ..Settings::default() }; let live = |id: &str| id != "gone"; - assert_eq!(s.pinned_neighbor("a", 1, live), Some("b")); - assert_eq!(s.pinned_neighbor("b", -1, live), Some("a")); + assert_eq!(s.pinned_neighbor("a", 1, live), Some(2)); + assert_eq!(s.pinned_neighbor("b", -1, live), Some(0)); } } diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index e09499cc..79584e13 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -437,9 +437,6 @@ impl LauncherUi { } self.grid_block(ui, rest); ui.add_space(24.0); - if !ui.input(|i| i.pointer.primary_down()) { - self.pin_dragging = None; - } } fn grid_block(&mut self, ui: &mut Ui, ids: &[&'static str]) { @@ -601,6 +598,7 @@ impl LauncherUi { // clipped to the scroll area. let clip = ui.clip_rect(); let span = ui.available_rect_before_wrap().x_range(); + let grip_w = self.grip_w(ui, ids); egui::Frame::new().fill(t.bg).stroke(t.hairline()).inner_margin(egui::Margin::symmetric(16, 0)).show(ui, |ui| { TableBuilder::new(ui) @@ -612,7 +610,7 @@ impl LauncherUi { .column(Column::exact(150.0)) .column(Column::exact(132.0)) .column(Column::exact(104.0)) - .column(Column::exact(206.0)) + .column(Column::exact(206.0 + grip_w)) .header(42.0, |header| self.table_header(header, &mut sort_clicked, &t, span, clip)) .body(|body| self.table_rows(body, ids, &t, (title_h, tagline_h), now, (span, clip))); }); @@ -624,9 +622,6 @@ impl LauncherUi { self.settings.sort_descending = k.starts_descending(); } } - if !ui.input(|i| i.pointer.primary_down()) { - self.pin_dragging = None; - } } /// The table's hairline under the row `ui` is drawing, across the table's full width. @@ -723,6 +718,7 @@ impl LauncherUi { rule: (egui::emath::Rangef, egui::Rect), ) { let last = ids.len().saturating_sub(1); + let grip_w = self.grip_w(body.ui_mut(), ids); 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); @@ -734,7 +730,16 @@ impl LauncherUi { row.col(|ui| { ui.label(RichText::new(entry.app.publisher).color(t.muted)); }); - row.col(|ui| self.actions(ui, &entry, &status, 204.0)); + // A pinned row's grip leads its actions; the other rows leave its room empty, so + // 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); + } else { + ui.add_space(grip_w); + self.actions(ui, &entry, &status, 204.0); + } + }); }); } } @@ -877,14 +882,18 @@ impl LauncherUi { } /// 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. Pinned apps also get a drag - /// handle for reordering. - pub(crate) fn actions_in(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, width: f32, links_in_menu: bool) { + /// 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) { let id = entry.app.id.to_string(); let gap = ui.spacing().item_spacing.x; - let pinned = self.settings.is_pinned(entry.app.id); - let squares = 1.0 + if pinned { 1.0 } else { 0.0 }; - let main_w = (width - squares * (theme::CONTROL_H + gap) + gap).max(90.0); + 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); if entry.job.is_some() { self.job_actions(ui, entry, &id, main_w, width); return; @@ -893,12 +902,15 @@ impl LauncherUi { return; } self.primary_button(ui, entry, &id, status, main_w); - if pinned { - self.pin_grip(ui, entry); - } self.menu(ui, entry, status, links_in_menu); } + /// The room a pin grip takes: one square and a gap while the list shows a pinned app (they come + /// first), else none. + fn grip_w(&self, ui: &Ui, ids: &[&'static str]) -> f32 { + if ids.first().is_some_and(|id| self.settings.is_pinned(id)) { theme::CONTROL_H + ui.spacing().item_spacing.x } else { 0.0 } + } + /// Cancel an in-progress pin reorder (Escape), restoring the pinned order from drag start /// (live hover already mutated `settings.pinned`). Called from [`Self::ui`] before the /// empty-results branch so Escape still works when search/filter hides every app mid-drag. @@ -911,14 +923,6 @@ impl LauncherUi { } } - /// End a pin drag when the pointer is up (commit current order). Used when no grid/table - /// drop targets run — e.g. empty search results mid-drag. - pub(crate) fn clear_pin_drag_if_pointer_up(&mut self, ui: &Ui) { - if !ui.input(|i| i.pointer.primary_down()) { - self.pin_dragging = None; - } - } - /// Drag handle that reorders this pinned app among the other pinned apps. fn pin_grip(&mut self, ui: &mut Ui, entry: &AppEntry) { let tip = fmt(tl!("Reorder {name}"), &[("name", entry.app.name)]); @@ -926,7 +930,7 @@ impl LauncherUi { // Only latch on drag start: after a reorder, the same egui widget id can sit on a // different app's row, and `dragged()` would steal `pin_dragging` mid-gesture. // Do not clear on `drag_stopped` here — drop targets must see the release first; - // `grid`/`table` clear `pin_dragging` when the pointer is up (Escape restores earlier). + // `LauncherUi::ui` clears `pin_dragging` after the list once the pointer is up. if resp.drag_started() { self.pin_dragging = Some((entry.app.id.to_string(), self.settings.pinned.clone())); } @@ -935,7 +939,7 @@ impl LauncherUi { /// While a pin handle is dragged, hovering another pinned card/row moves the dragged app there. /// Grid uses the horizontal half; table uses the vertical half. One target per surface — the /// table's full-row target already covers the grip column, so the grip itself does not reorder. - /// A release over a target still counts (process before clearing `pin_dragging`). + /// A release over a target still counts: `LauncherUi::ui` ends the drag after the list. fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect, axis: PinDropAxis) { let Some((from, _)) = self.pin_dragging.as_ref() else { return; @@ -944,7 +948,6 @@ impl LauncherUi { if from == id || !self.settings.is_pinned(id) { return; } - let primary_down = ui.input(|i| i.pointer.primary_down()); let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return; }; @@ -960,9 +963,6 @@ impl LauncherUi { }; let insertion_index = if insert_before { to_i } else { to_i.saturating_add(1) }; self.settings.reorder_pinned_to(&from, insertion_index); - if !primary_down { - self.pin_dragging = None; - } } /// A running job's row: one progress bar, with pause and cancel while its download is under @@ -1031,9 +1031,8 @@ impl LauncherUi { return false; } let pct = if p.total > 0 { format!(" · {:.0}%", p.done as f32 / p.total as f32 * 100.0) } else { String::new() }; - // Resume, then discard, optional pin grip, and the menu. - let squares = 2.0 + if self.settings.is_pinned(entry.app.id) { 1.0 } else { 0.0 }; - let resume_w = (width - squares * (theme::CONTROL_H + gap) + gap).max(0.0); + // Resume, then discard and the menu (two squares). + let resume_w = (width - 2.0 * (theme::CONTROL_H + gap)).max(90.0); // Narrow (the table): just tl!("Resume"); the tooltip says how far it got. let label = match resume_w { w if w < 150.0 => tl!("Resume").to_string(), @@ -1052,9 +1051,6 @@ impl LauncherUi { if widgets::glyph_button(ui, widgets::Glyph::Close, tl!("Discard download")).clicked() { self.act_command(Command::Cancel { app: id.to_string() }); } - if self.settings.is_pinned(entry.app.id) { - self.pin_grip(ui, entry); - } self.menu(ui, entry, status, links_in_menu); true } @@ -1169,19 +1165,16 @@ impl LauncherUi { // would let Move skip over a filtered pin with no visible change. let visible: std::collections::HashSet<&str> = self.visible_ids().into_iter().collect(); let known = |pid: &str| visible.contains(pid); - let prev = self.settings.pinned_neighbor(id, -1, known).map(str::to_owned); - let next = self.settings.pinned_neighbor(id, 1, known).map(str::to_owned); - if prev.is_some() && ui.button(tl!("Move up")).clicked() { - if let Some(prev) = prev.as_deref() { - self.settings.reorder_pinned(id, prev); + // Up: into the slot before the neighbour above; down: into the slot after the one below. + let up = self.settings.pinned_neighbor(id, -1, known); + let down = self.settings.pinned_neighbor(id, 1, known).map(|j| j + 1); + for (slot, label) in [(up, tl!("Move up")), (down, tl!("Move down"))] { + if let Some(slot) = slot + && ui.button(label).clicked() + { + self.settings.reorder_pinned_to(id, slot); + ui.close(); } - ui.close(); - } - if next.is_some() && ui.button(tl!("Move down")).clicked() { - if let Some(next) = next.as_deref() { - self.settings.reorder_pinned(id, next); - } - ui.close(); } } else if !hidden && ui.button(tl!("Pin to top")).clicked() { self.settings.pin_app(id); diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 044a3e82..51804dfc 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1407,7 +1407,7 @@ fn pinning_an_app_keeps_it_on_top_until_unpinned() { let ids = h.state().as_ref().unwrap().visible_ids_for_test(); assert_eq!(ids.first().copied(), Some("photocraft"), "pinned app stays first under Name sort: {ids:?}"); h.state_mut().as_mut().unwrap().settings.pin_app("filmcraft"); - h.state_mut().as_mut().unwrap().settings.reorder_pinned("photocraft", "filmcraft"); + h.state_mut().as_mut().unwrap().settings.reorder_pinned_to("photocraft", 2); h.run_steps(2); assert_eq!(h.state().as_ref().unwrap().settings.pinned, vec!["filmcraft".to_string(), "photocraft".to_string()]); assert_eq!(&h.state().as_ref().unwrap().visible_ids_for_test()[..2], &["filmcraft", "photocraft"]); @@ -1504,6 +1504,30 @@ fn grid_shows_pinned_section_header() { h.get_all_by_label_contains("Reorder PhotoCraft").next().expect("pinned card exposes a reorder handle"); } +#[test] +/// A pinned table row's grip leads its actions without taking room from them: its Open button is +/// as wide as an unpinned row's, and the menus stay in one line (#956: a squeezed button pushes +/// the menu out of the column). +fn a_pinned_table_row_keeps_its_buttons_in_line() { + let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true); + h.run_steps(6); + // LightCraft and WordCraft are both installed and up to date: each row offers Open. + h.state_mut().as_mut().unwrap().settings.pin_app("lightcraft"); + h.run_steps(4); + let grip = h.get_all_by_label_contains("Reorder LightCraft").next().expect("pinned row has a grip").rect(); + let open_in_row = |menu: egui::Rect| { + let row = menu.y_range(); + h.query_all_by_label("Open").map(|n| n.rect()).find(|r| row.contains(r.center().y)).expect("an Open button in the row") + }; + let (pinned_menu, other_menu) = + (h.get_by_label("More actions for LightCraft").rect(), h.get_by_label("More actions for WordCraft").rect()); + let (pinned_open, other_open) = (open_in_row(pinned_menu), open_in_row(other_menu)); + assert!(grip.right() <= pinned_open.left(), "grip {grip:?} leads the row's Open {pinned_open:?}"); + assert!((pinned_open.width() - other_open.width()).abs() < 0.5, "pinned {pinned_open:?} vs unpinned {other_open:?}"); + assert!((pinned_open.left() - other_open.left()).abs() < 0.5, "pinned {pinned_open:?} vs unpinned {other_open:?}"); + assert!((pinned_menu.right() - other_menu.right()).abs() < 0.5, "pinned {pinned_menu:?} vs unpinned {other_menu:?}"); +} + #[test] /// Hide removes an app from All into Hidden Apps; Unhide restores it, clears the hidden set and /// returns the filter to All. diff --git a/docs/superpowers/specs/2026-11-02-pin-apps-design.md b/docs/superpowers/specs/2026-11-02-pin-apps-design.md deleted file mode 100644 index b99f4aa9..00000000 --- a/docs/superpowers/specs/2026-11-02-pin-apps-design.md +++ /dev/null @@ -1,99 +0,0 @@ -# Pin apps to top — design - -**Date:** 2026-11-02 -**Repo:** storytold/craft-launcher -**Status:** approved - -## Goal - -Let users pin catalog apps to a fixed block at the top of the launcher (grid and table), and reorder those pinned apps by drag-and-drop. Pin order is intentional and is not scrambled by the active sort. - -## Decisions - -| Topic | Choice | -|---|---| -| Pin entry points | `…` menu **Pin to top** / **Unpin**, plus a drag handle on pinned items | -| Sort interaction | Pinned stay in user order at top; sort applies only to unpinned apps | -| Grid grouping | One **Pinned** block first; then existing Installed / Not installed for the rest | -| Persistence | `Settings.pinned: Vec` (catalog ids, order = display order) | -| Hide interaction | Hiding an app removes it from `pinned` | - -## Data - -- Add `pinned: Vec` to `Settings` with `#[serde(default)]`. -- Helpers (on `Settings` or next to sort logic): - - `is_pinned(id)` - - `pin(id)` — append if absent - - `unpin(id)` — remove if present - - `reorder_pinned(from, to)` — move within the vec; ignore out-of-range -- On **Hide App**, also `unpin`. -- Unknown / removed catalog ids in `pinned` are skipped when building the visible list (left in settings until unpin/hide so re-adding an app can restore order later is unnecessary; prune on load is optional — prefer skip-at-display for simplicity). - -## Ordering - -`LauncherUi::visible_ids` (or equivalent): - -1. Collect filtered/search-matching entries as today. -2. Partition into pinned vs unpinned using `settings.pinned` membership. -3. Emit pinned ids in `settings.pinned` order (only those present in the filtered set). -4. Sort unpinned with the existing sort keys; in Grid, still put installed before not-installed within the unpinned set only. - -## UI - -### Menu - -In `menu` / `menu_hide` area (before Hide): - -- If not pinned: **Pin to top** -- If pinned: **Unpin** - -### Drag handle - -- Shown only when the app is pinned (grid card and table row). -- Painter-drawn grip (six dots or two bars), matching existing glyph buttons — not emoji. -- Accessible name e.g. **Reorder {name}**. -- Drag reorders only among pinned siblings; drop inserts before/after the hovered pinned target. -- Escape cancels an in-progress drag and restores the pin order from drag start (hover already mutates order live), including when search/filter leaves no visible apps. -- Cursor: grab / grabbing. -- Respect `reduce_motion` only for any visual feedback animation; reorder itself is immediate. - -### Section header (grid) - -When the pinned block is non-empty, show a **Pinned** HUD label (same style as **Not installed**) above that block. - -### Table - -Same id order as grid’s logical list (pinned first). Handle sits near the app name or before the `…` control without changing column semantics. - -## i18n - -Add English keys to every `complete_menus` catalog TSV: - -- `Pin to top` -- `Unpin` -- `Pinned` -- `Reorder {name}` (accessible name / tooltip) - -Translate clean-room from meaning; product names stay English. - -## Tests - -- Unit: pin/unpin/reorder helpers; hide clears pin; `visible_ids` puts pinned first in pin order then sorted unpinned. -- UI harness: menu shows Pin/Unpin; after pin, Pinned header (grid) / order in table; reorder via helper or simulated drag if harness allows. -- Settings round-trip: `pinned` survives serde default for old settings files. - -## Out of scope - -- Syncing pins across machines -- Changing upstream suite catalog order -- A new Sort key for “Pinned” -- Pinning while the Hidden filter is active (hidden apps are not in the main list; unpin-on-hide covers this) - -## Files (expected) - -- `crates/ui-egui/src/settings.rs` — field + helpers -- `crates/ui-egui/src/app.rs` — `visible_ids` partition -- `crates/ui-egui/src/views.rs` — menu, grid sections, drag, table handle -- `crates/ui-egui/src/widgets.rs` — grip glyph / handle widget if needed -- `crates/ui-egui/src/i18n/*.tsv` — strings -- `crates/ui-egui/tests/ui.rs` (+ unit tests near helpers) From 644b2adb6f8c28d8d773b4738f3167b638ad1531 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:06:33 +0100 Subject: [PATCH 11/12] style(ui): rustfmt the pinned-row test Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/ui.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 440e3956..8a519fe2 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1550,8 +1550,7 @@ fn a_pinned_table_row_keeps_its_buttons_in_line() { let row = menu.y_range(); h.query_all_by_label("Open").map(|n| n.rect()).find(|r| row.contains(r.center().y)).expect("an Open button in the row") }; - let (pinned_menu, other_menu) = - (h.get_by_label("More actions for LightCraft").rect(), h.get_by_label("More actions for WordCraft").rect()); + let (pinned_menu, other_menu) = (h.get_by_label("More actions for LightCraft").rect(), h.get_by_label("More actions for WordCraft").rect()); let (pinned_open, other_open) = (open_in_row(pinned_menu), open_in_row(other_menu)); assert!(grip.right() <= pinned_open.left(), "grip {grip:?} leads the row's Open {pinned_open:?}"); assert!((pinned_open.width() - other_open.width()).abs() < 0.5, "pinned {pinned_open:?} vs unpinned {other_open:?}"); From 8aa8df744a560393cb0ff8c0e21026d88d9d350d Mon Sep 17 00:00:00 2001 From: FredApps Date: Tue, 3 Nov 2026 09:08:50 +0100 Subject: [PATCH 12/12] Fix pinned app drops outside the scroll viewport --- crates/ui-egui/src/views.rs | 10 ++++--- crates/ui-egui/tests/ui.rs | 55 +++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index 4c6ca3dd..b7085e4f 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -475,7 +475,7 @@ impl LauncherUi { /// pointer is over it. fn card(&mut self, ui: &mut Ui, id: &str, rect: Rect) { let Some(entry) = self.entry(id).cloned() else { return }; - self.pin_drop_target(ui, id, rect, PinDropAxis::Horizontal); + self.pin_drop_target(ui, id, rect, ui.clip_rect(), PinDropAxis::Horizontal); let t = Tokens::get(ui.ctx()); let app = entry.app; let hovered = ui.rect_contains_pointer(rect); @@ -775,7 +775,7 @@ impl LauncherUi { } let row_rect = ui.max_rect(); // Full-row target (covers the grip column too); only this target reorders in the table. - self.pin_drop_target(ui, id, Rect::from_x_y_ranges(span, row_rect.y_range()), PinDropAxis::Vertical); + self.pin_drop_target(ui, id, Rect::from_x_y_ranges(span, row_rect.y_range()), clip, PinDropAxis::Vertical); // Clicking the app's cell opens its page (the content is added after, so it wins any hit). if AppDetails::by_id(id).is_some() { let open = ui.interact(row_rect, ui.scope_id().with(("row-open", id)), Sense::click()); @@ -953,7 +953,7 @@ impl LauncherUi { /// Grid uses the horizontal half; table uses the vertical half. One target per surface — the /// table's full-row target already covers the grip column, so the grip itself does not reorder. /// A release over a target still counts: `LauncherUi::ui` ends the drag after the list. - fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect, axis: PinDropAxis) { + fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect, viewport: Rect, axis: PinDropAxis) { let Some((from, _)) = self.pin_dragging.as_ref() else { return; }; @@ -964,7 +964,9 @@ impl LauncherUi { let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return; }; - if !rect.contains(pos) { + // Layout includes clipped-off entries. Only the scroll viewport accepts drops; the + // table passes its full viewport rather than the narrower first-column clip. + if !rect.contains(pos) || !viewport.contains(pos) { return; } let Some(to_i) = self.settings.pinned.iter().position(|p| p == id) else { diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 8a519fe2..cdd7f792 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1558,6 +1558,61 @@ fn a_pinned_table_row_keeps_its_buttons_in_line() { assert!((pinned_menu.right() - other_menu.right()).abs() < 0.5, "pinned {pinned_menu:?} vs unpinned {other_menu:?}"); } +#[test] +fn pin_drag_ignores_targets_clipped_by_the_scroll_viewport() { + for view in [ViewMode::Grid, ViewMode::Table] { + let (mut h, _) = harness(view, egui::Theme::Dark, true); + h.run_steps(6); + let pins = vec!["photocraft".to_string(), "lightcraft".to_string(), "wordcraft".to_string()]; + h.state_mut().as_mut().unwrap().settings.pinned = pins.clone(); + h.run_steps(4); + let grip = h.get_by_label("Reorder PhotoCraft").rect().center(); + let target = h.get_by_label("WordCraft details").rect(); + let at = match view { + ViewMode::Grid => egui::pos2(target.right() - 10.0, target.bottom() - 20.0), + ViewMode::Table => egui::pos2(h.get_by_label("Reorder WordCraft").rect().center().x, target.bottom() - 10.0), + }; + h.hover_at(grip); + h.run_steps(2); + h.drag_at(grip); + h.run_steps(2); + h.hover_at(grip + egui::vec2(0.0, -8.0)); + h.run_steps(2); + assert!(h.state().as_ref().unwrap().pin_dragging_for_test(), "{view:?}: real grip starts drag"); + // Keep the target's layout position, but clip it behind the window footer. + h.set_size(egui::vec2(1180.0, at.y + 2.0)); + h.hover_at(at); + h.run_steps(2); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, pins, "{view:?}: clipped hover"); + h.drop_at(at); + h.run_steps(2); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, pins, "{view:?}: clipped release"); + assert!(!h.state().as_ref().unwrap().pin_dragging_for_test()); + } +} + +#[test] +fn pin_drag_accepts_the_table_grip_column() { + let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true); + h.run_steps(6); + h.state_mut().as_mut().unwrap().settings.pinned = vec!["photocraft".into(), "lightcraft".into(), "wordcraft".into()]; + h.run_steps(4); + let from = h.get_by_label("Reorder PhotoCraft").rect().center(); + let target = h.get_by_label("Reorder WordCraft").rect(); + h.hover_at(from); + h.run_steps(2); + h.drag_at(from); + h.run_steps(2); + h.hover_at(from + egui::vec2(0.0, -8.0)); + h.run_steps(2); + assert!(h.state().as_ref().unwrap().pin_dragging_for_test()); + // Release directly in the last column, outside the first app cell's clip rectangle. + h.drop_at(egui::pos2(target.center().x, target.bottom() - 2.0)); + h.run_steps(2); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, ["lightcraft", "wordcraft", "photocraft"]); + assert!(!h.state().as_ref().unwrap().pin_dragging_for_test()); +} + #[test] /// Hide removes an app from All into Hidden Apps; Unhide restores it, clears the hidden set and /// returns the filter to All.