icons/thumbnails view

This commit is contained in:
2026-06-15 20:53:20 -05:00
parent c492d7c0f9
commit 04fa19fdc5
5 changed files with 1118 additions and 50 deletions
+35 -2
View File
@@ -16,15 +16,18 @@ use std::path::PathBuf;
use librarian_core::{Sort, SortKey, SortOrder};
use crate::ViewMode;
const FILE_NAME: &str = "librarian.config";
const APP_DIR: &str = "Librarian";
/// The persisted, user-facing view preferences. Defaults match a fresh install:
/// hidden items off, name-ascending sort.
/// hidden items off, name-ascending sort, details view.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Settings {
pub show_hidden: bool,
pub sort: Sort,
pub view_mode: ViewMode,
}
/// Load saved settings, falling back to defaults if none are stored or the file
@@ -76,10 +79,11 @@ fn roaming_path() -> Option<PathBuf> {
fn serialize(settings: &Settings) -> String {
format!(
"show_hidden={}\nsort_key={}\nsort_order={}\n",
"show_hidden={}\nsort_key={}\nsort_order={}\nview_mode={}\n",
settings.show_hidden,
sort_key_str(settings.sort.key),
sort_order_str(settings.sort.order),
view_mode_str(settings.view_mode),
)
}
@@ -102,6 +106,11 @@ fn parse(text: &str) -> Settings {
settings.sort.order = order;
}
}
"view_mode" => {
if let Some(mode) = view_mode_from(value) {
settings.view_mode = mode;
}
}
_ => {}
}
}
@@ -142,6 +151,29 @@ fn sort_order_from(value: &str) -> Option<SortOrder> {
}
}
fn view_mode_str(mode: ViewMode) -> &'static str {
match mode {
ViewMode::Details => "details",
ViewMode::Tiny => "tiny",
ViewMode::Small => "small",
ViewMode::Medium => "medium",
ViewMode::Large => "large",
ViewMode::ExtraLarge => "xlarge",
}
}
fn view_mode_from(value: &str) -> Option<ViewMode> {
match value {
"details" => Some(ViewMode::Details),
"tiny" => Some(ViewMode::Tiny),
"small" => Some(ViewMode::Small),
"medium" => Some(ViewMode::Medium),
"large" => Some(ViewMode::Large),
"xlarge" => Some(ViewMode::ExtraLarge),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -155,6 +187,7 @@ mod tests {
order: SortOrder::Descending,
..Sort::default()
},
view_mode: ViewMode::ExtraLarge,
};
assert_eq!(parse(&serialize(&settings)), settings);
}
File diff suppressed because it is too large Load Diff
+189
View File
@@ -0,0 +1,189 @@
//! Thumbnail cache and background extraction for the icon-grid views.
//!
//! Distinct from the file list's 16px [`crate::icons`] cache: thumbnails are
//! larger, per-file (not shared by type), and keyed by size + modification time
//! so a resized view or a changed file fetches fresh pixels. Extraction runs on
//! the COM worker (see `librarian-win`); we hand back plain [`IconImage`] data
//! and build the Iced [`Handle`] on the UI thread.
//!
//! The cache is bounded with FIFO eviction. Because we only ever request the
//! visible window (plus a little overscan), the working set stays small; the
//! bound just caps memory when scrolling through a huge directory.
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf;
use std::time::SystemTime;
use iced::widget::image::Handle;
use librarian_win::{IconImage, ShellWorker, thumbnail};
/// How many decoded thumbnails to keep. Bounds memory (a 256px tile is ~256 KB,
/// so this caps the cache near ~130 MB worst-case) while comfortably covering
/// several screenfuls across the supported sizes.
const CAPACITY: usize = 512;
/// Identifies a specific thumbnail: a file at a pixel size, invalidated when the
/// file's modification time changes (so an edited file re-thumbnails on refresh).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ThumbKey {
pub path: PathBuf,
pub size: u16,
pub mtime: Option<SystemTime>,
}
#[derive(Default)]
pub struct ThumbCache {
handles: HashMap<ThumbKey, Handle>,
requested: HashSet<ThumbKey>,
/// Materialized keys in insertion order, for FIFO eviction past `CAPACITY`.
/// In-flight (requested-but-not-yet-inserted) keys are absent, so they're
/// never evicted out from under a pending extraction.
order: VecDeque<ThumbKey>,
}
impl ThumbCache {
pub fn get(&self, key: &ThumbKey) -> Option<&Handle> {
self.handles.get(key)
}
pub fn insert(&mut self, key: ThumbKey, image: IconImage) {
let handle = Handle::from_rgba(image.width, image.height, image.rgba);
if self.handles.insert(key.clone(), handle).is_none() {
self.order.push_back(key);
}
while self.order.len() > CAPACITY {
if let Some(evicted) = self.order.pop_front() {
self.handles.remove(&evicted);
// Allow a re-request if it scrolls back into view later.
self.requested.remove(&evicted);
}
}
}
/// From `keys`, return those not already cached or in-flight, marking them
/// in-flight so each is extracted only once.
pub fn take_unrequested(&mut self, keys: impl IntoIterator<Item = ThumbKey>) -> Vec<ThumbKey> {
let mut out = Vec::new();
for key in keys {
if !self.requested.contains(&key) && !self.handles.contains_key(&key) {
self.requested.insert(key.clone());
out.push(key);
}
}
out
}
/// Clear the in-flight mark for `keys` we chose not to extract (e.g. tiles
/// that scrolled off before their slow pass ran), so they're eligible to be
/// requested again if they return to view.
pub fn release(&mut self, keys: impl IntoIterator<Item = ThumbKey>) {
for key in keys {
self.requested.remove(&key);
}
}
}
/// The outcome of a cache-only sweep: thumbnails already in the OS cache
/// (`hits`, ready to display now) and the keys that weren't (`misses`, which need
/// a full extraction).
#[derive(Debug, Clone, Default)]
pub struct CacheSweep {
pub hits: Vec<(ThumbKey, IconImage)>,
pub misses: Vec<ThumbKey>,
}
/// Fast pass: ask the shell for each thumbnail *only if already cached*, doing no
/// slow extraction (see [`thumbnail`]'s `cache_only`). Partitions into ready hits
/// and the misses a later [`extract_full`] must extract.
pub fn extract_cached(worker: &ShellWorker, keys: Vec<ThumbKey>) -> CacheSweep {
let mut sweep = CacheSweep::default();
for key in keys {
let path = key.path.clone();
let size = key.size as u32;
match worker.run(move |apt| thumbnail(apt, &path, size, true)) {
Some(image) => sweep.hits.push((key, image)),
None => sweep.misses.push(key),
}
}
sweep
}
/// Slow pass: fully extract each thumbnail, generating it if needed. Run on the
/// dedicated thumbnail worker so a slow decode can't stall interactive shell ops.
pub fn extract_full(worker: &ShellWorker, keys: Vec<ThumbKey>) -> Vec<(ThumbKey, IconImage)> {
keys.into_iter()
.filter_map(|key| {
let path = key.path.clone();
let size = key.size as u32;
worker
.run(move |apt| thumbnail(apt, &path, size, false))
.map(|img| (key, img))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn key(name: &str, size: u16) -> ThumbKey {
ThumbKey {
path: PathBuf::from(name),
size,
mtime: None,
}
}
fn img() -> IconImage {
IconImage {
width: 1,
height: 1,
rgba: vec![0, 0, 0, 255],
}
}
#[test]
fn take_unrequested_dedups_against_in_flight_and_cached() {
let mut cache = ThumbCache::default();
let a = key("a", 48);
let b = key("b", 48);
// First pass: both are new and become in-flight.
let out = cache.take_unrequested([a.clone(), b.clone()]);
assert_eq!(out, vec![a.clone(), b.clone()]);
// Second pass: in-flight, so nothing re-requested.
assert!(cache.take_unrequested([a.clone(), b.clone()]).is_empty());
// Once one resolves, it's cached and still not re-requested.
cache.insert(a.clone(), img());
assert!(cache.get(&a).is_some());
assert!(cache.take_unrequested([a]).is_empty());
}
#[test]
fn release_allows_a_skipped_key_to_be_requested_again() {
let mut cache = ThumbCache::default();
let a = key("a", 48);
assert_eq!(cache.take_unrequested([a.clone()]), vec![a.clone()]);
// While in-flight it isn't re-requested...
assert!(cache.take_unrequested([a.clone()]).is_empty());
// ...but once released (we decided not to extract it), it's eligible again.
cache.release([a.clone()]);
assert_eq!(cache.take_unrequested([a.clone()]), vec![a]);
}
#[test]
fn fifo_eviction_caps_the_cache_and_allows_re_request() {
let mut cache = ThumbCache::default();
let first = key("file-0", 48);
for i in 0..CAPACITY + 1 {
cache.insert(key(&format!("file-{i}"), 48), img());
}
// The oldest entry was evicted to stay within the bound.
assert!(cache.get(&first).is_none());
// And an evicted key is eligible to be requested again.
assert_eq!(cache.take_unrequested([first.clone()]), vec![first]);
}
}
+149 -2
View File
@@ -20,6 +20,7 @@ use core::ffi::c_void;
use std::mem::size_of;
use std::path::Path;
use windows::Win32::Foundation::SIZE;
use windows::Win32::Graphics::Gdi::{
BITMAP, BITMAPINFO, BITMAPINFOHEADER, DIB_RGB_COLORS, DeleteObject, GetDC, GetDIBits,
GetObjectW, HBITMAP, HGDIOBJ, ReleaseDC,
@@ -29,8 +30,9 @@ use windows::Win32::Storage::FileSystem::{
};
use windows::Win32::System::Com::CoTaskMemFree;
use windows::Win32::UI::Shell::{
FOLDERID_ComputerFolder, SHFILEINFOW, SHGFI_FLAGS, SHGFI_ICON, SHGFI_LARGEICON, SHGFI_PIDL,
SHGFI_SMALLICON, SHGFI_USEFILEATTRIBUTES, SHGetFileInfoW, SHGetKnownFolderIDList,
FOLDERID_ComputerFolder, IShellItemImageFactory, SHCreateItemFromParsingName, SHFILEINFOW,
SHGFI_FLAGS, SHGFI_ICON, SHGFI_LARGEICON, SHGFI_PIDL, SHGFI_SMALLICON, SHGFI_USEFILEATTRIBUTES,
SHGetFileInfoW, SHGetKnownFolderIDList, SIIGBF_INCACHEONLY, SIIGBF_RESIZETOFIT,
};
use windows::Win32::UI::WindowsAndMessaging::{DestroyIcon, GetIconInfo, HICON, ICONINFO};
use windows::core::PCWSTR;
@@ -114,6 +116,100 @@ pub fn computer_icon(_apt: &Apartment, large: bool) -> Option<IconImage> {
}
}
/// A thumbnail (or, for items without one, the scaled shell icon) for `path`,
/// fit within a `size`×`size` box, as straight-alpha RGBA. This is the same data
/// Explorer's icon views show. `size` should be one of the Windows thumbnail
/// cache buckets (16/32/48/96/256) so the request hits the OS cache instead of
/// re-rasterizing.
///
/// When `cache_only` is set, `SIIGBF_INCACHEONLY` asks the shell to return the
/// image *only if it's already cached* and to do no extraction — so a miss comes
/// back fast as `None` rather than blocking the apartment on a slow decode. With
/// `cache_only` clear, `SIIGBF_RESIZETOFIT` (the default, value 0) asks for a
/// thumbnail and lets the shell fall back to the type icon when none exists —
/// exactly Explorer's behavior, but this may hit disk or invoke a provider.
///
/// Either way, call on a COM STA thread.
pub fn thumbnail(_apt: &Apartment, path: &Path, size: u32, cache_only: bool) -> Option<IconImage> {
let wide = to_wide(&path.to_string_lossy());
let flags = if cache_only {
SIIGBF_INCACHEONLY
} else {
SIIGBF_RESIZETOFIT
};
unsafe {
let factory: IShellItemImageFactory =
SHCreateItemFromParsingName(PCWSTR(wide.as_ptr()), None).ok()?;
let hbm = factory
.GetImage(
SIZE {
cx: size as i32,
cy: size as i32,
},
flags,
)
.ok()?;
let image = hbitmap_to_rgba(hbm);
_ = DeleteObject(HGDIOBJ(hbm.0));
image
}
}
/// Convert a standalone 32bpp `HBITMAP` (as returned by
/// `IShellItemImageFactory::GetImage`) to straight-alpha RGBA. The caller retains
/// ownership of `hbm` and must `DeleteObject` it.
///
/// `GetImage` has two alpha quirks we normalize here:
/// * opaque images often come back with an all-zero alpha channel (a plain DDB
/// with no meaningful alpha) — treat those as fully opaque, or the whole
/// image would render invisible;
/// * images that *do* carry transparency are premultiplied (PBGRA), which Iced
/// would composite with dark fringes — so un-premultiply the partial pixels.
fn hbitmap_to_rgba(hbm: HBITMAP) -> Option<IconImage> {
let mut bm = BITMAP::default();
let written = unsafe {
GetObjectW(
HGDIOBJ(hbm.0),
size_of::<BITMAP>() as i32,
Some(&mut bm as *mut _ as *mut c_void),
)
};
if written == 0 {
return None;
}
let (w, h) = (bm.bmWidth.max(0) as u32, bm.bmHeight.max(0) as u32);
if w == 0 || h == 0 {
return None;
}
let mut bgra = get_dibits(hbm, w, h)?;
if bgra.chunks_exact(4).all(|px| px[3] == 0) {
// No usable alpha: an opaque image returned without an alpha channel.
for px in bgra.chunks_exact_mut(4) {
px[3] = 255;
}
} else {
// Premultiplied -> straight alpha for the partially transparent pixels.
for px in bgra.chunks_exact_mut(4) {
let a = px[3] as u32;
if a > 0 && a < 255 {
for c in &mut px[0..3] {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
}
// BGRA -> RGBA.
for px in bgra.chunks_exact_mut(4) {
px.swap(0, 2);
}
Some(IconImage {
width: w,
height: h,
rgba: bgra,
})
}
fn icon_flags(large: bool, use_attributes: bool) -> SHGFI_FLAGS {
let size = if large {
SHGFI_LARGEICON
@@ -293,6 +389,57 @@ mod tests {
assert_eq!(icon.rgba.len(), (icon.width * icon.height * 4) as usize);
}
#[test]
fn extracts_a_thumbnail_for_a_file() {
// A plain text file has no real thumbnail, so the shell falls back to the
// type icon (via SIIGBF_RESIZETOFIT) — which still exercises the full
// GetImage -> HBITMAP -> RGBA path, including alpha normalization.
let mut path = std::env::temp_dir();
path.push(format!("librarian-thumb-{}.txt", std::process::id()));
std::fs::write(&path, b"hi").unwrap();
let image = worker()
.run({
let path = path.clone();
move |apt| thumbnail(apt, &path, 48, false)
})
.expect("a thumbnail or fallback icon should resolve");
assert!(image.width > 0 && image.height > 0);
assert!(
image.width <= 48 && image.height <= 48,
"fit within the box"
);
assert_eq!(image.rgba.len(), (image.width * image.height * 4) as usize);
// Normalized alpha means a visible image: not fully transparent.
assert!(image.rgba.chunks_exact(4).any(|px| px[3] != 0));
let _ = std::fs::remove_file(&path);
}
#[test]
fn cache_only_thumbnail_never_panics_and_is_valid_when_present() {
// A cache-only request must not extract: it returns `None` on a miss
// (the common case for a brand-new temp file) and a well-formed image on
// a hit. Either outcome is acceptable here — we're asserting the fast
// path is sound, not forcing a particular cache state.
let mut path = std::env::temp_dir();
path.push(format!("librarian-thumb-cache-{}.txt", std::process::id()));
std::fs::write(&path, b"hi").unwrap();
let result = worker().run({
let path = path.clone();
move |apt| thumbnail(apt, &path, 48, true)
});
if let Some(image) = result {
assert!(image.width > 0 && image.height > 0);
assert_eq!(image.rgba.len(), (image.width * image.height * 4) as usize);
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn extracts_the_computer_icon() {
let icon = worker()
+3 -1
View File
@@ -21,6 +21,8 @@ pub use chrome::apply_window_chrome;
pub use com::{Apartment, ShellWorker};
pub use drives::{DriveInfo, DriveKind, list_drives};
pub use fileop::{copy_items, create_folder, delete_to_recycle, move_items, rename};
pub use icon::{IconImage, computer_icon, folder_icon, icon_for_extension, icon_for_path};
pub use icon::{
IconImage, computer_icon, folder_icon, icon_for_extension, icon_for_path, thumbnail,
};
pub use known::{KnownFolder, known_folders, user_home};
pub use open::open_path;