@@ -73,13 +73,15 @@ mod priority;
7373
7474mod stats;
7575
76+ mod preview_cache;
77+
7678use explore:: ExplorePage ;
7779mod explore;
7880
7981use nav:: { Category , CategoryIndex , NavPage , ScrollContext } ;
8082mod nav;
8183
82- use search:: { CachedExploreResults , SearchResult } ;
84+ use search:: { CachedExploreResults , GridMetrics , SearchResult } ;
8385mod search;
8486
8587mod 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}
0 commit comments