From 17dfc144b2a0c0c8ddff586c4bafc49b892ac058 Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Sun, 26 Apr 2026 12:45:56 +0200 Subject: [PATCH] Add initial image viewer --- src/main.rs | 2 + ui/app-window.slint | 152 +++--------------------------------------- ui/global.slint | 17 +++++ ui/image-viewer.slint | 88 ++++++++++++++++++++++++ ui/timeline.slint | 99 +++++++++++++++++++++++++++ ui/types.slint | 28 ++++++++ 6 files changed, 242 insertions(+), 144 deletions(-) create mode 100644 ui/global.slint create mode 100644 ui/image-viewer.slint create mode 100644 ui/timeline.slint create mode 100644 ui/types.slint diff --git a/src/main.rs b/src/main.rs index 8f0a96d..c5c8964 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,6 +71,7 @@ fn main() -> anyhow::Result<()> { let app = ui::AppWindow::new()?; let global = app.global::(); + global.set_image_buckets(ModelRc::new(VecModel::default())); let app_weak = app.as_weak(); let api = api_.clone(); @@ -222,6 +223,7 @@ struct AppImageBuckets { impl Deref for AppImageBuckets { type Target = VecModel; + #[track_caller] fn deref(&self) -> &Self::Target { self.buckets .as_any() diff --git a/ui/app-window.slint b/ui/app-window.slint index e57c53a..758cfde 100644 --- a/ui/app-window.slint +++ b/ui/app-window.slint @@ -1,48 +1,9 @@ import { AboutSlint, Button, Palette, HorizontalBox, ScrollView } from "std-widgets.slint"; +import { Timeline } from "timeline.slint"; +import { ImageViewer } from "image-viewer.slint"; -enum PreviewKind { - None, - Thumbhash, - Thumbnail, -} - -struct ImagePreview { - asset_id: string, - image: image, - kind: PreviewKind, -} - -enum Visibility { - Hidden, - NearView, - InView, -} - -struct ImageBucket { - key: string, - title: string, - count: int, - previews: [ImagePreview], - y: length, - height: length, - visibility: Visibility, -} - -export global Global { - in-out property min-image-size: 100px; - in-out property image-margin: 2px; - in-out property <[ImageBucket]> image-buckets: [ - { key: "2026-02-01", title: "Feb 1, 2026", count: 12 }, - { key: "2026-02-02", title: "Feb 2, 2026", count: 12 }, - { key: "2026-02-03", title: "Feb 3, 2026", count: 12 }, - ]; - in property timeline-height; - in property timeline-width; - in property timeline-scroll; - callback set-timeline-width(length); - callback timeline-scrolled(length); -} - +import { Global } from "global.slint"; +export { Global } component Header inherits Rectangle { width: 100%; @@ -57,84 +18,6 @@ component Header inherits Rectangle { } } -component ImagePreview inherits Rectangle { - in property preview; - in property size: 32px; - width: size; - height: size; - - Image { - width: 100%; - height: 100%; - source: preview; - } - - touch := TouchArea { - clicked => { - } - } -} - -component TimelineBlock inherits VerticalLayout { - in property index: -1; - 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: 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 { - let remaining-length = Math.mod(self.width, min-size-with-margin); - min-image-size + remaining-length / count-x - } - - property image-size: calc-image-size(); - property image-size-with-margin: image-size + Global.image-margin; - - property title-box-height: 36px; - height: title-box.height + count-y * image-size-with-margin; - - y: bucket.y; - min-width: min-image-size; - alignment: start; - - title-box := HorizontalBox { - alignment: space-between; - height: title-box-height; - - Text { - text: bucket.title; - } - - // TODO: checkbox thingy - Text { - text: "O"; - } - } - - image-box := Rectangle { - width: 100%; - height: count-y * image-size-with-margin; - - for preview[i] in bucket.previews : ImagePreview { - preview: preview.image; - size: image-size; - x: Global.image-margin / 2 + Math.mod(i, count-x) * (Global.image-margin + image-size); - y: Math.floor(i / count-x) * (image-size + Global.image-margin); - } - } -} - -// component ImageViewer inherits Rectangle { -// in property image; - -// width: 100%; -// height: 100%; - -// background: black; -// } - export component AppWindow inherits Window { out property window-height: self.height; @@ -151,30 +34,11 @@ export component AppWindow inherits Window { Header {} - ScrollView { - mouse-drag-pan-enabled: true; - viewport-height: rect.height; + Timeline {} + } - changed viewport-y => { - Global.timeline-scrolled(-self.viewport-y); - } - - 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; - } - } - } - } + if Global.previewed-image.asset-id != "" : ImageViewer { + image: Global.previewed-image.image; } } diff --git a/ui/global.slint b/ui/global.slint new file mode 100644 index 0000000..ebb8ed7 --- /dev/null +++ b/ui/global.slint @@ -0,0 +1,17 @@ +import { ImageBucket, ImagePreview } from "types.slint"; + +export global Global { + in-out property min-image-size: 100px; + in-out property image-margin: 2px; + in-out property previewed-image; + in-out property <[ImageBucket]> image-buckets: [ + { key: "2026-02-01", title: "Feb 1, 2026", count: 12 }, + { key: "2026-02-02", title: "Feb 2, 2026", count: 12 }, + { key: "2026-02-03", title: "Feb 3, 2026", count: 12 }, + ]; + in property timeline-height; + in property timeline-width; + in property timeline-scroll; + callback set-timeline-width(length); + callback timeline-scrolled(length); +} diff --git a/ui/image-viewer.slint b/ui/image-viewer.slint new file mode 100644 index 0000000..da2332a --- /dev/null +++ b/ui/image-viewer.slint @@ -0,0 +1,88 @@ +import { Global } from "global.slint"; + +export component ImageViewer inherits Rectangle { + in property image; + + width: 100%; + height: 100%; + + background: black; + + enter-animation := Timer { + running: true; + interval: 1ms; + triggered => { + self.running = false; + } + } + + exit-animation := Timer { + running: false; + interval: 0.2s; + triggered => { + self.running = false; + Global.previewed-image.asset-id = ""; + } + } + + states [ + entering when enter-animation.running: { + root.opacity: 0.0; + out { + animate root.opacity { + duration: 0.15s; + } + } + } + exiting when exit-animation.running: { + root.opacity: 0.0; + in { + animate root.opacity { + duration: exit-animation.interval; + } + } + } + entered: { + root.opacity: 1.0; + } + ] + + sgh := SwipeGestureHandler { + enabled: !enter-animation.running && !exit-animation.running; + handle-swipe-down: true; + handle-swipe-up: true; + + swiped() => { + if self.current-position.y < self.pressed-position.y { + debug ("todo: handle swiped up") + } else { + exit-animation.running = true; + } + } + + + Image { + source: image; + image-fit: ImageFit.contain; + + function calc-y() -> length { + parent.y + parent.height / 2 - self.height / 2 + } + y: calc-y(); + + states [ + swiping when sgh.swiping: { + y: calc-y() + sgh.current-position.y - sgh.pressed-position.y; + } + exiting when exit-animation.running: { + y: parent.height; + in { + animate y { + duration: exit-animation.interval; + } + } + } + ] + } + } +} diff --git a/ui/timeline.slint b/ui/timeline.slint new file mode 100644 index 0000000..1f60db8 --- /dev/null +++ b/ui/timeline.slint @@ -0,0 +1,99 @@ +import { ScrollView } from "std-widgets.slint"; +import { Global } from "global.slint"; +import { ImageBucket, Visibility, ImagePreview } from "types.slint"; + +component ImagePreview inherits Rectangle { + in property preview; + in property size: 32px; + width: size; + height: size; + + Image { + width: 100%; + height: 100%; + source: preview.image; + } + + touch := TouchArea { + clicked => { + Global.previewed-image = root.preview; + } + } +} + +component TimelineBlock inherits VerticalLayout { + in property index: -1; + 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: 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 { + let remaining-length = Math.mod(self.width, min-size-with-margin); + min-image-size + remaining-length / count-x + } + + property image-size: calc-image-size(); + property image-size-with-margin: image-size + Global.image-margin; + + property title-box-height: 36px; + height: title-box.height + count-y * image-size-with-margin; + + y: bucket.y; + min-width: min-image-size; + alignment: start; + + title-box := HorizontalLayout { + alignment: space-between; + height: title-box-height; + padding: 8px; + + Text { + text: bucket.title; + } + + // TODO: checkbox thingy + Text { + text: "O"; + } + } + + image-box := Rectangle { + width: 100%; + height: count-y * image-size-with-margin; + + for preview[i] in bucket.previews : ImagePreview { + preview: preview; + size: image-size; + x: Global.image-margin / 2 + Math.mod(i, count-x) * (Global.image-margin + image-size); + y: Math.floor(i / count-x) * (image-size + Global.image-margin); + } + } +} + +export component Timeline inherits ScrollView { + mouse-drag-pan-enabled: true; + viewport-height: rect.height; + + changed viewport-y => { + Global.timeline-scrolled(-self.viewport-y); + } + + 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; + } + } + } +} diff --git a/ui/types.slint b/ui/types.slint new file mode 100644 index 0000000..9485e43 --- /dev/null +++ b/ui/types.slint @@ -0,0 +1,28 @@ +export enum PreviewKind { + None, + Thumbhash, + Thumbnail, +} + +export struct ImagePreview { + asset_id: string, + image: image, + kind: PreviewKind, +} + +export enum Visibility { + Hidden, + NearView, + InView, +} + +export struct ImageBucket { + key: string, + title: string, + count: int, + previews: [ImagePreview], + y: length, + height: length, + visibility: Visibility, +} +