Merge pull request #1149 from tareq-halaby/feat/pin-apps-to-top

Add pin-to-top and reorder for launcher apps
This commit is contained in:
FredApps authored and GitHub committed 2026-11-03 09:17:12 +01:00
commit 1f168df0af
15 files changed
+585 -17

No files matched your search

+41 -5
View File
@@ -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<String>)>,
}
/// 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::<Vec<_>, _>(|(_, 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()
}
+6
View File
@@ -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
1 # Czech (cs) catalog for ArtCraft Launcher.
109
110
111
112
113
114
115
116
117
118
119
120
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 11 and column 16.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 48.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 50.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 11 and column 16.
+6
View File
@@ -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 非表示のアプリ
Can't render this file because it contains an unexpected character in line 14 and column 44.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 11 and column 15.
+6
View File
@@ -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
1 # Brazilian Portuguese (pt-BR) catalog for ArtCraft Launcher.
111
112
113
114
115
116
117
118
119
120
121
122
+6
View File
@@ -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 Скрытые приложения
Can't render this file because it contains an unexpected character in line 13 and column 44.
+6
View File
@@ -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 已隐藏的应用
Can't render this file because it contains an unexpected character in line 4 and column 3.
+115
View File
@@ -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<String, u32>,
/// Catalog ids pinned to the top of the grid/table, in the order the user chose.
pub pinned: Vec<String>,
}
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<usize> {
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));
}
}
+147 -9
View File
@@ -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)];
+30 -3
View File
@@ -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> {
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)
+192
View File
@@ -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.