icons/thumbnails view
This commit is contained in:
@@ -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
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user