Compare commits

..

1 Commits

Author SHA1 Message Date
99a436571b 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.
2026-04-26 12:02:38 +02:00
6 changed files with 144 additions and 242 deletions

View File

@@ -71,7 +71,6 @@ 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();
@@ -223,7 +222,6 @@ struct AppImageBuckets {
impl Deref for AppImageBuckets {
type Target = VecModel<ImageBucket>;
#[track_caller]
fn deref(&self) -> &Self::Target {
self.buckets
.as_any()

View File

@@ -1,9 +1,48 @@
import { AboutSlint, Button, Palette, HorizontalBox, ScrollView } from "std-widgets.slint";
import { Timeline } from "timeline.slint";
import { ImageViewer } from "image-viewer.slint";
import { Global } from "global.slint";
export { Global }
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);
}
component Header inherits Rectangle {
width: 100%;
@@ -18,6 +57,84 @@ 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;
@@ -34,11 +151,30 @@ export component AppWindow inherits Window {
Header {}
Timeline {}
ScrollView {
mouse-drag-pan-enabled: true;
viewport-height: rect.height;
changed viewport-y => {
Global.timeline-scrolled(-self.viewport-y);
}
if Global.previewed-image.asset-id != "" : ImageViewer {
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;
}
}
}
}
}
}

View File

@@ -1,17 +0,0 @@
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);
}

View File

@@ -1,88 +0,0 @@
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;
}
}
}
]
}
}
}

View File

@@ -1,99 +0,0 @@
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;
}
}
}
}

View File

@@ -1,28 +0,0 @@
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,
}