Merge branch 'worktree-agent-abfe489c84c337e7c' into integration
This commit is contained in:
+24
-1
@@ -2,7 +2,7 @@ import { Theme } from "theme.slint";
|
||||
import { AdjustPanel, GeometryPanel, ModeStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
|
||||
import { GradientHandle, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
|
||||
import { LaunchScreen } from "launch.slint";
|
||||
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll } from "library.slint";
|
||||
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow } from "library.slint";
|
||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
||||
import { HistogramPanel, HistogramView } from "histogram.slint";
|
||||
@@ -589,6 +589,25 @@ export component AppWindow inherits Window {
|
||||
/// the target's id, and whether to take the images out of the collection
|
||||
/// currently being shown.
|
||||
callback library-file-in-collection(int, bool);
|
||||
/// TRACES: FR-CAT-5 | FR-CAT-6
|
||||
/// Keywording the grid's selection. The catalog has been searchable by
|
||||
/// keyword since it existed and there was nowhere to type one; this is it.
|
||||
///
|
||||
/// The vocabulary arrives already answered against the selection — each row
|
||||
/// says how many of the selected photographs carry that word — because only
|
||||
/// Rust knows what is selected, and a `.slint` file counting it would need
|
||||
/// the selection as a second model that could disagree with the first.
|
||||
in property <[KeywordRow]> library-keywords;
|
||||
/// The sheet is opening: recompute the rows against the selection as it
|
||||
/// stands now. Pulled rather than pushed, because the selection changes on
|
||||
/// every arrow key and the sheet is shut for almost all of them.
|
||||
callback library-keywords-opened();
|
||||
/// Put a keyword on the selection, creating it if it is new. By name, so a
|
||||
/// word typed into the field and a word tapped in the list are one path.
|
||||
callback library-assign-keyword(string);
|
||||
/// Take a keyword off the selection. Never deletes the keyword itself —
|
||||
/// it stays in the vocabulary and on every other photograph that carries it.
|
||||
callback library-unassign-keyword(string);
|
||||
/// TRACES: FR-UI-2
|
||||
/// Whether a tap in the grid selects rather than opens, and the button
|
||||
/// that turns it on. The long press does the same thing without it.
|
||||
@@ -1268,6 +1287,10 @@ in property <bool> panel-visible: true;
|
||||
file-in-collection(id, moves) => {
|
||||
root.library-file-in-collection(id, moves);
|
||||
}
|
||||
keywords: root.library-keywords;
|
||||
keywords-opened() => { root.library-keywords-opened(); }
|
||||
assign-keyword(word) => { root.library-assign-keyword(word); }
|
||||
unassign-keyword(word) => { root.library-unassign-keyword(word); }
|
||||
cursor: root.library-cursor;
|
||||
move-cursor(delta, extend) => {
|
||||
root.library-move-cursor(delta, extend);
|
||||
|
||||
+269
-1
@@ -9,12 +9,38 @@
|
||||
// must not look identical (FR-NC-6c).
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon } from "widgets.slint";
|
||||
import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon, Field } from "widgets.slint";
|
||||
// The filing sheet lists the same rows the sidebar draws, from the same model:
|
||||
// two lists of collections that could disagree about what exists is one list
|
||||
// too many.
|
||||
import { CollectionRow } from "collections.slint";
|
||||
|
||||
// TRACES: FR-CAT-5
|
||||
// One keyword in the keywording sheet, already answered against the selection.
|
||||
//
|
||||
// The three-way `coverage` is the whole reason this is a struct rather than a
|
||||
// list of strings. Applying a word to forty photographs where thirty already
|
||||
// carry it must not look like applying it to forty that carry none, and
|
||||
// removing one that only some of them carry must not silently claim to have
|
||||
// taken it off all forty. Rust computes it, because only Rust knows how big the
|
||||
// selection is and how many of it each word covers.
|
||||
export struct KeywordRow {
|
||||
// Row id in `keyword_terms`, or 0 for a word an image carries that the
|
||||
// vocabulary has no identity for yet. The sheet acts on `name`, never on
|
||||
// this, so a 0 costs nothing — it is here so a future rename gesture has
|
||||
// something to name.
|
||||
id: int,
|
||||
name: string,
|
||||
// 0 none of the selection, 1 some of it, 2 all of it.
|
||||
coverage: int,
|
||||
// How many of the selected photographs carry it, for the "3 of 12" that
|
||||
// makes `coverage: 1` a number rather than a shrug.
|
||||
selected-count: int,
|
||||
// How many photographs in the whole library carry it. Lets a word in
|
||||
// regular use be told from one typed once by mistake.
|
||||
image-count: int,
|
||||
}
|
||||
|
||||
// One bar of the capture-time histogram.
|
||||
export struct TimelineBar {
|
||||
// 0..1, relative to the tallest bucket. Square-rooted in Rust so a quiet
|
||||
@@ -656,6 +682,9 @@ component HeaderActions inherits HorizontalLayout {
|
||||
callback remove-from-collection();
|
||||
/// Open the sheet that files the selection in a collection.
|
||||
callback add-to-collection();
|
||||
/// TRACES: FR-CAT-5
|
||||
/// Open the sheet that keywords the selection.
|
||||
callback add-keyword();
|
||||
callback toggle-select-mode();
|
||||
callback change-library();
|
||||
callback toggle-pin-scope();
|
||||
@@ -694,6 +723,17 @@ component HeaderActions inherits HorizontalLayout {
|
||||
clicked => { root.add-to-collection(); }
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-5 | FR-CAT-6
|
||||
// Keyword the selection. Beside "Add to collection" because they are the
|
||||
// same thought — these photographs are *of* something, and they belong
|
||||
// *with* something — and appearing under the same condition, because
|
||||
// neither means anything without a selection to act on.
|
||||
if root.selected-count > 0: Button {
|
||||
text: "Keywords";
|
||||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||||
clicked => { root.add-keyword(); }
|
||||
}
|
||||
|
||||
// TRACES: FR-DEV-6
|
||||
// Batch-apply the copied settings. Shown only with both a selection and a
|
||||
// clipboard, because it is meaningless without either — and because a
|
||||
@@ -1105,6 +1145,34 @@ export component LibraryGrid inherits Rectangle {
|
||||
/// out of the one currently being shown.
|
||||
callback file-in-collection(int, bool);
|
||||
|
||||
// --- keywording the selection (FR-CAT-5, FR-CAT-6) ----------------------
|
||||
//
|
||||
// The catalog has been searchable by keyword since it existed and there was
|
||||
// never anywhere to type one. This sheet is that place, and it sits beside
|
||||
// the filing sheet above because the two are the same gesture applied to
|
||||
// two different kinds of label — pick the photographs, then say what they
|
||||
// are — and a user who has learnt one should not have to learn the other.
|
||||
//
|
||||
// Assign and unassign travel by **name**, not by id. A word typed into the
|
||||
// field and a word tapped in the list are then one path through Rust rather
|
||||
// than two, and the sheet does not have to invent an id for a keyword that
|
||||
// does not exist yet.
|
||||
/// The vocabulary, already answered against the current selection.
|
||||
in property <[KeywordRow]> keywords;
|
||||
/// The sheet is opening: Rust answers by refreshing `keywords` against
|
||||
/// whatever is selected *now*.
|
||||
///
|
||||
/// Pulled on open rather than pushed on every selection change, because the
|
||||
/// selection changes on every arrow key and the sheet is shut for almost
|
||||
/// all of them — recomputing coverage over a forty-image selection for a
|
||||
/// panel nobody is looking at is work the grid cannot afford.
|
||||
callback keywords-opened();
|
||||
callback assign-keyword(string);
|
||||
callback unassign-keyword(string);
|
||||
/// Whether the sheet is up. Local, for the same reason `filing` is: it is a
|
||||
/// disclosure rather than a preference, and what closes it is dismissing it.
|
||||
property <bool> keywording: false;
|
||||
|
||||
// Cell geometry. Columns are derived from the available width so the grid
|
||||
// reflows with the window rather than fixing a count (FR-UI-1).
|
||||
// Zoomable, so the grid serves both jobs: fewer, larger images for
|
||||
@@ -1355,6 +1423,14 @@ export component LibraryGrid inherits Rectangle {
|
||||
// to is still there when the sheet closes.
|
||||
root.actions-open = false;
|
||||
}
|
||||
add-keyword => {
|
||||
// Ask for the vocabulary before showing the sheet, so
|
||||
// it is answered against the selection as it stands now
|
||||
// rather than as it stood when the grid last loaded.
|
||||
root.keywords-opened();
|
||||
root.keywording = true;
|
||||
root.actions-open = false;
|
||||
}
|
||||
change-library => { root.change-library(); }
|
||||
toggle-pin-scope => { root.toggle-pin-scope(); }
|
||||
sync-now => { root.sync-now(); }
|
||||
@@ -1429,6 +1505,14 @@ export component LibraryGrid inherits Rectangle {
|
||||
// to is still there when the sheet closes.
|
||||
root.actions-open = false;
|
||||
}
|
||||
add-keyword => {
|
||||
// Ask for the vocabulary before showing the sheet, so
|
||||
// it is answered against the selection as it stands now
|
||||
// rather than as it stood when the grid last loaded.
|
||||
root.keywords-opened();
|
||||
root.keywording = true;
|
||||
root.actions-open = false;
|
||||
}
|
||||
change-library => { root.change-library(); }
|
||||
toggle-pin-scope => { root.toggle-pin-scope(); }
|
||||
sync-now => { root.sync-now(); }
|
||||
@@ -1788,6 +1872,10 @@ export component LibraryGrid inherits Rectangle {
|
||||
// button, and a sheet it walked straight past would leave
|
||||
// the user out of the grid with their selection gone.
|
||||
if (event.text == Key.Back || event.text == Key.Escape) {
|
||||
if (root.keywording) {
|
||||
root.keywording = false;
|
||||
return accept;
|
||||
}
|
||||
if (root.filing) {
|
||||
root.filing = false;
|
||||
return accept;
|
||||
@@ -2486,4 +2574,184 @@ export component LibraryGrid inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- the keywording sheet (FR-CAT-5, FR-CAT-6) --------------------------
|
||||
//
|
||||
// "These are of…". Deliberately the same card, scrim and dismissal as the
|
||||
// filing sheet above: a user who has filed a selection already knows how
|
||||
// this works, and a second idiom for the same gesture would be a second
|
||||
// thing to learn for no gain.
|
||||
//
|
||||
// It stays open after each word, where the filing sheet closes. Filing is
|
||||
// one choice; keywording is usually several — "puffin", "Látrabjarg",
|
||||
// "2026" — and a sheet that shut after each one would have to be reopened,
|
||||
// and the selection re-confirmed, three times over.
|
||||
if root.keywording: Rectangle {
|
||||
background: #000000CC;
|
||||
|
||||
// Swallows the taps that miss the card, and closes. First, so the
|
||||
// card's own controls sit above it.
|
||||
TouchArea {
|
||||
clicked => { root.keywording = false; }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: min(420px, parent.width - 2 * Theme.gap-lg);
|
||||
height: min(kw-sheet.preferred-height, parent.height - 2 * Theme.gap-lg);
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
background: Theme.surface;
|
||||
border-radius: Theme.radius;
|
||||
border-width: 1px;
|
||||
border-color: Theme.rule;
|
||||
|
||||
// Stops a press on the card reaching the scrim behind it.
|
||||
TouchArea { }
|
||||
|
||||
kw-sheet := VerticalLayout {
|
||||
padding: Theme.gap-lg;
|
||||
spacing: Theme.gap;
|
||||
|
||||
Text {
|
||||
text: root.selected-count == 1
|
||||
? "Keywords for 1 photograph"
|
||||
: "Keywords for " + root.selected-count + " photographs";
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text-lg;
|
||||
font-weight: 600;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
// Typing a word applies it, whether or not it already exists.
|
||||
// One field for both, because "is this keyword new?" is a
|
||||
// question about the catalog and not about what the user meant,
|
||||
// and Rust can answer it without being asked.
|
||||
//
|
||||
// The field clears itself on accept so the next word can be
|
||||
// typed straight after — keywording a shoot is a run of them.
|
||||
new-keyword := Field {
|
||||
placeholder: "Type a keyword and press return";
|
||||
accepted(text) => {
|
||||
root.assign-keyword(text);
|
||||
self.text = "";
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.rule; }
|
||||
|
||||
Flickable {
|
||||
vertical-stretch: 1;
|
||||
// A floor, so the list is not squeezed out of existence by
|
||||
// the field and the button around it on a short window.
|
||||
min-height: 120px;
|
||||
viewport-height: root.keywords.length * (Theme.touch-target + 2px);
|
||||
|
||||
for word[i] in root.keywords: Rectangle {
|
||||
y: i * (Theme.touch-target + 2px);
|
||||
width: parent.width;
|
||||
// A full touch target per row, for the same reason the
|
||||
// filing sheet uses one: this is a place to hit once,
|
||||
// with a thumb, holding a selection that took a minute
|
||||
// to build (FR-UI-3).
|
||||
height: Theme.touch-target;
|
||||
background: kw-touch.pressed ? Theme.pressed
|
||||
: (kw-touch.has-hover ? Theme.hover : transparent);
|
||||
border-radius: Theme.radius-sm;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap-sm;
|
||||
padding-right: Theme.gap-sm;
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
// Tick, dash, or nothing — the three states of
|
||||
// `coverage`, drawn as three different marks rather
|
||||
// than as two. A half-applied keyword shown as
|
||||
// applied is a lie about photographs the user
|
||||
// cannot see from here.
|
||||
Rectangle {
|
||||
width: 16px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
height: 16px;
|
||||
border-radius: Theme.radius-sm;
|
||||
border-width: 1px;
|
||||
border-color: word.coverage == 0 ? Theme.rule : Theme.active;
|
||||
background: word.coverage == 2 ? Theme.active : transparent;
|
||||
|
||||
// The dash for "some of them". A bar rather
|
||||
// than a tick, because a tick at half strength
|
||||
// reads as a rendering artefact.
|
||||
if word.coverage == 1: Rectangle {
|
||||
width: 8px;
|
||||
height: 2px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
background: Theme.active;
|
||||
}
|
||||
if word.coverage == 2: Icon {
|
||||
name: "check";
|
||||
ink: Theme.surface;
|
||||
size: 12px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: word.name;
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
|
||||
// "3 of 12" only where it says something the mark
|
||||
// does not. For a word the whole selection carries,
|
||||
// or none of it, the mark has already said it and
|
||||
// the number would be noise on every row.
|
||||
Text {
|
||||
text: word.coverage == 1
|
||||
? word.selected-count + " of " + root.selected-count
|
||||
: (word.image-count > 0 ? word.image-count + "" : "");
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
// One target for both directions. A word the selection
|
||||
// fully carries comes off; anything else goes on — so a
|
||||
// partly-applied keyword is completed rather than
|
||||
// removed, which is what a user tapping a dash means
|
||||
// nine times in ten, and the tenth is one more tap
|
||||
// away.
|
||||
kw-touch := TouchArea {
|
||||
clicked => {
|
||||
if (word.coverage == 2) {
|
||||
root.unassign-keyword(word.name);
|
||||
} else {
|
||||
root.assign-keyword(word.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if root.keywords.length == 0: Text {
|
||||
text: "No keywords yet. Type one above to make the first.";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
wrap: word-wrap;
|
||||
width: parent.width;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.rule; }
|
||||
|
||||
Button {
|
||||
text: "Done";
|
||||
clicked => { root.keywording = false; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user