Optimize timeline visibility

Due to "Recursion detected" bugs in slint, we need to calculate the
layout of the timeline in Rust. This is ugly, but works.
This commit is contained in:
2026-04-26 12:02:38 +02:00
parent ab01696537
commit 10fcd546ce
3 changed files with 219 additions and 100 deletions

View File

@@ -162,6 +162,10 @@ impl Message<GetAssetThumbnail> for Api {
msg: GetAssetThumbnail,
_ctx: &mut Context<Self, Self::Reply>,
) -> 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<GetAssetThumbnail> 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)
}
}

View File

@@ -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::<ui::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::<ui::Global>();
let buckets = buckets
.into_iter()
@@ -103,29 +98,173 @@ fn main() -> anyhow::Result<()> {
})
.collect::<VecModel<_>>(),
),
y: Default::default(),
height: Default::default(),
visibility: ui::Visibility::Hidden,
})
.collect::<VecModel<_>>();
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<Api>, scroll: f32) {
let global = app.global::<ui::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<Api>, timeline_width: f32) {
let global = app.global::<ui::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<ImageBucket>,
}
impl Deref for AppImageBuckets {
type Target = VecModel<ImageBucket>;
fn deref(&self) -> &Self::Target {
self.buckets
.as_any()
.downcast_ref::<VecModel<ui::ImageBucket>>()
.expect("Image buckets is a VecModel")
}
}
fn get_image_buckets(app: &AppWindow) -> impl Deref<Target = VecModel<ImageBucket>> {
let global = app.global::<ui::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<AppWindow>, api: ActorRef<Api>) {
tokio::spawn(async move {
let Ok(api_bucket) = api
@@ -138,13 +277,7 @@ fn load_bucket(time_bucket: TimeBucketKey, app_weak: Weak<AppWindow>, api: Actor
};
let _ = app_weak.upgrade_in_event_loop(move |app| {
let global = app.global::<ui::Global>();
let buckets = global.get_image_buckets();
let buckets = buckets
.as_any()
.downcast_ref::<VecModel<ui::ImageBucket>>()
.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::<ui::Global>();
let buckets = global.get_image_buckets();
let buckets = buckets
.as_any()
.downcast_ref::<VecModel<ui::ImageBucket>>()
.unwrap();
let buckets = get_image_buckets(&app);
let Some(i) = buckets.iter().position(|b| &b.key == &time_bucket) else {
return;
};

View File

@@ -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 <length> timeline-height;
in property <length> timeline-width;
in property <length> 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 <int> index: -1;
in property <ImageBucket> bucket;
function calc-view-state() -> ViewState {
// TODO: fix this function
return ViewState.InView;
}
in property <ViewState> view-state: calc-view-state();
in-out property <ImageBucket> bucket;
property <length> min-image-size: Global.min-image-size;
property <length> min-size-with-margin: min-image-size + Global.image-margin;
property <int> count-x: self.width / min-size-with-margin;
property <int> count-x: Math.floor(self.width / min-size-with-margin); // TODO: or is it ceil?
property <int> count-y: Math.ceil(bucket.count / count-x);
function calc-image-size() -> length {
@@ -93,19 +92,16 @@ component TimelineBlock inherits VerticalLayout {
property <length> image-size: calc-image-size();
property <length> image-size-with-margin: image-size + Global.image-margin;
min-width: min-image-size;
alignment: start;
property <length> 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 <length> 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;
changed viewport-y => {
Global.timeline-scrolled(-self.viewport-y);
}
// self.viewport-y goes into the negative. invert it to make things easier.
// property <length> viewport-top-y: -self.viewport-y;
// property <length> viewport-bottom-y: viewport-top-y + self.height;
// property <length> 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;
// }
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;
}
}
}
}