Home screen: remove individual Recent entries (#2731)

Fixes #2691. The hovered row of the Home screen's Recent list shows a ×
that takes the file off the list without opening it, and right-click
offers Remove from Recent and Clear Recent Files. The change is stored in
the preferences, so it survives a restart, and the file comes back when it
is opened again. `file.removeRecent {"path"}` does the same for agents.
Rows carry their file name as their accessible label.
This commit is contained in:
F4LCON
2026-10-10 16:49:34 -04:00
committed by GitHub
parent a28d786638
commit b773a39efa
19 changed files with 145 additions and 6 deletions
+89 -5
View File
@@ -2098,7 +2098,9 @@ const HOME_RECENT: usize = 6;
/// Height of one Home-screen recent-file row.
const HOME_RECENT_ROW: f32 = 30.0;
/// The Home screen's "Recent" list: file name, its folder on the right, click to open.
/// The Home screen's "Recent" list: file name, its folder on the right, click to open. The
/// hovered row shows a × that takes it off the list, and right-click offers Remove from Recent
/// and Clear Recent Files (#2691).
fn home_recent(app: &mut PhotocraftApp, ui: &mut egui::Ui, recent: &[String]) {
let t = crate::theme::Tokens::get(ui.ctx());
let width = 380.0;
@@ -2109,27 +2111,60 @@ fn home_recent(app: &mut PhotocraftApp, ui: &mut egui::Ui, recent: &[String]) {
});
ui.add_space(4.0);
let mut open = None;
let mut remove = None;
let mut clear = false;
for path in recent {
let name = crate::file_open::display_name(path);
let folder = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default();
let (row, resp) = ui.allocate_exact_size(egui::vec2(width, HOME_RECENT_ROW), Sense::click());
if resp.hovered() {
// The × sits on top of the row, so the row counts as hovered while the pointer is on it.
let hovered = ui.rect_contains_pointer(row);
if hovered {
ui.painter().rect_filled(row, t.radius_sm, t.hover);
}
let close = Rect::from_center_size(egui::pos2(row.right() - 16.0, row.center().y), egui::vec2(22.0, 22.0));
let right = if hovered { close.left() - 4.0 } else { row.right() - 10.0 };
let icon = Rect::from_center_size(egui::pos2(row.left() + 16.0, row.center().y), egui::vec2(16.0, 16.0));
crate::icons::paint(ui, icon, "file", 14.0, t.text_dim);
let x = row.left() + 32.0;
let name_g = crate::tab_strip::elided(ui, &name, egui::FontId::proportional(12.5), t.text, 170.0);
let name_w = name_g.size().x;
ui.painter().galley(egui::pos2(x, row.center().y - name_g.size().y / 2.0), name_g, t.text);
let folder_max = (row.right() - 10.0 - (x + name_w + 14.0)).max(0.0);
let folder_max = (right - (x + name_w + 14.0)).max(0.0);
if folder_max > 20.0 && !folder.is_empty() {
let fg = crate::tab_strip::elided(ui, &folder, egui::FontId::proportional(11.5), t.text_faint, folder_max);
ui.painter().galley(egui::pos2(row.right() - 10.0 - fg.size().x, row.center().y - fg.size().y / 2.0), fg, t.text_faint);
ui.painter().galley(egui::pos2(right - fg.size().x, row.center().y - fg.size().y / 2.0), fg, t.text_faint);
}
if resp.on_hover_text(path).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
let resp = resp.on_hover_text(path).on_hover_cursor(egui::CursorIcon::PointingHand);
if hovered {
let x = ui.interact(close, resp.id.with("remove"), Sense::click());
let label = crate::i18n::fmt(tl!("Remove {name} from Recent"), &[("name", &name)]);
x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label));
let color = crate::icons::button_chrome(ui, close, false, x.hovered());
crate::icons::paint(ui, close, "x", 12.0, color);
if x.on_hover_text(tl!("Remove from Recent")).clicked() {
remove = Some(path.clone());
}
}
if resp.clicked() && remove.is_none() {
open = Some(path.clone());
}
resp.context_menu(|ui| {
if ui.button(tl!("Remove from Recent")).clicked() {
remove = Some(path.clone());
ui.close();
}
if ui.button(tl!("Clear Recent Files")).clicked() {
clear = true;
ui.close();
}
});
}
if clear {
app.clear_recent();
} else if let Some(path) = remove {
app.remove_recent(&path);
}
if let Some(path) = open
&& let Err(e) = app.open_path(&path)
@@ -5038,6 +5073,55 @@ mod tests {
assert!(hint.ends_with(&format!("paste a copied image with {}.", crate::notices::paste_hint(&wayland))), "{hint}");
}
/// The Home screen's × takes a file off Recent without opening it, and its right-click menu
/// removes one entry or clears the list; the preferences keep the change (#2691).
#[test]
fn home_recent_entries_can_be_removed() {
use egui_kittest::kittest::Queryable;
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
for name in ["c.tif", "b.psd", "a.png"] {
app.push_recent(&format!("/tmp/photocraft-2691/{name}"));
}
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 760.0)).build_ui_state(
|ui, (app, ready): &mut (PhotocraftApp, bool)| {
if *ready {
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| document_area(app, ui));
}
},
(app, false),
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default());
h.state_mut().1 = true;
h.run_steps(3);
let listed = |h: &egui_kittest::Harness<'_, (PhotocraftApp, bool)>| {
let app = &h.state().0;
assert_eq!(app.session.prefs().file_handling.recent_files, app.ui.recent_files, "stored in the preferences");
app.ui.recent_files.iter().map(|p| crate::file_open::display_name(p)).collect::<Vec<_>>()
};
assert_eq!(listed(&h), ["a.png", "b.psd", "c.tif"]);
assert!(h.query_by_label("Remove b.psd from Recent").is_none(), "the × shows on the hovered row only");
h.get_by_label("b.psd").hover();
h.run_steps(2);
h.get_by_label("Remove b.psd from Recent").click();
h.run_steps(2);
assert_eq!(listed(&h), ["a.png", "c.tif"]);
assert!(h.state().0.session.documents().is_empty(), "the file was not opened");
h.get_by_label("c.tif").click_secondary();
h.run_steps(2);
h.get_by_label("Remove from Recent").click();
h.run_steps(2);
assert_eq!(listed(&h), ["a.png"]);
h.get_by_label("a.png").click_secondary();
h.run_steps(2);
h.get_by_label("Clear Recent Files").click();
h.run_steps(2);
assert!(listed(&h).is_empty());
assert!(h.state().0.session.documents().is_empty());
}
#[test]
fn pointer_moves_are_bounded_by_the_press_and_release() {
use egui::{Event, PointerButton, pos2};
+2
View File
@@ -47,6 +47,8 @@
Right Vpravo
Open Recent Otevřít nedávné
Clear Recent Files Vymazat nedávné soubory
Remove from Recent Odebrat z nedávných
Remove {name} from Recent Odebrat {name} z nedávných
Close Zavřít
Minimize Minimalizovat
Maximize Maximalizovat
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -43,6 +43,8 @@
Right Rechts
Open Recent Zuletzt geöffnete Dateien
Clear Recent Files Zuletzt geöffnete Dateien leeren
Remove from Recent Aus zuletzt geöffneten Dateien entfernen
Remove {name} from Recent {name} aus zuletzt geöffneten Dateien entfernen
Close Schließen
Minimize Minimieren
Maximize Maximieren
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -25,6 +25,8 @@
Right Δεξιά
Open Recent Πρόσφατα αρχεία
Clear Recent Files Εκκαθάριση πρόσφατων αρχείων
Remove from Recent Αφαίρεση από τα πρόσφατα
Remove {name} from Recent Αφαίρεση του {name} από τα πρόσφατα
Close Κλείσιμο
Minimize Ελαχιστοποίηση
Maximize Μεγιστοποίηση
1 # Greek (el) PhotoCraft interface catalog.
25
26
27
28
29
30
31
32
+2
View File
@@ -39,6 +39,8 @@
Right Derecha
Open Recent Archivos recientes
Clear Recent Files Vaciar archivos recientes
Remove from Recent Quitar de recientes
Remove {name} from Recent Quitar {name} de recientes
Close Cerrar
Minimize Minimizar
Maximize Maximizar
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -45,6 +45,8 @@
Right Droite
Open Recent Ouvrir un fichier récent
Clear Recent Files Effacer la liste des fichiers récents
Remove from Recent Retirer des fichiers récents
Remove {name} from Recent Retirer {name} des fichiers récents
Close Fermer
Minimize Réduire
Maximize Agrandir
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -38,6 +38,8 @@
Right Kanan
Open Recent Terakhir Dibuka
Clear Recent Files Hapus Daftar Berkas Terakhir
Remove from Recent Hapus dari Daftar Terakhir
Remove {name} from Recent Hapus {name} dari Daftar Terakhir
Close Tutup
Minimize Minimalkan
Maximize Maksimalkan
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -39,6 +39,8 @@
Right Destra
Open Recent Apri recenti
Clear Recent Files Cancella file recenti
Remove from Recent Rimuovi dai recenti
Remove {name} from Recent Rimuovi {name} dai recenti
Close Chiudi
Minimize Riduci a icona
Maximize Ingrandisci
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
39
40
41
42
43
44
45
46
+2
View File
@@ -43,6 +43,8 @@
Right 右
Open Recent 最近使ったファイルを開く
Clear Recent Files 最近使ったファイルを消去
Remove from Recent 最近使ったファイルから削除
Remove {name} from Recent {name} を最近使ったファイルから削除
Close 閉じる
Minimize 最小化
Maximize 最大化
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -31,6 +31,8 @@
Right 오른쪽
Open Recent 최근 파일 열기
Clear Recent Files 최근 파일 목록 지우기
Remove from Recent 최근 항목에서 제거
Remove {name} from Recent 최근 항목에서 {name} 제거
Close 닫기
Minimize 최소화
Maximize 최대화
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
31
32
33
34
35
36
37
38
+2
View File
@@ -35,6 +35,8 @@
Right Rechts
Open Recent Recent geopend
Clear Recent Files Recente bestanden wissen
Remove from Recent Verwijderen uit recente bestanden
Remove {name} from Recent {name} verwijderen uit recente bestanden
Close Sluiten
Minimize Minimaliseren
Maximize Maximaliseren
Can't render this file because it contains an unexpected character in line 8 and column 34.
+2
View File
@@ -42,6 +42,8 @@
Right Prawa
Open Recent Otwórz ostatnie
Clear Recent Files Wyczyść listę ostatnich plików
Remove from Recent Usuń z ostatnich plików
Remove {name} from Recent Usuń {name} z ostatnich plików
Close Zamknij
Minimize Minimalizuj
Maximize Maksymalizuj
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -42,6 +42,8 @@
Right Direita
Open Recent Abrir recentes
Clear Recent Files Limpar arquivos recentes
Remove from Recent Remover dos recentes
Remove {name} from Recent Remover {name} dos recentes
Close Fechar
Minimize Minimizar
Maximize Maximizar
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -49,6 +49,8 @@
Right Правый
Open Recent Открыть недавние
Clear Recent Files Очистить список недавних файлов
Remove from Recent Удалить из недавних
Remove {name} from Recent Удалить {name} из недавних
Close Закрыть
Minimize Свернуть
Maximize Развернуть
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -40,6 +40,8 @@
Right Правий
Open Recent Нещодавні файли
Clear Recent Files Очистити список нещодавніх файлів
Remove from Recent Вилучити з нещодавніх
Remove {name} from Recent Вилучити {name} з нещодавніх
Close Закрити
Minimize Згорнути
Maximize Розгорнути
1 # Ukrainian (uk) catalog. UTF-8: context<TAB>English source<TAB>translation.
40
41
42
43
44
45
46
47
+2
View File
@@ -39,6 +39,8 @@
Right 右
Open Recent 打开最近文件
Clear Recent Files 清除最近文件
Remove from Recent 从最近文件中移除
Remove {name} from Recent 从最近文件中移除 {name}
Close 关闭
Minimize 最小化
Maximize 最大化
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
39
40
41
42
43
44
45
46
+2
View File
@@ -47,6 +47,8 @@
Right 右
Open Recent 最近開啟的檔案
Clear Recent Files 清除最近檔案
Remove from Recent 從最近檔案中移除
Remove {name} from Recent 從最近檔案中移除 {name}
Close 關閉
Minimize 最小化
Maximize 最大化
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -955,6 +955,13 @@ impl PhotocraftApp {
self.session.prefs.edit(|p| p.file_handling.recent_files.clear());
}
/// Take one file off the recent list (the Home screen's × or Remove from Recent, #2691). The
/// file stays on disk, and opening it again lists it again.
pub fn remove_recent(&mut self, path: &str) {
self.ui.recent_files.retain(|p| p != path);
self.session.prefs.edit(|p| p.file_handling.recent_files.retain(|r| r != path));
}
/// How many recent files to remember ("Recent File List Contains", 0–100).
pub fn recent_cap(&self) -> usize {
self.session.prefs().file_handling.recent_file_count.min(100) as usize
+17 -1
View File
@@ -285,6 +285,11 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
app.clear_recent();
Ok(Value::Null)
}
"file.removeRecent" => {
let path = params.get("path").and_then(Value::as_str).ok_or("file.removeRecent needs a \"path\"")?;
app.remove_recent(path);
Ok(Value::Null)
}
id if id.starts_with("file.openRecent.") => {
#[cfg(not(target_arch = "wasm32"))]
{
@@ -599,7 +604,7 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool {
return e;
}
match id {
"file.open" | "file.exit" | "file.clearRecent" | "help.about" | "help.systemInfo" | "edit.search" => true,
"file.open" | "file.exit" | "file.clearRecent" | "file.removeRecent" | "help.about" | "help.systemInfo" | "edit.search" => true,
i if i.starts_with("file.openRecent.") => true,
i if crate::links::url_for(i).is_some() => true,
i if i.starts_with("window.theme.") => true,
@@ -1834,6 +1839,17 @@ mod open_recent_tests {
assert!(!menu_items(&app).iter().any(|i| i.id.starts_with("file.openRecent.")));
// A bad recent index errors gracefully (no panic).
assert!(invoke(&mut app, &ctx, "file.openRecent.5", json!({})).is_err());
// Remove one entry (the Home screen's ×, #2691): the others keep their order.
for p in ["/tmp/c.tif", "/tmp/b.psd", "/tmp/a.png"] {
app.push_recent(p);
}
invoke(&mut app, &ctx, "file.removeRecent", json!({"path": "/tmp/b.psd"})).unwrap();
assert_eq!(app.ui.recent_files, vec!["/tmp/a.png".to_string(), "/tmp/c.tif".to_string()]);
assert_eq!(app.session.prefs().file_handling.recent_files, app.ui.recent_files, "removed in the preferences too");
invoke(&mut app, &ctx, "file.removeRecent", json!({"path": "/tmp/not-listed.png"})).unwrap();
assert_eq!(app.ui.recent_files.len(), 2, "a path that isn't listed changes nothing");
assert!(invoke(&mut app, &ctx, "file.removeRecent", json!({})).is_err(), "needs a path");
}
#[cfg(not(target_arch = "wasm32"))]