diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index f6ee3b01..d906aee9 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -205,6 +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)>, + /// 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 @@ -294,6 +297,7 @@ impl LauncherUi { frame: None, flushed: Vec::new(), pending_toasts: Vec::new(), + pin_dragging: None, } } @@ -844,6 +848,8 @@ 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() { ui.add_space(40.0); ui.vertical_centered(|ui| { @@ -856,6 +862,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); @@ -867,7 +877,28 @@ 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() + } + + /// 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() + } + + /// 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> { let needle = self.search.trim().to_lowercase(); let mut list: Vec<(usize, &AppEntry)> = self @@ -895,7 +926,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)), @@ -923,11 +957,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 8a0cb2f6..4e12dca0 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -109,6 +109,12 @@ 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} + 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 f6c38b5c..50e9444e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -123,6 +123,12 @@ 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 Lö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 5f7c61d9..dd3a798b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -122,6 +122,12 @@ 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} + 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 a8301291..2289324f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -121,6 +121,12 @@ 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} + 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 0eb3a94b..ae6650e4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -123,6 +123,12 @@ 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} + 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 b2b6dc03..0c1702fc 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -125,6 +125,12 @@ Apps on this computer このコンピューターのアプリ Installed apps with a newer version 新しいバージョンがあるインストール済みアプリ Apps you can install インストールできるアプリ + Pin to top 上部にピン留め + 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 ca7e55e4..17ae818c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -124,6 +124,12 @@ 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} + 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 554c58a4..38b61111 100644 --- a/crates/ui-egui/src/i18n/pt-BR.tsv +++ b/crates/ui-egui/src/i18n/pt-BR.tsv @@ -111,6 +111,12 @@ 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} + 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 db0e1e31..b24e14b0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -121,6 +121,12 @@ Apps on this computer Приложения на этом компьютере Installed apps with a newer version Установленные приложения, у которых есть новая версия Apps you can install Приложения, которые можно установить + Pin to top Закрепить сверху + 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 6c7c3193..f3017b6a 100644 --- a/crates/ui-egui/src/i18n/zh-CN.tsv +++ b/crates/ui-egui/src/i18n/zh-CN.tsv @@ -271,6 +271,12 @@ 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} + 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 fe520ca1..f8be326a 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,54 @@ 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); + } + + /// 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)?; + if available(self.pinned.get(j)?) { + return Some(j); + } + } + } + + /// 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; + }; + 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); + 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 /// ([`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 +262,71 @@ 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_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"]); + // Insert c after b (slot 3). + s.reorder_pinned_to("c", 3); + assert_eq!(s.pinned, vec!["a", "b", "c"]); + // 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, 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] + 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 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(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); + } + + #[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(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 ccdb34a2..b7085e4f 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -425,10 +425,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()); @@ -464,6 +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, ui.clip_rect(), PinDropAxis::Horizontal); let t = Tokens::get(ui.ctx()); let app = entry.app; let hovered = ui.rect_contains_pointer(rect); @@ -598,6 +610,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) @@ -609,7 +622,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))); }); @@ -717,6 +730,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); @@ -728,7 +742,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); + } + }); }); } } @@ -750,9 +773,12 @@ impl LauncherUi { if rule { Self::table_rule(ui, t, span, clip); } + 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()), 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(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()); @@ -869,11 +895,17 @@ 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. - 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(); - // One square button (the menu) beside the main one. let gap = ui.spacing().item_spacing.x; + 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); @@ -886,6 +918,68 @@ impl LauncherUi { 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. + 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() + { + self.settings.pinned = original; + } + } + + /// 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); + // 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; + // `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())); + } + } + + /// 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: `LauncherUi::ui` ends the drag after the list. + 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; + }; + let from = from.clone(); + if from == id || !self.settings.is_pinned(id) { + return; + } + let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { + return; + }; + // 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 { + return; + }; + 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 /// 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) { @@ -1067,10 +1161,43 @@ 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. 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); + let hidden = self.settings.hidden.contains(entry.app.id); + if pinned { + if ui.button(tl!("Unpin")).clicked() { + self.settings.unpin_app(id); + ui.close(); + } + // 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); + // 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(); + } + } + } else if !hidden && ui.button(tl!("Pin to top")).clicked() { + 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) { @@ -1119,6 +1246,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); @@ -1129,6 +1257,7 @@ impl LauncherUi { self.settings.filter = Filter::All; } } else { + self.settings.unpin_app(id); self.settings.hidden.insert(id.to_string()); } ui.close(); @@ -1136,6 +1265,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)]; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index fdedd686..5b3ee38f 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -529,6 +529,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`. @@ -585,9 +587,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()); @@ -711,7 +738,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); @@ -769,11 +796,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 4ab1b64d..cdd7f792 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1421,6 +1421,198 @@ 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_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"]); + 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] +/// 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] +/// 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() { + 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] +/// 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] +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.