Drag the date range on the axis it is chosen from
The range could be turned on with a finger and not aimed with one. Its two ends were typed as `YYYY-MM-DD` into 108px fields behind a soft keyboard, to name days already drawn on the axis a thumb away; and the chip that seeds them takes its span from the timeline's zoom and pan, which are a wheel and a middle button. A touch screen has neither, so on Android the filter was a switch with no aim. The band is now on the timeline. Two ends with grips, dragged along the bars, released to filter — the histogram was already how a period is found, and this makes it how a period is stated. Both ends snap to whole days, which is what the typed fields mean, what `show_range` reads back out, and a floor under a range dragged shut. The fields stay for what dragging cannot do: name an exact day, and say in words what the range is. For that to work the axis had to stop following the range. Redrawn to the band, it moved the ground under the very handles doing the narrowing, and there was nothing outside the range left to widen back into. While there: a fixed number of equal bins instead of calendar buckets. Between one calendar unit and the next the bar count is free to wander by a factor of twelve, so zooming in halved it two steps out of three — the same picture drawn wider until it jumped back to fine. Equal bins also include the empty ones, so a bar's position on the track and the date under it are finally the same quantity; before, a library with gaps drew a February six months wide and the marker, the band and a click all pointed somewhere else. The count is a setting, 32 or 64, because the right answer is a question about the screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+207
-8
@@ -68,6 +68,7 @@ export struct TimelineBar {
|
||||
// - **click or drag** — scrub the grid to that instant
|
||||
// - **drag with the middle button, or shift-drag** — pan the visible span
|
||||
// - **wheel** — zoom, which changes the bucket size Rust picks
|
||||
// - **drag either end of the range band** — narrow the grid to a period
|
||||
//
|
||||
// # One hit area, not one per bar
|
||||
//
|
||||
@@ -105,6 +106,21 @@ export component Timeline inherits Rectangle {
|
||||
/// them together. Continuous, unlike the wheel's discrete steps.
|
||||
callback pinch(float);
|
||||
|
||||
/// TRACES: FR-CAT-6
|
||||
/// The chosen date range, as fractions of the drawn span — the same
|
||||
/// coordinates `scrub-to` reports and `current-fraction` is drawn in, so
|
||||
/// the band, the marker and a click cannot disagree about where a date
|
||||
/// sits. Negative in both when no range is set.
|
||||
in property <float> range-from: -1;
|
||||
in property <float> range-to: -1;
|
||||
/// An end of the range was dragged, and where both ends now are.
|
||||
///
|
||||
/// Sent on release rather than continuously: each one of these re-runs the
|
||||
/// grid's query and reloads its window, and doing that per frame of a drag
|
||||
/// is how a filter turns into a stutter. The band tracks the finger
|
||||
/// meanwhile — that is what `live-from` and `live-to` are for.
|
||||
callback range-changed(float, float);
|
||||
|
||||
width: 96px;
|
||||
background: Theme.surface;
|
||||
|
||||
@@ -113,6 +129,21 @@ export component Timeline inherits Rectangle {
|
||||
property <length> track-height: max(1px, self.height - self.track-top - 6px);
|
||||
property <length> slot: root.track-height / max(1, root.bars.length);
|
||||
|
||||
property <bool> has-range: root.range-from >= 0 && root.range-to >= 0;
|
||||
/// Which end is under the finger: 0 neither, 1 the early end, 2 the late.
|
||||
property <int> grabbed: 0;
|
||||
/// The ends as the drag has them, before Rust has been told. Drawing from
|
||||
/// these is what lets the band follow the finger without the grid being
|
||||
/// re-queried on every frame.
|
||||
property <float> live-from;
|
||||
property <float> live-to;
|
||||
property <float> shown-from: root.grabbed > 0 ? root.live-from : root.range-from;
|
||||
property <float> shown-to: root.grabbed > 0 ? root.live-to : root.range-to;
|
||||
/// How near a press has to land to take hold of an end, as a fraction of
|
||||
/// the track. Sized for a finger rather than a pointer: this control
|
||||
/// exists because on a phone the range could only be typed (FR-UI-2).
|
||||
property <float> grab: 22px / root.track-height;
|
||||
|
||||
/// Bucket index under a y coordinate, clamped to the ends so a drag that
|
||||
/// leaves the widget still scrubs to the nearest bucket rather than
|
||||
/// stopping dead.
|
||||
@@ -132,6 +163,50 @@ export component Timeline inherits Rectangle {
|
||||
return clamp((y - root.track-top) / max(1px, root.track-height), 0.0, 1.0);
|
||||
}
|
||||
|
||||
/// Where a fraction sits on the track. The inverse of `fraction-at`, and
|
||||
/// it has to stay so: the band is drawn with this and dragged with that.
|
||||
function y-at(f: float) -> length {
|
||||
return root.track-top + clamp(f, 0.0, 1.0) * root.track-height;
|
||||
}
|
||||
|
||||
/// Which end of the range a press at `y` takes hold of, 0 for neither.
|
||||
///
|
||||
/// Compared in fractions rather than pixels because the ends are held as
|
||||
/// fractions — converting them to a y here and back to a fraction on the
|
||||
/// drag would be a second copy of the same arithmetic to keep in step.
|
||||
function handle-at(y: length) -> int {
|
||||
if (!root.has-range) {
|
||||
return 0;
|
||||
}
|
||||
if (min(abs(root.fraction-at(y) - root.shown-from),
|
||||
abs(root.fraction-at(y) - root.shown-to)) > root.grab) {
|
||||
return 0;
|
||||
}
|
||||
// A range narrowed to a day draws one line, not two. Taking the
|
||||
// nearer end there would always answer "the early one" and leave the
|
||||
// late end impossible to pull back out; which side of the line the
|
||||
// press landed on is what the user meant by it.
|
||||
if (abs(root.shown-from - root.shown-to) < 0.001) {
|
||||
return root.fraction-at(y) > root.shown-from ? 2 : 1;
|
||||
}
|
||||
return abs(root.fraction-at(y) - root.shown-from)
|
||||
< abs(root.fraction-at(y) - root.shown-to) ? 1 : 2;
|
||||
}
|
||||
|
||||
/// Move the held end to `y`.
|
||||
///
|
||||
/// The ends are allowed to cross. Dragging one past the other is a normal
|
||||
/// way to say "no, from *here* instead", and Rust puts the pair back in
|
||||
/// order — stopping the finger dead at the other end would make a range
|
||||
/// that has been narrowed too far harder to correct than to start again.
|
||||
function drag-end(y: length) {
|
||||
if (root.grabbed == 1) {
|
||||
root.live-from = root.fraction-at(y);
|
||||
} else {
|
||||
root.live-to = root.fraction-at(y);
|
||||
}
|
||||
}
|
||||
|
||||
// Header: the hovered bucket, else the whole span.
|
||||
Caption {
|
||||
x: 8px;
|
||||
@@ -183,10 +258,68 @@ export component Timeline inherits Rectangle {
|
||||
background: Theme.active;
|
||||
opacity: root.anchored ? 1.0 : 0.35;
|
||||
y: root.anchored && root.current-fraction >= 0
|
||||
? root.track-top + clamp(root.current-fraction, 0.0, 1.0) * root.track-height
|
||||
? root.y-at(root.current-fraction)
|
||||
: root.track-top + root.track-height / 2;
|
||||
}
|
||||
|
||||
// --- the chosen date range ------------------------------------------
|
||||
//
|
||||
// Drawn *on* the axis, and dragged there. The two typed date fields under
|
||||
// the filter chips were the only way to state a range, and on a phone they
|
||||
// were barely a way at all: `YYYY-MM-DD` keyed into a 108px field behind a
|
||||
// soft keyboard, to name a day that is already drawn on the axis a thumb
|
||||
// away. The other route — zoom the axis to a period, then "limit to range"
|
||||
// — needs a wheel to zoom and a middle button to pan, and a touch screen
|
||||
// has neither.
|
||||
//
|
||||
// The axis keeps its full extent while a range is on (see
|
||||
// `refresh_timeline`, and `Filter::without_date_range`). It has to: the
|
||||
// bars outside the band are what the range is widened back *into*, and an
|
||||
// axis that redrew itself to the band would move the ground under the very
|
||||
// handles doing the narrowing.
|
||||
if root.has-range: Rectangle {
|
||||
// Outside the range, held back rather than hidden — those bars are
|
||||
// still the shape of the library, and still where the band is going
|
||||
// next. Starts at the track, so the header line stays legible.
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: root.track-top;
|
||||
width: parent.width;
|
||||
height: max(0px, root.y-at(min(root.shown-from, root.shown-to)) - root.track-top);
|
||||
background: Theme.surface;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: root.y-at(max(root.shown-from, root.shown-to));
|
||||
width: parent.width;
|
||||
height: max(0px, parent.height - self.y);
|
||||
background: Theme.surface;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
// The ends themselves: a line across the axis with a grip at the left,
|
||||
// where the bars start. The right-hand strip carries the year and
|
||||
// month labels, and a grip there would sit on top of them.
|
||||
for edge[i] in [0, 1]: Rectangle {
|
||||
x: 0;
|
||||
y: root.y-at(i == 0 ? root.shown-from : root.shown-to) - 1px;
|
||||
width: parent.width;
|
||||
height: 2px;
|
||||
background: Theme.active;
|
||||
|
||||
Rectangle {
|
||||
x: 2px;
|
||||
y: -5px;
|
||||
width: 18px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
background: Theme.active;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The single hit area. Everything above is inert.
|
||||
// Two-finger pinch, for tablet. There is no wheel there, so without this
|
||||
// the axis could only be zoomed by a control a finger cannot reach.
|
||||
@@ -218,7 +351,11 @@ export component Timeline inherits Rectangle {
|
||||
property <bool> panning;
|
||||
|
||||
moved => {
|
||||
if (self.panning) {
|
||||
if (root.grabbed > 0) {
|
||||
// The band follows the finger. The grid hears about it on
|
||||
// release — see `range-changed`.
|
||||
root.drag-end(self.mouse-y);
|
||||
} else if (self.panning) {
|
||||
// Fractional, so a slow drag moves the view continuously
|
||||
// rather than sitting still until it crosses a bucket edge.
|
||||
root.pan((self.press-y - self.mouse-y) / max(1px, root.track-height));
|
||||
@@ -236,11 +373,31 @@ export component Timeline inherits Rectangle {
|
||||
// modifier state belongs to the key handler, not the pointer
|
||||
// event, and a middle-drag is the unambiguous gesture.
|
||||
self.panning = e.button == PointerEventButton.middle;
|
||||
if (!self.panning && root.bars.length > 0) {
|
||||
// An end of the range first, if the press landed on one: a
|
||||
// press within a finger's width of a handle means the handle,
|
||||
// not the scrub it would otherwise have been.
|
||||
root.grabbed = self.panning ? 0 : root.handle-at(self.mouse-y);
|
||||
if (root.grabbed > 0) {
|
||||
root.live-from = root.range-from;
|
||||
root.live-to = root.range-to;
|
||||
} else if (!self.panning && root.bars.length > 0) {
|
||||
root.scrub-to(root.fraction-at(self.mouse-y));
|
||||
}
|
||||
}
|
||||
if (e.kind == PointerEventKind.up) { self.panning = false; }
|
||||
if (e.kind == PointerEventKind.up) {
|
||||
self.panning = false;
|
||||
if (root.grabbed > 0) {
|
||||
root.grabbed = 0;
|
||||
root.range-changed(root.live-from, root.live-to);
|
||||
}
|
||||
}
|
||||
// A cancelled gesture — the system taking the touch for a
|
||||
// back-swipe, most often — is not a range the user stated. The
|
||||
// band goes back to where Rust still has it.
|
||||
if (e.kind == PointerEventKind.cancel) {
|
||||
self.panning = false;
|
||||
root.grabbed = 0;
|
||||
}
|
||||
}
|
||||
|
||||
scroll-event(e) => {
|
||||
@@ -1105,6 +1262,16 @@ export component LibraryGrid inherits Rectangle {
|
||||
in property <bool> range-active: false;
|
||||
callback toggle-date-range();
|
||||
/// TRACES: FR-CAT-6
|
||||
/// The same range as fractions of the span the axis is drawn over, for
|
||||
/// the band on the timeline. Negative in both when there is none.
|
||||
///
|
||||
/// A second representation of one range, which is worth it: the fields
|
||||
/// below are how a date is *stated* and the band is how a period is
|
||||
/// *found*, and neither is a good way to do the other's job. Both come
|
||||
/// from the one filter in Rust, so they cannot drift apart.
|
||||
in property <float> range-from-fraction: -1;
|
||||
in property <float> range-to-fraction: -1;
|
||||
callback timeline-range-changed(float, float);
|
||||
/// The ends of the range, as `YYYY-MM-DD`, when one is set.
|
||||
///
|
||||
/// Strings rather than instants because this is what the user types and
|
||||
@@ -1736,10 +1903,12 @@ export component LibraryGrid inherits Rectangle {
|
||||
|
||||
Rectangle { width: Theme.gap; }
|
||||
|
||||
// Narrow the grid to the span the timeline is showing. The
|
||||
// histogram is already how you find a period, so the range is
|
||||
// taken from it rather than typed: zoom to the fortnight you
|
||||
// want, then say "only that". Clicking again lifts it.
|
||||
// Turn the range on, over the span the timeline is showing.
|
||||
// The histogram is already how you find a period, so this is
|
||||
// a starting point rather than the answer: the band it puts on
|
||||
// the axis is then dragged to the fortnight you meant, which
|
||||
// is the one way in that needs neither a keyboard nor a wheel.
|
||||
// Pressing again lifts the range.
|
||||
//
|
||||
// The histogram deliberately keeps drawing the full extent
|
||||
// while this is on, or there would be nothing left to widen
|
||||
@@ -1843,6 +2012,11 @@ export component LibraryGrid inherits Rectangle {
|
||||
// into the chip row — which is 34px tall and scrolls sideways, so
|
||||
// they were both clipped and off past the right-hand edge.
|
||||
//
|
||||
// They are no longer the only way to state a range — the band on the
|
||||
// axis is, and on a phone it is the only usable one. These stay for
|
||||
// the two things dragging cannot do: name an exact day, and say what
|
||||
// the range currently is in words rather than as a position.
|
||||
//
|
||||
// A `Rectangle` stacks its children at the origin rather than laying
|
||||
// them out, which is why putting a second row inside the chips' one
|
||||
// drew it over them instead of under them. This is a row of the
|
||||
@@ -2024,7 +2198,10 @@ export component LibraryGrid inherits Rectangle {
|
||||
current-start: root.current-bucket;
|
||||
current-fraction: root.current-bucket-fraction;
|
||||
anchored: root.timeline-anchored;
|
||||
range-from: root.range-from-fraction;
|
||||
range-to: root.range-to-fraction;
|
||||
|
||||
range-changed(a, b) => { root.timeline-range-changed(a, b); }
|
||||
scrub-to(f) => { root.scrub-fraction(f); }
|
||||
pinch(r) => { root.timeline-pinch(r); }
|
||||
pan(d) => { root.timeline-pan(d); }
|
||||
@@ -2316,6 +2493,28 @@ export component LibraryGrid inherits Rectangle {
|
||||
// window is swapped underneath to match.
|
||||
viewport-height: root.total-rows * (root.cell-size + Theme.gap) + Theme.gap;
|
||||
|
||||
// TRACES: FR-CAT-7
|
||||
// Stay inside the content when the content shrinks.
|
||||
//
|
||||
// Deleting photographs makes the library shorter, and the
|
||||
// viewport is sized to the *whole* library — so a view that was
|
||||
// scrolled near the end is suddenly scrolled past it. Slint
|
||||
// does not pull a Flickable back on its own, so the grid went
|
||||
// blank: the cells were still there, above a viewport looking
|
||||
// at empty space below them.
|
||||
//
|
||||
// Worse than blank, it also *moved*. Cells are drawn at
|
||||
// `(i + offset) / columns`, and a delete re-clamps `offset`
|
||||
// downward to keep the window full — so the same `viewport-y`
|
||||
// now points at a different part of the library, and the grid
|
||||
// appeared to jump somewhere arbitrary. Re-seeking below is the
|
||||
// other half of this; this half stops the blank.
|
||||
changed viewport-height => {
|
||||
if (-self.viewport-y > max(0px, self.viewport-height - self.height)) {
|
||||
self.viewport-y = -max(0px, self.viewport-height - self.height);
|
||||
}
|
||||
}
|
||||
|
||||
// Report the first fully-scrolled-past row so Rust can move the
|
||||
// window. Derived rather than eventful: Slint has no scroll
|
||||
// callback, and a `changed` handler on a derived integer fires only
|
||||
|
||||
Reference in New Issue
Block a user