diff --git a/src/api.rs b/src/api.rs index db58efd..df47eeb 100644 --- a/src/api.rs +++ b/src/api.rs @@ -162,6 +162,10 @@ impl Message for Api { msg: GetAssetThumbnail, _ctx: &mut Context, ) -> Self::Reply { + if let Some(thumbnail) = self.thumbnails.get(&msg.id).cloned() { + return Ok(thumbnail); + } + let response = self .client .assets() @@ -182,9 +186,13 @@ impl Message for Api { thumbnail.height(), ); - Ok(Arc::new(AssetThumbnail { + let thumbnail = Arc::new(AssetThumbnail { id: msg.id, thumbnail: pixel_buffer, - })) + }); + + self.thumbnails.insert(msg.id, Arc::clone(&thumbnail)); + + Ok(thumbnail) } } diff --git a/src/main.rs b/src/main.rs index 273ed6c..8f0a96d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ // Prevent console window in addition to Slint window in Windows release builds when, e.g., starting the app via file manager. Ignored on other platforms. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use std::{mem, ops::Deref}; + use clap::Parser; use immich_sdk::AssetId; use kameo::actor::{ActorRef, Spawn}; @@ -12,7 +14,7 @@ use tracing::Level; use crate::{ api::{Api, GetAssetThumbnail, GetTimeBucket, GetTimeBuckets, TimeBucketKey}, - ui::AppWindow, + ui::{AppWindow, ImageBucket}, }; mod api; @@ -68,6 +70,7 @@ fn main() -> anyhow::Result<()> { let api_ = Api::spawn(api); let app = ui::AppWindow::new()?; + let global = app.global::(); let app_weak = app.as_weak(); let api = api_.clone(); @@ -76,17 +79,9 @@ fn main() -> anyhow::Result<()> { return; }; - for bucket in buckets.iter() { - load_bucket(bucket.key.clone(), app_weak.clone(), api.clone()); - } - + let api = api.clone(); let _ = app_weak.upgrade_in_event_loop(move |app| { - let preview_image = slint::Image::from_rgb8(SharedPixelBuffer::clone_from_slice( - &[0x33, 0x33, 0x33], // a single dark gray pixel - 1, - 1, - )); - + let preview_image = placeholder_preview(); let global = app.global::(); let buckets = buckets .into_iter() @@ -103,29 +98,173 @@ fn main() -> anyhow::Result<()> { }) .collect::>(), ), + y: Default::default(), + height: Default::default(), + visibility: ui::Visibility::Hidden, }) .collect::>(); global.set_image_buckets(ModelRc::new(buckets)); + calculate_timeline_layout(&app, api, global.get_timeline_width()); }); }); - // FIXME: visibility-based callbacks are broken - // let app_weak = app.as_weak(); - // let api = api_.clone(); - // global.on_bucket_view_state(move |key, new_state| { - // eprintln!("{key} => {new_state:?}"); + let app_weak = app.as_weak(); + let api = api_.clone(); + global.on_set_timeline_width(move |new_width| { + let api = api.clone(); + let _ = app_weak.upgrade_in_event_loop(move |app| { + calculate_timeline_layout(&app, api, new_width); + }); + }); - // // TODO - // if new_state == ui::ViewState::Hidden { - // return; - // } - // }); + let app_weak = app.as_weak(); + let api = api_.clone(); + global.on_timeline_scrolled(move |scroll| { + let api = api.clone(); + let _ = app_weak.upgrade_in_event_loop(move |app| { + calculate_timeline_visibility(&app, api, scroll); + }); + }); app.run()?; Ok(()) } +fn calculate_timeline_visibility(app: &AppWindow, api: ActorRef, scroll: f32) { + let global = app.global::(); + global.set_timeline_scroll(scroll); + + let window_height = app.get_window_height(); + let visible_range = scroll..=(scroll + window_height); + let buckets = get_image_buckets(&app); + + for i in 0..buckets.row_count() { + let Some(mut bucket) = buckets.row_data(i) else { + break; + }; + let top_y = bucket.y; + let bottom_y = bucket.y + bucket.height; + + let is_visible = &top_y <= &visible_range.end() && visible_range.start() <= &bottom_y; + + let visibility = if is_visible { + ui::Visibility::InView + } else { + ui::Visibility::Hidden + }; + + let prev_visibility = mem::replace(&mut bucket.visibility, visibility); + + if prev_visibility != visibility { + tracing::debug!("{i} {prev_visibility:?} => {visibility:?}"); + } + + match (prev_visibility, visibility) { + (ui::Visibility::Hidden, ui::Visibility::InView) => { + load_bucket(bucket.key.to_string(), app.as_weak(), api.clone()); + } + + (ui::Visibility::NearView, ui::Visibility::Hidden) + | (ui::Visibility::InView, ui::Visibility::Hidden) => { + unload_bucket(&bucket.key.to_string(), app); + } + + (..) => {} + } + + buckets.set_row_data(i, bucket); + } +} + +fn calculate_timeline_layout(app: &AppWindow, api: ActorRef, timeline_width: f32) { + let global = app.global::(); + let min_image_size = global.get_min_image_size(); + let image_margin = global.get_image_margin(); + let min_size_with_margin = min_image_size + image_margin; + let buckets = get_image_buckets(&app); + + let count_x = (timeline_width / min_size_with_margin).floor() as usize; + let remaining_length = timeline_width.rem_euclid(min_size_with_margin); + let image_size = min_image_size + remaining_length / (count_x as f32); + let image_size_with_margin = image_size + image_margin; + let header_height = 48.0; // TODO + + let mut y = 0.0; + for i in 0..buckets.row_count() { + let Some(mut bucket) = buckets.row_data(i) else { + break; + }; + + let count_y = ((bucket.count as f32) / (count_x as f32)).ceil(); + + if i == 0 { + dbg!(count_x); + dbg!(count_y); + } + + bucket.y = y; + bucket.height = header_height + count_y * image_size_with_margin; + y += bucket.height; + + buckets.set_row_data(i, bucket); + } + + global.set_timeline_width(timeline_width); + global.set_timeline_height(y); + calculate_timeline_visibility(app, api, global.get_timeline_scroll()); +} + +struct AppImageBuckets { + buckets: ModelRc, +} + +impl Deref for AppImageBuckets { + type Target = VecModel; + + fn deref(&self) -> &Self::Target { + self.buckets + .as_any() + .downcast_ref::>() + .expect("Image buckets is a VecModel") + } +} + +fn get_image_buckets(app: &AppWindow) -> impl Deref> { + let global = app.global::(); + AppImageBuckets { + buckets: global.get_image_buckets(), + } +} + +fn placeholder_preview() -> slint::Image { + slint::Image::from_rgb8(SharedPixelBuffer::clone_from_slice( + &[0x33, 0x33, 0x33], // a single dark gray pixel + 1, + 1, + )) +} + +fn unload_bucket(time_bucket: &TimeBucketKey, app: &AppWindow) { + let buckets = get_image_buckets(app); + let Some(i) = buckets.iter().position(|b| &b.key == &time_bucket) else { + return; + }; + let bucket = buckets.row_data(i).expect("i is in the list"); + + let placeholder_preview = placeholder_preview(); + for j in 0..bucket.previews.row_count() { + let Some(mut preview) = bucket.previews.row_data(j) else { + break; + }; + preview.kind = ui::PreviewKind::None; + preview.image = placeholder_preview.clone(); + bucket.previews.set_row_data(j, preview); + } + + // TODO: write `bucket` into `buckets?` +} + fn load_bucket(time_bucket: TimeBucketKey, app_weak: Weak, api: ActorRef) { tokio::spawn(async move { let Ok(api_bucket) = api @@ -138,13 +277,7 @@ fn load_bucket(time_bucket: TimeBucketKey, app_weak: Weak, api: Actor }; let _ = app_weak.upgrade_in_event_loop(move |app| { - let global = app.global::(); - - let buckets = global.get_image_buckets(); - let buckets = buckets - .as_any() - .downcast_ref::>() - .unwrap(); + let buckets = get_image_buckets(&app); let Some(i) = buckets.iter().position(|b| &b.key == &time_bucket) else { return; @@ -192,14 +325,7 @@ fn load_thumbnail( }; let _ = app_weak.upgrade_in_event_loop(move |app| { - let global = app.global::(); - - let buckets = global.get_image_buckets(); - let buckets = buckets - .as_any() - .downcast_ref::>() - .unwrap(); - + let buckets = get_image_buckets(&app); let Some(i) = buckets.iter().position(|b| &b.key == &time_bucket) else { return; }; diff --git a/ui/app-window.slint b/ui/app-window.slint index 04e0366..e57c53a 100644 --- a/ui/app-window.slint +++ b/ui/app-window.slint @@ -12,17 +12,20 @@ struct ImagePreview { kind: PreviewKind, } +enum Visibility { + Hidden, + NearView, + InView, +} + struct ImageBucket { key: string, title: string, count: int, previews: [ImagePreview], -} - -enum ViewState { - Hidden, - NearView, - InView, + y: length, + height: length, + visibility: Visibility, } export global Global { @@ -33,8 +36,11 @@ export global Global { { key: "2026-02-02", title: "Feb 2, 2026", count: 12 }, { key: "2026-02-03", title: "Feb 3, 2026", count: 12 }, ]; - - callback bucket-view-state(string, ViewState); + in property timeline-height; + in property timeline-width; + in property timeline-scroll; + callback set-timeline-width(length); + callback timeline-scrolled(length); } @@ -71,18 +77,11 @@ component ImagePreview inherits Rectangle { component TimelineBlock inherits VerticalLayout { in property index: -1; - in property bucket; - - function calc-view-state() -> ViewState { - // TODO: fix this function - return ViewState.InView; - } - - in property view-state: calc-view-state(); + in-out property bucket; property min-image-size: Global.min-image-size; property min-size-with-margin: min-image-size + Global.image-margin; - property count-x: self.width / min-size-with-margin; + property count-x: Math.floor(self.width / min-size-with-margin); // TODO: or is it ceil? property count-y: Math.ceil(bucket.count / count-x); function calc-image-size() -> length { @@ -93,19 +92,16 @@ component TimelineBlock inherits VerticalLayout { property image-size: calc-image-size(); property image-size-with-margin: image-size + Global.image-margin; - min-width: min-image-size; - alignment: start; + property title-box-height: 36px; height: title-box.height + count-y * image-size-with-margin; - // FIXME: this triggers recursion errors in slint. - // changed view-state => { - // Global.bucket-view-state(bucket.key, view-state); - // } + y: bucket.y; + min-width: min-image-size; + alignment: start; - // TODO: don't render subtree when self.view-state = Hidden title-box := HorizontalBox { alignment: space-between; - height: 36px; + height: title-box-height; Text { text: bucket.title; @@ -117,7 +113,6 @@ component TimelineBlock inherits VerticalLayout { } } - // TODO: don't render subtree when self.view-state = Hidden image-box := Rectangle { width: 100%; height: count-y * image-size-with-margin; @@ -141,6 +136,15 @@ component TimelineBlock inherits VerticalLayout { // } export component AppWindow inherits Window { + out property window-height: self.height; + + // Do not base preferred-width on children + preferred-width: 480px; + + changed width => { + Global.set-timeline-width(self.width); + } + VerticalLayout { padding: 0px; width: 100%; @@ -148,45 +152,26 @@ export component AppWindow inherits Window { Header {} ScrollView { - width: 100%; mouse-drag-pan-enabled: true; + viewport-height: rect.height; - // viewport-height: 20000px; - - // self.viewport-y goes into the negative. invert it to make things easier. - // property viewport-top-y: -self.viewport-y; - // property viewport-bottom-y: viewport-top-y + self.height; - // property almost-visible-margin: self.height; - - // function is-visible(top-y: length, bottom-y: length) -> bool { - // top-y <= viewport-bottom-y && viewport-top-y <= bottom-y - // } - - // function is-almost-visible(top-y: length, bottom-y: length) -> bool { - // top-y <= (viewport-bottom-y + almost-visible-margin) - // && (viewport-top-y - almost-visible-margin) <= bottom-y - // } - - // function view-state(top-y: length, bottom-y: length) -> ViewState { - // if is-visible(top-y, bottom-y) { - // return ViewState.InView; - // } - // if is-almost-visible(top-y, bottom-y) { - // return ViewState.NearView; - // } - // return ViewState.Hidden; - // } + changed viewport-y => { + Global.timeline-scrolled(-self.viewport-y); + } - VerticalLayout { - alignment: start; - padding: 0px; - width: 100%; - - for bucket[i] in Global.image-buckets : TimelineBlock { - width: 100%; - index: i; - bucket: bucket; - // view-state: view-state(self.y, self.y + self.height); + rect := Rectangle { + y: 0; + x: 0; + width: root.width; + height: Global.timeline-height; + preferred-width: self.width; + preferred-height: self.height; + for bucket[i] in Global.image-buckets : Rectangle { + if bucket.visibility == Visibility.InView : TimelineBlock { + width: root.width; + index: i; + bucket: bucket; + } } } }