Skip to content

Commit 51d480f

Browse files
committed
perf: add preview image cache for screenshots
Disclosure: I developed this code utilizing LSPs and LLMs. All changes have been manually reviewed by me.
1 parent 4ffc666 commit 51d480f

File tree

5 files changed

+301
-24
lines changed

5 files changed

+301
-24
lines changed

src/main.rs

Lines changed: 143 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,15 @@ mod priority;
7373

7474
mod stats;
7575

76+
mod preview_cache;
77+
7678
use explore::ExplorePage;
7779
mod explore;
7880

7981
use nav::{Category, CategoryIndex, NavPage, ScrollContext};
8082
mod nav;
8183

82-
use search::{CachedExploreResults, SearchResult};
84+
use search::{CachedExploreResults, GridMetrics, SearchResult};
8385
mod search;
8486

8587
mod view;
@@ -305,6 +307,7 @@ pub enum Message {
305307
OpenDesktopId(String),
306308
Operation(OperationKind, BackendName, AppId, Arc<AppInfo>),
307309
PeriodicUpdateCheck,
310+
PreviewTick,
308311
PendingComplete(u64),
309312
PendingDismiss,
310313
PendingError(u64, String),
@@ -1089,6 +1092,7 @@ impl App {
10891092
backend_name,
10901093
info.source_id
10911094
);
1095+
10921096
let sources = self.selected_sources(backend_name, &id, &info);
10931097
let addons = self.selected_addons(backend_name, &id, &info);
10941098
self.selected_opt = Some(Selected {
@@ -1132,6 +1136,81 @@ impl App {
11321136
)
11331137
}
11341138

1139+
/// Queue preview images for first N items
1140+
fn queue_previews(results: &[SearchResult], count: usize) {
1141+
for result in results.iter().take(count) {
1142+
if let Some(screenshot) = result.info.screenshots.first() {
1143+
preview_cache::queue(&screenshot.url);
1144+
}
1145+
}
1146+
}
1147+
1148+
/// Calculate max results per explore section based on grid columns
1149+
fn explore_section_max_results(cols: usize) -> usize {
1150+
match cols {
1151+
1 => 4,
1152+
2 => 8,
1153+
3 => 9,
1154+
_ => cols * 2,
1155+
}
1156+
}
1157+
1158+
/// Calculate grid width from window size
1159+
fn grid_width(&self, spacing: &cosmic_theme::Spacing) -> usize {
1160+
self.size
1161+
.get()
1162+
.map(|s| (s.width - 2.0 * spacing.space_s as f32).floor().max(0.0) as usize)
1163+
.unwrap_or(800)
1164+
}
1165+
1166+
/// Queue preview images for explore page sections
1167+
fn queue_explore_previews(&self) {
1168+
let spacing = theme::active().cosmic().spacing;
1169+
let GridMetrics { cols, .. } =
1170+
SearchResult::grid_metrics(&spacing, self.grid_width(&spacing));
1171+
1172+
for results in self.explore_results.values() {
1173+
Self::queue_previews(results, Self::explore_section_max_results(cols));
1174+
}
1175+
}
1176+
1177+
/// Queue preview images for items visible in the scroll viewport
1178+
fn queue_visible_previews(&self, viewport: Option<&scrollable::Viewport>) {
1179+
let Some(results) = self.current_results() else {
1180+
return;
1181+
};
1182+
1183+
let spacing = theme::active().cosmic().spacing;
1184+
let GridMetrics { cols, .. } =
1185+
SearchResult::grid_metrics(&spacing, self.grid_width(&spacing));
1186+
let row_height = SearchResult::card_height(&spacing) + spacing.space_xxs as f32;
1187+
1188+
let (first_item, count) = match viewport {
1189+
Some(vp) => {
1190+
let first_row = (vp.absolute_offset().y / row_height).floor() as usize;
1191+
let visible_rows = (vp.bounds().height / row_height).ceil() as usize + 2;
1192+
(first_row * cols, visible_rows * cols)
1193+
}
1194+
None => (0, cols * 4), // Initial load: first ~4 rows
1195+
};
1196+
1197+
Self::queue_previews(&results[first_item.min(results.len())..], count);
1198+
}
1199+
1200+
/// Get the current results being displayed based on app state
1201+
fn current_results(&self) -> Option<&[SearchResult]> {
1202+
match (
1203+
&self.search_results,
1204+
self.explore_page_opt,
1205+
&self.category_results,
1206+
) {
1207+
(Some((_, r)), _, _) => Some(r),
1208+
(None, Some(page), _) => self.explore_results.get(&page).map(|r| r.as_slice()),
1209+
(None, None, Some((_, r))) => Some(r),
1210+
_ => None,
1211+
}
1212+
}
1213+
11351214
fn update_backends(&mut self, refresh: bool) -> Task<Message> {
11361215
let locale = self.locale.clone();
11371216
Task::perform(
@@ -2082,6 +2161,8 @@ impl Application for App {
20822161
let cache_start = Instant::now();
20832162
if let Some(cached) = CachedExploreResults::load() {
20842163
app.explore_results = cached.to_results();
2164+
// Queue preview images for all explore sections
2165+
app.queue_explore_previews();
20852166
log::info!(
20862167
"explore page loaded from cache: {} categories in {:?}",
20872168
app.explore_results.len(),
@@ -2102,7 +2183,19 @@ impl Application for App {
21022183
}
21032184
}
21042185

2105-
let command = Task::batch([app.update_title(), app.update_backends(true)]);
2186+
let command = Task::batch([
2187+
app.update_title(),
2188+
app.update_backends(true),
2189+
// Run one-time preview cache cleanup in background
2190+
Task::perform(
2191+
async {
2192+
tokio::task::spawn_blocking(preview_cache::enforce_size_limit)
2193+
.await
2194+
.ok();
2195+
},
2196+
|()| action::none(),
2197+
),
2198+
]);
21062199
(app, command)
21072200
}
21082201

@@ -2653,6 +2746,12 @@ impl Application for App {
26532746
.push(cosmic::iced::time::every(duration).map(|_| Message::PeriodicUpdateCheck));
26542747
}
26552748

2749+
// Periodically queue preview images for visible items (catches scrollbar drag, etc.)
2750+
subscriptions.push(
2751+
cosmic::iced::time::every(std::time::Duration::from_millis(250))
2752+
.map(|_| Message::PreviewTick),
2753+
);
2754+
26562755
if !self.pending_operations.is_empty() {
26572756
#[cfg(feature = "logind")]
26582757
{
@@ -2778,21 +2877,26 @@ impl Application for App {
27782877
subscriptions.push(Subscription::run_with_id(
27792878
url.clone(),
27802879
stream::channel(16, move |mut msg_tx| async move {
2781-
log::info!("fetch screenshot {}", url);
2880+
// Check cache first
2881+
if let Some(data) = preview_cache::get_cached(&url) {
2882+
log::debug!("screenshot cache hit: {}", url);
2883+
let _ = msg_tx
2884+
.send(Message::SelectedScreenshot(screenshot_i, url, data))
2885+
.await;
2886+
return pending().await;
2887+
}
2888+
2889+
log::debug!("screenshot fetch: {}", url);
27822890
match reqwest::get(&url).await {
27832891
Ok(response) => match response.bytes().await {
27842892
Ok(bytes) => {
2785-
log::info!(
2786-
"fetched screenshot from {}: {} bytes",
2787-
url,
2788-
bytes.len()
2789-
);
2893+
let data = bytes.to_vec();
2894+
// Save to cache
2895+
if let Err(e) = preview_cache::save_to_cache(&url, &data) {
2896+
log::warn!("failed to cache screenshot {}: {}", url, e);
2897+
}
27902898
let _ = msg_tx
2791-
.send(Message::SelectedScreenshot(
2792-
screenshot_i,
2793-
url,
2794-
bytes.to_vec(),
2795-
))
2899+
.send(Message::SelectedScreenshot(screenshot_i, url, data))
27962900
.await;
27972901
}
27982902
Err(err) => {
@@ -2809,6 +2913,32 @@ impl Application for App {
28092913
}
28102914
}
28112915

2916+
// Background preview cache downloader - wakes on notification, processes LIFO queue
2917+
subscriptions.push(Subscription::run_with_id(
2918+
"preview-cache-downloader",
2919+
stream::channel(1, |_| async {
2920+
const SIZE_LIMIT_INTERVAL: u64 = 10 * 1024 * 1024; // 10 MB
2921+
let mut bytes_since_limit_check: u64 = 0;
2922+
loop {
2923+
preview_cache::wait_for_work().await;
2924+
let urls = preview_cache::take_pending();
2925+
for url in &urls {
2926+
log::debug!("preview fetch: {}", url);
2927+
if let Ok(resp) = reqwest::get(url).await {
2928+
if let Ok(bytes) = resp.bytes().await {
2929+
bytes_since_limit_check += bytes.len() as u64;
2930+
let _ = preview_cache::save_to_cache(url, &bytes);
2931+
}
2932+
}
2933+
}
2934+
if bytes_since_limit_check >= SIZE_LIMIT_INTERVAL {
2935+
preview_cache::enforce_size_limit();
2936+
bytes_since_limit_check = 0;
2937+
}
2938+
}
2939+
}),
2940+
));
2941+
28122942
Subscription::batch(subscriptions)
28132943
}
28142944
}

src/preview_cache.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
3+
use std::{
4+
hash::{DefaultHasher, Hash, Hasher},
5+
path::PathBuf,
6+
sync::{Mutex, OnceLock},
7+
time::SystemTime,
8+
};
9+
use tokio::sync::Notify;
10+
11+
/// Maximum number of URLs to take from queue per tick
12+
const MAX_BATCH_SIZE: usize = 5;
13+
14+
/// Maximum cache size in bytes (250 MB)
15+
const MAX_CACHE_BYTES: u64 = 250 * 1024 * 1024;
16+
17+
/// Convert a URL to a cache file path using a hash
18+
fn url_to_path(url: &str) -> Option<PathBuf> {
19+
let mut hasher = DefaultHasher::new();
20+
url.hash(&mut hasher);
21+
let hash = format!("{:016x}", hasher.finish());
22+
dirs::cache_dir().map(|dir| dir.join("cosmic-store/previews").join(&hash))
23+
}
24+
25+
/// Get cached image data from disk (returns None if not cached)
26+
pub fn get_cached(url: &str) -> Option<Vec<u8>> {
27+
let path = url_to_path(url)?;
28+
std::fs::read(&path).ok()
29+
}
30+
31+
/// Save data to disk cache
32+
pub fn save_to_cache(url: &str, data: &[u8]) -> std::io::Result<()> {
33+
let path = url_to_path(url)
34+
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no cache directory"))?;
35+
if let Some(parent) = path.parent() {
36+
std::fs::create_dir_all(parent)?;
37+
}
38+
std::fs::write(path, data)
39+
}
40+
41+
/// Global download queue (LIFO for prioritizing recently viewed items)
42+
static PENDING: Mutex<Vec<String>> = Mutex::new(Vec::new());
43+
44+
/// Notification for waking the background downloader
45+
static NOTIFY: OnceLock<Notify> = OnceLock::new();
46+
47+
fn notify() -> &'static Notify {
48+
NOTIFY.get_or_init(Notify::new)
49+
}
50+
51+
/// Queue a URL for background download if not already cached
52+
pub fn queue(url: &str) {
53+
// Skip if already cached
54+
if url_to_path(url).is_some_and(|p| p.exists()) {
55+
return;
56+
}
57+
if let Ok(mut queue) = PENDING.lock() {
58+
// Avoid duplicates (cheap linear scan since queue stays small)
59+
if !queue.iter().any(|u| u == url) {
60+
queue.push(url.to_string());
61+
notify().notify_one();
62+
}
63+
}
64+
}
65+
66+
/// Take URLs to download (LIFO order, up to MAX_BATCH_SIZE)
67+
pub fn take_pending() -> Vec<String> {
68+
let Ok(mut queue) = PENDING.lock() else {
69+
return Vec::new();
70+
};
71+
let start = queue.len().saturating_sub(MAX_BATCH_SIZE);
72+
queue.split_off(start)
73+
}
74+
75+
/// Wait for work to be queued. Returns immediately if work is already pending.
76+
pub async fn wait_for_work() {
77+
// Check if there's already work pending
78+
if PENDING.lock().map(|q| !q.is_empty()).unwrap_or(false) {
79+
return;
80+
}
81+
notify().notified().await;
82+
}
83+
84+
/// Evict least-recently-accessed entries until total cache size is within the limit.
85+
pub fn enforce_size_limit() {
86+
let Some(cache_dir) = dirs::cache_dir().map(|d| d.join("cosmic-store/previews")) else {
87+
return;
88+
};
89+
let Ok(entries) = std::fs::read_dir(&cache_dir) else {
90+
return;
91+
};
92+
93+
// Collect all files with their size and access time
94+
let mut files: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
95+
let mut total_size: u64 = 0;
96+
97+
for entry in entries.filter_map(Result::ok) {
98+
let path = entry.path();
99+
if !path.is_file() {
100+
continue;
101+
}
102+
let Ok(metadata) = path.metadata() else {
103+
continue;
104+
};
105+
let size = metadata.len();
106+
let accessed = metadata.accessed().unwrap_or(
107+
metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
108+
);
109+
total_size += size;
110+
files.push((path, size, accessed));
111+
}
112+
113+
if total_size <= MAX_CACHE_BYTES {
114+
return;
115+
}
116+
117+
// Sort by access time ascending (oldest accessed first)
118+
files.sort_by_key(|(_, _, accessed)| *accessed);
119+
120+
let mut evicted = 0usize;
121+
for (path, size, _) in &files {
122+
if total_size <= MAX_CACHE_BYTES {
123+
break;
124+
}
125+
if std::fs::remove_file(path).is_ok() {
126+
total_size -= size;
127+
evicted += 1;
128+
}
129+
}
130+
if evicted > 0 {
131+
log::info!(
132+
"preview cache: evicted {} files to stay within {} MB limit",
133+
evicted,
134+
MAX_CACHE_BYTES / (1024 * 1024)
135+
);
136+
}
137+
}

src/search.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,11 @@ impl SearchResult {
231231
GridMetrics::new(width, 240 + 2 * spacing.space_s as usize, spacing.space_xxs)
232232
}
233233

234+
/// Card height including padding
235+
pub fn card_height(spacing: &cosmic_theme::Spacing) -> f32 {
236+
48.0 + (spacing.space_xxs as f32) * 2.0
237+
}
238+
234239
pub fn grid_view<'a, F: Fn(usize) -> Message + 'a>(
235240
results: &'a [Self],
236241
spacing: cosmic_theme::Spacing,
@@ -291,7 +296,7 @@ impl SearchResult {
291296
)
292297
.align_y(Alignment::Center)
293298
.width(Length::Fixed(width as f32))
294-
.height(Length::Fixed(48.0 + (spacing.space_xxs as f32) * 2.0))
299+
.height(Length::Fixed(Self::card_height(spacing)))
295300
.padding([spacing.space_xxs, spacing.space_s])
296301
.class(theme::Container::Card)
297302
.into()

0 commit comments

Comments
 (0)