Add initial image viewer

This commit is contained in:
2026-04-26 12:45:56 +02:00
parent 10fcd546ce
commit 17dfc144b2
6 changed files with 242 additions and 144 deletions

View File

@@ -71,6 +71,7 @@ fn main() -> anyhow::Result<()> {
let app = ui::AppWindow::new()?;
let global = app.global::<ui::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<ImageBucket>;
#[track_caller]
fn deref(&self) -> &Self::Target {
self.buckets
.as_any()

View File

@@ -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 <length> min-image-size: 100px;
in-out property <length> 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 <length> timeline-height;
in property <length> timeline-width;
in property <length> 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 <image> preview;
in property <length> size: 32px;
width: size;
height: size;
Image {
width: 100%;
height: 100%;
source: preview;
}
touch := TouchArea {
clicked => {
}
}
}
component TimelineBlock inherits VerticalLayout {
in property <int> index: -1;
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: 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 {
let remaining-length = Math.mod(self.width, min-size-with-margin);
min-image-size + remaining-length / count-x
}
property <length> image-size: calc-image-size();
property <length> image-size-with-margin: image-size + Global.image-margin;
property <length> 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> image;
// width: 100%;
// height: 100%;
// background: black;
// }
export component AppWindow inherits Window {
out property <length> 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;
}
}

17
ui/global.slint Normal file
View File

@@ -0,0 +1,17 @@
import { ImageBucket, ImagePreview } from "types.slint";
export global Global {
in-out property <length> min-image-size: 100px;
in-out property <length> image-margin: 2px;
in-out property <ImagePreview> 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 <length> timeline-height;
in property <length> timeline-width;
in property <length> timeline-scroll;
callback set-timeline-width(length);
callback timeline-scrolled(length);
}

88
ui/image-viewer.slint Normal file
View File

@@ -0,0 +1,88 @@
import { Global } from "global.slint";
export component ImageViewer inherits Rectangle {
in property <image> 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;
}
}
}
]
}
}
}

99
ui/timeline.slint Normal file
View File

@@ -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 <ImagePreview> preview;
in property <length> 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 <int> index: -1;
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: 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 {
let remaining-length = Math.mod(self.width, min-size-with-margin);
min-image-size + remaining-length / count-x
}
property <length> image-size: calc-image-size();
property <length> image-size-with-margin: image-size + Global.image-margin;
property <length> 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;
}
}
}
}

28
ui/types.slint Normal file
View File

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