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

@@ -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;
};