Add initial image viewer
This commit is contained in:
@@ -71,6 +71,7 @@ fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let app = ui::AppWindow::new()?;
|
let app = ui::AppWindow::new()?;
|
||||||
let global = app.global::<ui::Global>();
|
let global = app.global::<ui::Global>();
|
||||||
|
global.set_image_buckets(ModelRc::new(VecModel::default()));
|
||||||
|
|
||||||
let app_weak = app.as_weak();
|
let app_weak = app.as_weak();
|
||||||
let api = api_.clone();
|
let api = api_.clone();
|
||||||
@@ -222,6 +223,7 @@ struct AppImageBuckets {
|
|||||||
impl Deref for AppImageBuckets {
|
impl Deref for AppImageBuckets {
|
||||||
type Target = VecModel<ImageBucket>;
|
type Target = VecModel<ImageBucket>;
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
fn deref(&self) -> &Self::Target {
|
fn deref(&self) -> &Self::Target {
|
||||||
self.buckets
|
self.buckets
|
||||||
.as_any()
|
.as_any()
|
||||||
|
|||||||
@@ -1,48 +1,9 @@
|
|||||||
import { AboutSlint, Button, Palette, HorizontalBox, ScrollView } from "std-widgets.slint";
|
import { AboutSlint, Button, Palette, HorizontalBox, ScrollView } from "std-widgets.slint";
|
||||||
|
import { Timeline } from "timeline.slint";
|
||||||
|
import { ImageViewer } from "image-viewer.slint";
|
||||||
|
|
||||||
enum PreviewKind {
|
import { Global } from "global.slint";
|
||||||
None,
|
export { Global }
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
component Header inherits Rectangle {
|
component Header inherits Rectangle {
|
||||||
width: 100%;
|
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 {
|
export component AppWindow inherits Window {
|
||||||
out property <length> window-height: self.height;
|
out property <length> window-height: self.height;
|
||||||
|
|
||||||
@@ -151,30 +34,11 @@ export component AppWindow inherits Window {
|
|||||||
|
|
||||||
Header {}
|
Header {}
|
||||||
|
|
||||||
ScrollView {
|
Timeline {}
|
||||||
mouse-drag-pan-enabled: true;
|
}
|
||||||
viewport-height: rect.height;
|
|
||||||
|
|
||||||
changed viewport-y => {
|
if Global.previewed-image.asset-id != "" : ImageViewer {
|
||||||
Global.timeline-scrolled(-self.viewport-y);
|
image: Global.previewed-image.image;
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
ui/global.slint
Normal file
17
ui/global.slint
Normal 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
88
ui/image-viewer.slint
Normal 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
99
ui/timeline.slint
Normal 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
28
ui/types.slint
Normal 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,
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user