Compare commits
1 Commits
master
...
99a436571b
| Author | SHA1 | Date | |
|---|---|---|---|
|
99a436571b
|
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%;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user