From 209c0abb5dca7ee24474b8df8d95a029d3b9ef93 Mon Sep 17 00:00:00 2001 From: Diego Palacios <98249751+CodingDiego@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:39:41 -0300 Subject: [PATCH] FIX: rest of the #17 backlog - A clipboard change only re-evaluates Paste (it re-read every selected item's attributes: 64 ms per change with 20k selected, now 1.6 ms). - Pin/Unpin runs on a worker, one at a time (Quick Access can hold network folders; the window stalled 300 ms, now under 3 ms). - As-you-type search starts at 2 characters; Enter searches any text. - A change notification no longer restarts an armed change timer, so a folder written every few ms still reloads about once a second. - When the first session's icon export lands, cached misses and every tab's row icons are dropped, so new types get their icons without a restart. - The volume index is built per drive outside the cache lock; same-drive searches share one build. - Refresh reselects exact names first (a case-sensitive folder can hold a.txt and A.txt), falling back to a case-insensitive match. - After a sort the Shift+click anchor follows the focused item, which is scrolled into view; with every row selected only the focus moves (227 ms -> 20 ms on 20k rows). Co-Authored-By: Claude Opus 5.5 --- crates/app/src/commands.rs | 22 +++++++++---- crates/app/src/list.rs | 17 ++++++++-- crates/app/src/main.rs | 33 ++++++++++++++++---- crates/app/src/nav.rs | 45 ++++++++++++++++++++------- crates/app/src/panes.rs | 32 +++++++++++-------- crates/app/src/source.rs | 22 +++++++------ crates/app/src/views.rs | 64 +++++++++++++++++++++++++++----------- crates/core/src/icons.rs | 21 +++++++++++++ 8 files changed, 190 insertions(+), 66 deletions(-) diff --git a/crates/app/src/commands.rs b/crates/app/src/commands.rs index 85212d4..c93b249 100644 --- a/crates/app/src/commands.rs +++ b/crates/app/src/commands.rs @@ -42,6 +42,20 @@ pub(crate) unsafe fn clipboard_has_files() -> bool { IsClipboardFormatAvailable(15).is_ok() || (idlist != 0 && IsClipboardFormatAvailable(idlist).is_ok()) } +/// Whether the active tab is a shell listing that has finished loading (the only place Paste can act). +unsafe fn shell_ready(app: *mut App) -> bool { + let tab = cur(app); + tab.loading.is_none() && matches!(tab.source, Some(Source::Shell(_))) +} + +/// Paste's enabled state alone, for a clipboard change: skips the selection and attribute reads of `update`. +pub(crate) unsafe fn update_paste(app: *mut App) { + if (*app).tabs.is_empty() { + return; + } + toolbar::set_enabled((*app).cmdbar, CMD_PASTE, shell_ready(app) && clipboard_has_files()); +} + /// Enabled states for the active tab: nothing while it loads; selection verbs in a shell listing (Copy and Delete /// in search results too, which act on each file's own folder); Paste with files on the clipboard; New and Sort /// not on the Home page. @@ -51,12 +65,8 @@ pub(crate) unsafe fn update(app: *mut App) { return; } let tab = cur(app); - let ready = tab.loading.is_none(); - let (shell, search) = match &tab.source { - Some(Source::Shell(_)) => (ready, false), - Some(Source::Search(_)) => (false, ready), - _ => (false, false), - }; + let search = tab.loading.is_none() && matches!(tab.source, Some(Source::Search(_))); + let shell = shell_ready(app); let home = IsWindowVisible(a.home).as_bool(); let sel = SendMessageW(a.list, LVM_GETSELECTEDCOUNT, None, None).0 as usize; let bar = a.cmdbar; diff --git a/crates/app/src/list.rs b/crates/app/src/list.rs index 7110254..c74a582 100644 --- a/crates/app/src/list.rs +++ b/crates/app/src/list.rs @@ -135,23 +135,34 @@ pub(crate) unsafe fn sort_by(app: *mut App, col: u32, desc: bool) { // items but not their PIDLs, so a PIDL's address is the item's identity (exact, unlike a case-folded name). let focus = SendMessageW(list, LVM_GETNEXTITEM, Some(WPARAM(usize::MAX)), Some(LPARAM(LVNI_FOCUSED as isize))).0; let key = |i: usize| l.items.get(i).map(|it| it.pidl.as_ptr() as usize); - let selected: std::collections::HashSet = selected_indices(list).into_iter().filter_map(key).collect(); + // Ctrl+A then sort: every row stays selected whatever the order, so only the focus moves (collecting and + // re-setting each row costs 0.15 s per 20k rows). + let all = SendMessageW(list, LVM_GETSELECTEDCOUNT, None, None).0 as usize == l.items.len(); + let selected: std::collections::HashSet = if all { Default::default() } else { selected_indices(list).into_iter().filter_map(key).collect() }; let focused = usize::try_from(focus).ok().and_then(key); l.sort_col = col; l.sort_desc = desc; sort_listing(l); // Icon views keep images per row position: drop them, or thumbnails stay where their files were. views::reset(app, l.items.len()); - set_item_state(list, usize::MAX, LIST_VIEW_ITEM_STATE_FLAGS(0), LIST_VIEW_ITEM_STATE_FLAGS(LVIS_SELECTED.0 | LVIS_FOCUSED.0)); + let mask = if all { LVIS_FOCUSED.0 } else { LVIS_SELECTED.0 | LVIS_FOCUSED.0 }; + set_item_state(list, usize::MAX, LIST_VIEW_ITEM_STATE_FLAGS(0), LIST_VIEW_ITEM_STATE_FLAGS(mask)); + let mut row = None; for (i, it) in l.items.iter().enumerate() { let p = it.pidl.as_ptr() as usize; - if selected.contains(&p) { + if !all && selected.contains(&p) { set_item_state(list, i, LVIS_SELECTED, LVIS_SELECTED); } if focused == Some(p) { set_item_state(list, i, LVIS_FOCUSED, LVIS_FOCUSED); + row = Some(i); } } + // The Shift+click anchor follows the focused item to its new row, and that row stays in view. + if let Some(i) = row { + SendMessageW(list, LVM_SETSELECTIONMARK, None, Some(LPARAM(i as isize))); + SendMessageW(list, LVM_ENSUREVISIBLE, Some(WPARAM(i)), Some(LPARAM(0))); + } let _ = InvalidateRect(Some(list), None, true); } diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 2c20135..d4b0000 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -220,6 +220,9 @@ pub(crate) struct App { change_gate: session::Debounce, /// A change arrived while minimized: reload once when the window is restored. stale: bool, + /// TIMER_CHANGE is armed: a notification does not restart it (SetTimer would), so a folder written every few + /// milliseconds still reloads. + change_armed: bool, recent: Recent, panes: panes::Panes, views: views::Views, @@ -375,6 +378,7 @@ fn main() -> Result<()> { debounce: session::Debounce::new(Duration::from_secs(2)), change_gate: session::Debounce::new(Duration::from_secs(1)), stale: false, + change_armed: false, recent: Recent::load(dir.join("recent.txt")), panes: panes::Panes::default(), browser: None, @@ -494,7 +498,7 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: WM_SIZE => { ui::layout(app); if wparam.0 as u32 != SIZE_MINIMIZED && std::mem::take(&mut (*app).stale) { - SetTimer(Some(hwnd), TIMER_CHANGE, 1, None); + arm_change(app, 1); } } WM_PAINT => ui::paint(app), @@ -516,7 +520,7 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: WM_SETFOCUS => { let _ = SetFocus(Some(source::browser_hwnd(app).unwrap_or_else(|| ui::view_hwnd(app)))); } - WM_CLIPBOARDUPDATE => commands::update(app), + WM_CLIPBOARDUPDATE => commands::update_paste(app), WM_NOTIFY => return on_notify(app, lparam), WM_COMMAND => { let (id, code) = (wparam.0 & 0xFFFF, (wparam.0 >> 16) & 0xFFFF); @@ -566,7 +570,15 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: source::WM_WORKER_ERROR => message(hwnd, &Box::from_raw(lparam.0 as *mut String)), panes::WM_SIDEBAR_ROWS => panes::on_sidebar_rows(app, Box::from_raw(lparam.0 as *mut panes::SidebarRows)), panes::WM_TREE_SELECT => panes::on_tree_select(app, Box::from_raw(lparam.0 as *mut Pidl)), + panes::WM_QUICK_ACCESS_DONE => { + if wparam.0 == 1 { + panes::sidebar_changed(app); + } else { + let _ = MessageBeep(MB_OK); + } + } views::WM_THUMB_DONE => views::on_thumb_done(app, wparam, lparam), + views::WM_ICONS_EXPORTED => views::on_icons_exported(app), WM_SETCURSOR => { if HWND(wparam.0 as *mut c_void) == hwnd && (lparam.0 & 0xFFFF) as u32 == HTCLIENT && panes::on_set_cursor(app) { return LRESULT(1); @@ -581,13 +593,16 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: if nav::mark_changed(app, &ChangeWatch::acknowledge(wparam, lparam)) { if IsIconic(hwnd).as_bool() { (*app).stale = true; - } else { - SetTimer(Some(hwnd), TIMER_CHANGE, 150, None); + } else if !(*app).change_armed { + arm_change(app, 150); } } } WM_TIMER => { let _ = KillTimer(Some(hwnd), wparam.0); + if wparam.0 == TIMER_CHANGE { + (*app).change_armed = false; + } match wparam.0 { TIMER_REFRESH => nav::refresh(app), // Armed for the tab and folder active then: a clean one shown since has nothing to catch up on. @@ -596,13 +611,13 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: let (now, tab) = (Instant::now(), nav::cur(app)); // One change reload at a time (F5 and the rest still restart one): a change during it reloads once more after. if tab.pending != 0 && tab.loading.is_none() { - SetTimer(Some(hwnd), TIMER_CHANGE, 500, None); + arm_change(app, 500); } else if (*app).change_gate.should_save(now) { // Covers an in-app rename or new item whose own follow-up reload is still pending. let _ = KillTimer(Some(hwnd), TIMER_REFRESH); nav::refresh(app); } else { - SetTimer(Some(hwnd), TIMER_CHANGE, (*app).change_gate.remaining(now).as_millis() as u32, None); + arm_change(app, (*app).change_gate.remaining(now).as_millis() as u32); } } TIMER_STATUS => ui::update_status(app), @@ -666,6 +681,12 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LRESULT(0) } +/// Arms TIMER_CHANGE for `ms` and records it, so a notification can tell it is already pending. +unsafe fn arm_change(app: *mut App, ms: u32) { + (*app).change_armed = true; + SetTimer(Some((*app).hwnd), TIMER_CHANGE, ms, None); +} + unsafe fn on_notify(app: *mut App, lparam: LPARAM) -> LRESULT { let hdr = &*(lparam.0 as *const NMHDR); let from = hdr.hwndFrom; diff --git a/crates/app/src/nav.rs b/crates/app/src/nav.rs index 089fee9..8f05a08 100644 --- a/crates/app/src/nav.rs +++ b/crates/app/src/nav.rs @@ -490,16 +490,33 @@ unsafe fn show_source(app: *mut App, mode: Show) { update_chrome(app); } -/// Indices of the items whose parsing name is one of `names` (ASCII case-insensitive), stopping at the last match. -fn selection_matches(names: &[String], parsing: impl Iterator) -> Vec { - let mut wanted: std::collections::HashSet = names.iter().map(|n| n.to_ascii_lowercase()).collect(); - let mut parsing = parsing.enumerate(); +/// Indices of the items whose parsing name is one of `names`, stopping at the last match. Exact matches come +/// first (a case-sensitive folder can hold `a.txt` and `A.txt`); a name with none falls back to an ASCII +/// case-insensitive match on an item not already hit, so a rename that only changed case still reselects. +fn selection_matches(names: &[String], mut parsing: impl Iterator) -> Vec { + let mut wanted: std::collections::HashSet<&str> = names.iter().map(String::as_str).collect(); + let mut seen = Vec::new(); // kept for the fallback pass: parsing a name is a shell call let mut hits = Vec::new(); while !wanted.is_empty() { - let Some((i, p)) = parsing.next() else { break }; - if wanted.remove(&p.to_ascii_lowercase()) { - hits.push(i); + let Some(p) = parsing.next() else { break }; + if wanted.remove(p.as_str()) { + hits.push(seen.len()); } + seen.push(p); + } + if !wanted.is_empty() { + // The walk ran out of items, so `seen` is all of them. + let mut loose: std::collections::HashSet = wanted.iter().map(|n| n.to_ascii_lowercase()).collect(); + let exact: std::collections::HashSet = hits.iter().copied().collect(); + for (i, p) in seen.iter().enumerate() { + if loose.is_empty() { + break; + } + if !exact.contains(&i) && loose.remove(&p.to_ascii_lowercase()) { + hits.push(i); + } + } + hits.sort_unstable(); } hits } @@ -636,6 +653,7 @@ pub(crate) unsafe fn refresh(app: *mut App) { }; // This reload covers the changes notified so far. let _ = KillTimer(Some((*app).hwnd), crate::TIMER_CHANGE); + (*app).change_armed = false; go(app, target, History::Refresh); } @@ -988,13 +1006,18 @@ mod tests { } #[test] - fn selection_matches_ascii_case_insensitively_and_stops_at_the_last() { - let names = ["C:\\A.txt".to_string(), "c:\\B.TXT".to_string()]; - let parsing = ["c:\\a.TXT", "c:\\x", "C:\\b.txt", "never"].into_iter().enumerate().map(|(i, p)| { - assert!(i < 3, "read past the last match"); + fn selection_matches_exact_first_then_ascii_case_insensitively() { + let s = |v: &[&str]| v.iter().map(|p| p.to_string()).collect::>(); + let names = s(&["C:\\a.txt", "c:\\b.txt"]); + let parsing = ["C:\\a.txt", "c:\\x", "c:\\b.txt", "never"].into_iter().enumerate().map(|(i, p)| { + assert!(i < 3, "read past the last exact match"); p.to_string() }); assert_eq!(selection_matches(&names, parsing), [0, 2]); + assert_eq!(selection_matches(&s(&["C:\\a.txt"]), s(&["C:\\A.txt", "C:\\a.txt"]).into_iter()), [1], "the exact one wins in a case-sensitive folder"); + assert_eq!(selection_matches(&s(&["C:\\a.txt"]), s(&["C:\\A.txt"]).into_iter()), [0], "a rename that only changed case still reselects"); + assert_eq!(selection_matches(&s(&["C:\\a.txt", "C:\\A.txt"]), s(&["C:\\a.txt"]).into_iter()), [0], "an item already hit is not taken twice"); + assert_eq!(selection_matches(&s(&["C:\\A.txt", "c:\\B.TXT"]), s(&["c:\\a.TXT", "c:\\x", "C:\\b.txt"]).into_iter()), [0, 2]); assert!(selection_matches(&["É".to_string()], ["é".to_string()].into_iter()).is_empty(), "ASCII only, as eq_ignore_ascii_case"); assert!(selection_matches(&[], std::iter::once_with(|| -> String { panic!("no names, nothing to parse") })).is_empty()); } diff --git a/crates/app/src/panes.rs b/crates/app/src/panes.rs index d7917a5..4038191 100644 --- a/crates/app/src/panes.rs +++ b/crates/app/src/panes.rs @@ -19,6 +19,8 @@ use windows::core::{GUID, Interface}; pub(crate) const WM_TREE_SELECT: u32 = WM_APP + 3; /// lparam = Box built by the sidebar worker. pub(crate) const WM_SIDEBAR_ROWS: u32 = WM_APP + 8; +/// wparam = 1 when the shell ran the pin or unpin verb, 0 when it refused it. +pub(crate) const WM_QUICK_ACCESS_DONE: u32 = WM_APP + 9; /// Gap between the side panes and the cards, and the cards' margin from the window edges; it is the splitter. const SPLIT: i32 = 8; const MIN_WIDTH: i32 = 120; @@ -347,20 +349,24 @@ pub(crate) unsafe fn sidebar_menu(app: *mut App, index: usize) { } /// A canonical verb ("unpinfromhome", "pintohome") on the Quick Access item for `target`; beeps when the shell -/// refuses it (the item left Quick Access meanwhile). +/// refuses it (the item left Quick Access meanwhile). On a worker: Quick Access can hold network folders, and a +/// shell context menu needs an STA. One at a time: two quick unpins racing on the shell's Quick Access list lose one. unsafe fn quick_access_verb(app: *mut App, target: &str, verb: &str) { - let hwnd = (*app).hwnd; - let done = (|| { - let qa = ShellFolder::parse(&format!("shell:{}", nav::QUICK_ACCESS)).ok()?; - let items = qa.enumerate().ok()?; - let item = items.iter().find(|i| i.parsing_name().eq_ignore_ascii_case(target))?; - ContextMenu::for_items(&qa, &[item], hwnd).ok()?.invoke_verb(hwnd, verb, false).ok() - })(); - if done.is_some() { - sidebar_changed(app); - } else { - let _ = MessageBeep(MB_OK); - } + static QUICK_ACCESS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let hwnd = (*app).hwnd.0 as isize; + let (target, verb) = (target.to_string(), verb.to_string()); + std::thread::spawn(move || { + let _one = QUICK_ACCESS.lock().unwrap_or_else(|e| e.into_inner()); + let _com = ComInit::sta(); + let hwnd = HWND(hwnd as *mut c_void); + let done = (|| { + let qa = ShellFolder::parse(&format!("shell:{}", nav::QUICK_ACCESS)).ok()?; + let items = qa.enumerate().ok()?; + let item = items.iter().find(|i| i.parsing_name().eq_ignore_ascii_case(&target))?; + ContextMenu::for_items(&qa, &[item], hwnd).ok()?.invoke_verb(hwnd, &verb, false).ok() + })(); + unsafe { PostMessageW(Some(hwnd), WM_QUICK_ACCESS_DONE, WPARAM(done.is_some() as usize), LPARAM(0)) }.ok(); + }); } fn small_icon(pidl: &Pidl) -> Option { diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs index bff063d..e047736 100644 --- a/crates/app/src/source.rs +++ b/crates/app/src/source.rs @@ -29,7 +29,9 @@ pub(crate) const WM_WORKER_ERROR: u32 = WM_APP + 6; /// Most results the volume index returns per search. const USN_LIMIT: usize = 5000; -pub(crate) type UsnCache = Arc>>>; +/// Volume indexes per drive letter. Each drive has its own slot, so a build (seconds) stalls only searches of that +/// drive, which then share its index instead of building their own. +pub(crate) type UsnCache = Arc>>>>>>; pub(crate) enum Source { Shell(Listing), @@ -537,15 +539,13 @@ pub(crate) unsafe fn start_search(app: *mut App, t: usize, needle: &str, push: b /// Elevated search of a whole NTFS volume through its MFT index (built once per drive, kept for the session). fn usn_search(cache: &UsnCache, drive: char, needle: &str, cancel: &AtomicBool, sink: &mut dyn FnMut(Vec)) -> Result<()> { + let slot = cache.lock().unwrap_or_else(|e| e.into_inner()).entry(drive).or_default().clone(); let index = { - let mut m = cache.lock().unwrap_or_else(|e| e.into_inner()); - match m.get(&drive) { + let mut slot = slot.lock().unwrap_or_else(|e| e.into_inner()); + match &*slot { Some(i) => i.clone(), - None => { - let i = Arc::new(usn::build(drive)?); - m.insert(drive, i.clone()); - i - } + None if cancel.load(Ordering::Relaxed) => return Ok(()), + None => slot.insert(Arc::new(usn::build(drive)?)).clone(), } }; // A superseded search must not scan a whole index it no longer needs. @@ -656,6 +656,10 @@ pub(crate) unsafe fn cancel_search(app: *mut App) -> bool { } } +/// As-you-type searches start at this many characters; Enter searches any non-empty text. Below it a showing search +/// stays: going back to the folder would rewrite the box under the user's typing. +const MIN_TYPED: usize = 2; + /// TIMER_SEARCH: the box text settled. The box also changes when a tab shows its own state; that matches the /// active search (or is empty with none), so only a real edit gets through. pub(crate) unsafe fn on_search_timer(app: *mut App) { @@ -671,7 +675,7 @@ pub(crate) unsafe fn on_search_timer(app: *mut App) { true => nav::go_up(app), } } - } else if !same && matches!(cur(app).source, Some(Source::Shell(_) | Source::Search(_))) { + } else if !same && text.chars().count() >= MIN_TYPED && matches!(cur(app).source, Some(Source::Shell(_) | Source::Search(_))) { start_search(app, active, text, !searching); } } diff --git a/crates/app/src/views.rs b/crates/app/src/views.rs index 0fa6cc1..1abe7ac 100644 --- a/crates/app/src/views.rs +++ b/crates/app/src/views.rs @@ -13,6 +13,8 @@ use windows::Win32::Graphics::Gdi::{ }; pub(crate) const WM_THUMB_DONE: u32 = WM_APP + 7; +/// The system-icon export into the default icon theme has finished. +pub(crate) const WM_ICONS_EXPORTED: u32 = WM_APP + 10; #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) enum View { @@ -178,6 +180,42 @@ pub(crate) unsafe fn init(app: *mut App) { } } apply(app); + start_icon_export(a.hwnd, &a.theme); +} + +/// While the theme uses `icon_theme=default`, exports Windows' own icon for every registered extension in the +/// background (seconds), writes `system-icons.txt` (which lists them and marks the export done; delete it to export +/// again, say after installing a program) and posts WM_ICONS_EXPORTED. Here, not before the window exists, so it has +/// somewhere to post. +fn start_icon_export(hwnd: HWND, t: &Theme) { + let dir = settings::dir().join("icons").join("default"); + let done = dir.join("system-icons.txt"); + if t.icon_theme.as_deref() != Some("default") || !dir.is_dir() || done.exists() { + return; + } + let hwnd = hwnd.0 as isize; + std::thread::spawn(move || { + let _com = rexplorer_core::com::ComInit::mta(); + let exts = icons::export_system_icons(&dir); + let _ = std::fs::write(done, exts.join("\n")); + let _ = unsafe { PostMessageW(Some(HWND(hwnd as *mut c_void)), WM_ICONS_EXPORTED, WPARAM(0), LPARAM(0)) }; + }); +} + +/// The export wrote PNGs for types the theme had already cached as missing, and rows keep the generic icon they +/// got: forget the misses and every tab's per-row icons (Shell, Archive, Search); rows ask again as they paint. +pub(crate) unsafe fn on_icons_exported(app: *mut App) { + let Some(t) = (*app).views.icon_theme.as_mut() else { return }; + t.theme.forget_missing(); + for t in (&mut (*app).tabs).iter_mut() { + match &mut t.source { + Some(Source::Shell(l)) => l.icons.fill(ICON_UNKNOWN), + Some(Source::Archive(a)) => a.icons.fill(ICON_UNKNOWN), + Some(Source::Search(s)) => s.icons.fill(ICON_UNKNOWN), + _ => {} + } + } + let _ = InvalidateRect(Some((*app).list), None, false); } /// None when the folder is missing or has neither `folder.png` nor `file.png`: the system icons stay. @@ -574,29 +612,19 @@ pub(crate) fn try_save_profile_keys(profile: &str, pairs: &[(&str, String)]) -> const STARTER: [&str; 2] = ["folder", "file"]; -/// Writes `icons\default\`: `folder.png` and `file.png` once (when the folder does not exist yet), then, in the -/// background and only while the theme uses `icon_theme=default`, Windows' own icon for every registered extension; -/// `system-icons.txt` lists those and marks the export done (delete it to export again, say after installing a program). +/// Writes `icons\default\folder.png` and `file.png` once (when the folder does not exist yet); the export of +/// Windows' own icons starts in `init`, once the window exists. pub(crate) fn ensure_starter_theme(t: &Theme) { let dir = settings::dir().join("icons").join("default"); - if !dir.exists() { - if std::fs::create_dir_all(&dir).is_err() { - return; - } - for name in STARTER { - let _ = icons::save_png(&starter_icon(name, t), 32, 32, &dir.join(format!("{name}.png"))); - } + if dir.exists() { + return; } - // The export only feeds an enabled default theme. - let done = dir.join("system-icons.txt"); - if t.icon_theme.as_deref() != Some("default") || done.exists() { + if std::fs::create_dir_all(&dir).is_err() { return; } - std::thread::spawn(move || { - let _com = rexplorer_core::com::ComInit::mta(); - let exts = icons::export_system_icons(&dir); - let _ = std::fs::write(done, exts.join("\n")); - }); + for name in STARTER { + let _ = icons::save_png(&starter_icon(name, t), 32, 32, &dir.join(format!("{name}.png"))); + } } /// 32x32 straight-alpha BGRA: a folder silhouette in the accent, or a page tinted toward it. diff --git a/crates/core/src/icons.rs b/crates/core/src/icons.rs index bb081fa..090cc71 100644 --- a/crates/core/src/icons.rs +++ b/crates/core/src/icons.rs @@ -362,6 +362,11 @@ impl IconTheme { ext.and_then(|e| self.get(&e)).or_else(|| self.get(if is_folder { "folder" } else { "file" })) } + /// Drops the remembered misses, so a PNG written since is found on the next lookup. + pub fn forget_missing(&mut self) { + self.bitmaps.retain(|_, h| h.is_some()); + } + fn get(&mut self, key: &str) -> Option { if let Some(h) = self.bitmaps.get(key) { return *h; @@ -444,6 +449,22 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + #[test] + fn forgetting_misses_finds_a_png_written_since() { + let _com = ComInit::sta(); + let dir = tmp("forget"); + save_png(&blue(8, 8), 8, 8, &dir.join("folder.png")).unwrap(); + let mut t = load_theme(&dir, 16).unwrap(); + assert!(t.get("zzz").is_none()); + save_png(&blue(8, 8), 8, 8, &dir.join("zzz.png")).unwrap(); + assert!(t.get("zzz").is_none(), "the miss is cached"); + t.forget_missing(); + assert!(t.get("zzz").is_some()); + assert!(t.get("folder").is_some(), "loaded bitmaps are kept"); + drop(t); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn registry_lists_txt() { assert!(registered_extensions().iter().any(|e| e == "txt"));