mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-11-09 20:04:34 +00:00
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:
15 files changed
+585
-17
No files matched your search
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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
@@ -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)];
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user