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:
12
src/api.rs
12
src/api.rs
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
198
src/main.rs
198
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::<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;
|
||||
};
|
||||
|
||||
@@ -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%;
|
||||
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;
|
||||
// view-state: view-state(self.y, self.y + self.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user