Merge branch 'android-collections'
Build and test / Desktop (Linux) (push) Failing after 18m53s
Build and test / Layer separation (push) Successful in 41s
Traceability / Requirement traces (push) Failing after 1m4s
🐳 Android image / Build and push (push) Successful in 6s
Build and test / android-image (push) Successful in 5s
Build and test / Android (aarch64) (push) Failing after 9m40s
Build and test / Desktop (Linux) (push) Failing after 18m53s
Build and test / Layer separation (push) Successful in 41s
Traceability / Requirement traces (push) Failing after 1m4s
🐳 Android image / Build and push (push) Successful in 6s
Build and test / android-image (push) Successful in 5s
Build and test / Android (aarch64) (push) Failing after 9m40s
Touch multi-selection in the grid, filing a selection into a collection without a drag, and taking a collection offline from a held row.
This commit is contained in:
@@ -41,7 +41,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use dr_types::{ImageId, Tier};
|
use dr_types::{ImageId, Tier};
|
||||||
use rusqlite::Connection;
|
use rusqlite::{Connection, OptionalExtension as _};
|
||||||
|
|
||||||
use crate::error::CatalogError;
|
use crate::error::CatalogError;
|
||||||
|
|
||||||
@@ -299,6 +299,71 @@ impl Cache {
|
|||||||
self.set_pinned(conn, images, false)
|
self.set_pinned(conn, images, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Release the pin *and* delete the bytes it was holding.
|
||||||
|
///
|
||||||
|
/// The destructive half of the pair [`unpin`](Self::unpin) deliberately is
|
||||||
|
/// not. Unpinning answers "stop promising"; this answers "give me the disk
|
||||||
|
/// back", which is the question actually being asked when a trip is over
|
||||||
|
/// and the device is full. Leaving those gigabytes to sit until some future
|
||||||
|
/// eviction happens to want the room is not an answer to it.
|
||||||
|
///
|
||||||
|
/// Nothing is lost that cannot be fetched again: the original lives on the
|
||||||
|
/// server, and the catalog row, the ratings and the edit graph are all
|
||||||
|
/// untouched here — they are authoritative and small (FR-NC-6b).
|
||||||
|
///
|
||||||
|
/// Returns how many images were released and how many bytes that freed.
|
||||||
|
/// A file that has already vanished frees nothing and is still counted as
|
||||||
|
/// released, because the row describing it goes either way.
|
||||||
|
pub fn release(
|
||||||
|
&self,
|
||||||
|
conn: &Connection,
|
||||||
|
images: &[ImageId],
|
||||||
|
) -> Result<(usize, u64), CatalogError> {
|
||||||
|
if images.is_empty() {
|
||||||
|
return Ok((0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the paths before the rows are rewritten: `forget` clears `path`,
|
||||||
|
// and a file whose name has been forgotten cannot be deleted.
|
||||||
|
let mut held = Vec::new();
|
||||||
|
{
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT bytes, path FROM image_cache
|
||||||
|
WHERE image_id = ?1 AND path IS NOT NULL",
|
||||||
|
)?;
|
||||||
|
for image in images {
|
||||||
|
if let Some(row) = stmt
|
||||||
|
.query_row(rusqlite::params![image.0 as i64], |r| {
|
||||||
|
Ok((r.get::<_, i64>(0)? as u64, r.get::<_, String>(1)?))
|
||||||
|
})
|
||||||
|
.optional()?
|
||||||
|
{
|
||||||
|
held.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut freed = 0u64;
|
||||||
|
for (bytes, rel) in &held {
|
||||||
|
let abs = self.dir.join(rel);
|
||||||
|
match std::fs::remove_file(&abs) {
|
||||||
|
Ok(()) => freed += bytes,
|
||||||
|
// Already gone is the ordinary case after a crash mid-write,
|
||||||
|
// not a failure: the row still has to go, or the cache accounts
|
||||||
|
// for space nothing occupies.
|
||||||
|
Err(e) => log::debug!("releasing {}: {e}", abs.display()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unpin first, then forget. The other order would leave a pinned row
|
||||||
|
// claiming an original it no longer has, which `pending_pins` would
|
||||||
|
// then dutifully download again — the exact opposite of what was asked.
|
||||||
|
self.set_pinned(conn, images, false)?;
|
||||||
|
self.forget(conn, images)?;
|
||||||
|
|
||||||
|
Ok((images.len(), freed))
|
||||||
|
}
|
||||||
|
|
||||||
fn set_pinned(
|
fn set_pinned(
|
||||||
&self,
|
&self,
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
@@ -609,6 +674,66 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn releasing_a_pin_frees_the_disk_it_was_holding() {
|
||||||
|
// What "remove the local copies" has to mean. Unpinning alone leaves
|
||||||
|
// the bytes for a future eviction to notice, which is no answer at all
|
||||||
|
// to a device that is full now.
|
||||||
|
let (cat, cache, dir, ids) = fixture(2);
|
||||||
|
let bytes = vec![0u8; 700];
|
||||||
|
cache
|
||||||
|
.store(cat.connection(), ids[0], "a.CR2", &bytes, true, 10)
|
||||||
|
.unwrap();
|
||||||
|
cache
|
||||||
|
.store(cat.connection(), ids[1], "b.CR2", &bytes, true, 20)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (released, freed) = cache.release(cat.connection(), &ids).unwrap();
|
||||||
|
assert_eq!(released, 2);
|
||||||
|
assert_eq!(freed, 1400);
|
||||||
|
|
||||||
|
assert!(!cache.holds_original(cat.connection(), ids[0]));
|
||||||
|
assert_eq!(cache.usage(cat.connection()).unwrap().pinned_bytes, 0);
|
||||||
|
|
||||||
|
// The files themselves, not just the bookkeeping: a row cleared over a
|
||||||
|
// file still on disk is how a cache comes to hold gigabytes it does not
|
||||||
|
// know about.
|
||||||
|
let left: Vec<_> = walk_files(&dir);
|
||||||
|
assert!(left.is_empty(), "files remain on disk: {left:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_released_pin_is_not_downloaded_all_over_again() {
|
||||||
|
// The failure mode of releasing in the wrong order: bytes deleted while
|
||||||
|
// the row still says an original is wanted, so the next pin fetch pulls
|
||||||
|
// the whole trip back down.
|
||||||
|
let (cat, cache, _dir, ids) = fixture(1);
|
||||||
|
cache
|
||||||
|
.store(cat.connection(), ids[0], "a.CR2", &vec![0u8; 100], true, 10)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
cache.release(cat.connection(), &ids).unwrap();
|
||||||
|
|
||||||
|
assert!(cache.pending_pins(cat.connection()).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every file under `dir`, for asserting that a release left nothing.
|
||||||
|
fn walk_files(dir: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
out.extend(walk_files(&path));
|
||||||
|
} else {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pinned_bytes_do_not_count_against_the_budget() {
|
fn pinned_bytes_do_not_count_against_the_budget() {
|
||||||
// Otherwise a large pin starves the passive cache into evicting
|
// Otherwise a large pin starves the passive cache into evicting
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ pub struct CollectionsController {
|
|||||||
/// Selection is by id for the same reason (see the preamble); this is the
|
/// Selection is by id for the same reason (see the preamble); this is the
|
||||||
/// same argument applied to the one index that has to survive a move.
|
/// same argument applied to the one index that has to survive a move.
|
||||||
anchor: RefCell<Option<usize>>,
|
anchor: RefCell<Option<usize>>,
|
||||||
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
/// Where the anchor was *before* the press that moved it.
|
||||||
|
///
|
||||||
|
/// The touch equivalent of shift-click needs this. A double tap is two
|
||||||
|
/// presses, and both of them move the anchor onto the cell being tapped —
|
||||||
|
/// so by the time the double tap is reported, "the range from the anchor to
|
||||||
|
/// here" describes a single cell. This remembers the cell the user actually
|
||||||
|
/// started from, which is the one they mean.
|
||||||
|
///
|
||||||
|
/// Updated only when a press *moves* the anchor, so the second tap of a
|
||||||
|
/// double tap — which lands on the cell that is already the anchor — leaves
|
||||||
|
/// it pointing where the first tap left it.
|
||||||
|
previous_anchor: std::cell::Cell<Option<usize>>,
|
||||||
/// Where the keyboard is, as an image ordinal.
|
/// Where the keyboard is, as an image ordinal.
|
||||||
///
|
///
|
||||||
/// Distinct from the anchor, and it has to be: shift+arrow grows a range
|
/// Distinct from the anchor, and it has to be: shift+arrow grows a range
|
||||||
@@ -82,6 +95,23 @@ pub struct CollectionsController {
|
|||||||
/// develop view on the second ctrl-click. Slint does not report modifiers on
|
/// develop view on the second ctrl-click. Slint does not report modifiers on
|
||||||
/// `clicked`, so the press records them and the click consults this.
|
/// `clicked`, so the press records them and the click consults this.
|
||||||
modified_press: std::cell::Cell<bool>,
|
modified_press: std::cell::Cell<bool>,
|
||||||
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
/// Whether a tap in the grid selects rather than opens.
|
||||||
|
///
|
||||||
|
/// Touch has no ctrl and no shift, so without a mode there is no way to
|
||||||
|
/// select a second photograph: the first tap would open the first one. In
|
||||||
|
/// this mode a plain press is reported as a ctrl-press and goes through the
|
||||||
|
/// same [`apply_press`] as everything else — a separate touch policy would
|
||||||
|
/// be a second copy of these rules to keep in step.
|
||||||
|
select_mode: std::cell::Cell<bool>,
|
||||||
|
/// The timer that turns a held cell into a selection.
|
||||||
|
///
|
||||||
|
/// Here rather than in `.slint` because Slint has no long-press gesture and
|
||||||
|
/// a hand-rolled one would need a `Timer` element per visible cell — a
|
||||||
|
/// hundred timers to answer a question about one finger. Held so that
|
||||||
|
/// dropping it cancels: a press that ends, or is taken by the Flickable
|
||||||
|
/// when the finger travels, must not arrive as a selection a moment later.
|
||||||
|
hold_timer: RefCell<Option<slint::Timer>>,
|
||||||
/// Collection ids parallel to the sidebar's rows, so a hovered row index
|
/// Collection ids parallel to the sidebar's rows, so a hovered row index
|
||||||
/// resolves to an id without another query.
|
/// resolves to an id without another query.
|
||||||
row_ids: RefCell<Vec<CollectionId>>,
|
row_ids: RefCell<Vec<CollectionId>>,
|
||||||
@@ -305,6 +335,55 @@ pub fn apply_press(
|
|||||||
*anchor = Some(here);
|
*anchor = Some(here);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
/// Apply a press, and remember where the anchor was before it moved.
|
||||||
|
///
|
||||||
|
/// The bookkeeping a double tap depends on, split out from the callback so the
|
||||||
|
/// touch sequence — hold one cell, double-tap another, get the run between —
|
||||||
|
/// can be tested without a window. See [`CollectionsController::previous_anchor`]
|
||||||
|
/// for why the *previous* anchor is the one a double tap means.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn press_remembering_anchor(
|
||||||
|
selection: &mut BTreeSet<ImageId>,
|
||||||
|
anchor: &mut Option<usize>,
|
||||||
|
previous: &mut Option<usize>,
|
||||||
|
ids: &[ImageId],
|
||||||
|
offset: usize,
|
||||||
|
row: usize,
|
||||||
|
ctrl: bool,
|
||||||
|
shift: bool,
|
||||||
|
) {
|
||||||
|
let before = *anchor;
|
||||||
|
apply_press(selection, anchor, ids, offset, row, ctrl, shift);
|
||||||
|
// Only a press that *moved* the anchor updates this. The second tap of a
|
||||||
|
// double tap lands on the cell the first tap made the anchor, so it changes
|
||||||
|
// nothing and the origin survives to be extended from.
|
||||||
|
if *anchor != before {
|
||||||
|
*previous = before;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
/// Extend the selection to `row` — the touch form of a shift-click.
|
||||||
|
///
|
||||||
|
/// Added to the selection rather than replacing it: this is reached only from
|
||||||
|
/// selection mode, which the user entered deliberately, and a gesture that
|
||||||
|
/// silently discarded the run they gathered a moment ago would make gathering
|
||||||
|
/// two runs impossible.
|
||||||
|
pub fn extend_to_row(
|
||||||
|
selection: &mut BTreeSet<ImageId>,
|
||||||
|
anchor: &mut Option<usize>,
|
||||||
|
previous: Option<usize>,
|
||||||
|
ids: &[ImageId],
|
||||||
|
offset: usize,
|
||||||
|
row: usize,
|
||||||
|
) {
|
||||||
|
// Falling back to the current anchor makes a double tap with no history
|
||||||
|
// select just that cell, which is what a double tap already did.
|
||||||
|
*anchor = previous.or(*anchor);
|
||||||
|
apply_press(selection, anchor, ids, offset, row, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply a press and push the result into the grid — the whole of what a
|
/// Apply a press and push the result into the grid — the whole of what a
|
||||||
/// click, or an arrow key, does to the selection.
|
/// click, or an arrow key, does to the selection.
|
||||||
///
|
///
|
||||||
@@ -321,15 +400,18 @@ pub fn select_row(
|
|||||||
ctrl: bool,
|
ctrl: bool,
|
||||||
shift: bool,
|
shift: bool,
|
||||||
) {
|
) {
|
||||||
apply_press(
|
let mut previous = ctl.previous_anchor.get();
|
||||||
|
press_remembering_anchor(
|
||||||
&mut ctl.selection.borrow_mut(),
|
&mut ctl.selection.borrow_mut(),
|
||||||
&mut ctl.anchor.borrow_mut(),
|
&mut ctl.anchor.borrow_mut(),
|
||||||
|
&mut previous,
|
||||||
ids,
|
ids,
|
||||||
offset,
|
offset,
|
||||||
row,
|
row,
|
||||||
ctrl,
|
ctrl,
|
||||||
shift,
|
shift,
|
||||||
);
|
);
|
||||||
|
ctl.previous_anchor.set(previous);
|
||||||
// The cursor follows the press, so an arrow key after a click continues
|
// The cursor follows the press, so an arrow key after a click continues
|
||||||
// from the cell that was clicked rather than from wherever the keyboard
|
// from the cell that was clicked rather than from wherever the keyboard
|
||||||
// was last.
|
// was last.
|
||||||
@@ -353,6 +435,10 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog
|
|||||||
};
|
};
|
||||||
|
|
||||||
let collapsed = ctl.collapsed.borrow();
|
let collapsed = ctl.collapsed.borrow();
|
||||||
|
// TRACES: FR-NC-6a | FR-NC-6c
|
||||||
|
// How much of each collection is already on the device, rolled up the tree
|
||||||
|
// — computed once for the whole sidebar rather than per row.
|
||||||
|
let held = offline_state(catalog, &rows);
|
||||||
|
|
||||||
// A row is hidden when any ancestor is collapsed. The tree arrives
|
// A row is hidden when any ancestor is collapsed. The tree arrives
|
||||||
// depth-first, so tracking the shallowest collapsed depth seen is enough —
|
// depth-first, so tracking the shallowest collapsed depth seen is enough —
|
||||||
@@ -382,6 +468,8 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog
|
|||||||
let deep =
|
let deep =
|
||||||
coll::deep_count(catalog.connection(), id).unwrap_or(row.collection.direct_count);
|
coll::deep_count(catalog.connection(), id).unwrap_or(row.collection.direct_count);
|
||||||
|
|
||||||
|
let offline = held.get(&id).copied().unwrap_or_default();
|
||||||
|
|
||||||
ids.push(id);
|
ids.push(id);
|
||||||
smart.push(row.collection.kind == CollectionKind::Smart);
|
smart.push(row.collection.kind == CollectionKind::Smart);
|
||||||
has_kids.push(row.has_children);
|
has_kids.push(row.has_children);
|
||||||
@@ -394,6 +482,12 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog
|
|||||||
has_children: row.has_children,
|
has_children: row.has_children,
|
||||||
expanded,
|
expanded,
|
||||||
smart: row.collection.kind == CollectionKind::Smart,
|
smart: row.collection.kind == CollectionKind::Smart,
|
||||||
|
// An empty collection is never "kept": every one of its zero
|
||||||
|
// photographs being here is true and says nothing, and a full tray
|
||||||
|
// beside an empty collection is a lie about what a trip would cost
|
||||||
|
// to take.
|
||||||
|
pinned: offline.total > 0 && offline.pinned == offline.total,
|
||||||
|
partly_pinned: offline.pinned > 0 && offline.pinned < offline.total,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,6 +497,88 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog
|
|||||||
window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out)));
|
window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-NC-6a | FR-NC-6c
|
||||||
|
/// How many of a collection's photographs are pinned, and how many there are.
|
||||||
|
///
|
||||||
|
/// Counted over *members*, not distinct images: an image filed in both a parent
|
||||||
|
/// and a child is counted in each, which is what makes the roll-up below a sum
|
||||||
|
/// rather than a set union. That inflates the totals of a tree that files the
|
||||||
|
/// same photograph twice, and it does not matter here — the only questions
|
||||||
|
/// asked of these numbers are "all of them?" and "none of them?", and both
|
||||||
|
/// survive the double count.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
struct OfflineCount {
|
||||||
|
total: usize,
|
||||||
|
pinned: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The offline state of every collection in the tree, descendants included.
|
||||||
|
///
|
||||||
|
/// One query for the direct membership, then a single reverse pass to fold each
|
||||||
|
/// row into its parent. The alternative — `descendants` plus a count per row —
|
||||||
|
/// is two queries per collection, and the sidebar rebuilds after every drop,
|
||||||
|
/// rename and rating change.
|
||||||
|
///
|
||||||
|
/// The reverse pass is correct because `tree` emits parents before children:
|
||||||
|
/// walking it backwards means every child has already been folded in by the
|
||||||
|
/// time its parent is reached, however deep the nesting goes.
|
||||||
|
fn offline_state(
|
||||||
|
catalog: &Catalog,
|
||||||
|
rows: &[coll::TreeRow],
|
||||||
|
) -> std::collections::HashMap<CollectionId, OfflineCount> {
|
||||||
|
let mut counts: std::collections::HashMap<CollectionId, OfflineCount> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
|
||||||
|
// `tier_actual`, not `pinned`: the question a user is asking of this icon is
|
||||||
|
// "will these open on the aeroplane", and a pin whose download has not run
|
||||||
|
// yet answers no (see the note on `tier_actual` in `dr_catalog::cache`).
|
||||||
|
let sql = "SELECT cm.collection_id,
|
||||||
|
count(*),
|
||||||
|
coalesce(sum(CASE WHEN ic.tier_actual >= ?1 THEN 1 ELSE 0 END), 0)
|
||||||
|
FROM collection_members cm
|
||||||
|
LEFT JOIN image_cache ic ON ic.image_id = cm.image_id
|
||||||
|
GROUP BY cm.collection_id";
|
||||||
|
|
||||||
|
match catalog.connection().prepare(sql).and_then(|mut stmt| {
|
||||||
|
let rows = stmt.query_map(rusqlite::params![dr_types::Tier::Original.stored()], |r| {
|
||||||
|
Ok((
|
||||||
|
CollectionId(r.get::<_, i64>(0)? as u64),
|
||||||
|
r.get::<_, i64>(1)? as usize,
|
||||||
|
r.get::<_, i64>(2)? as usize,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
rows.collect::<Result<Vec<_>, _>>()
|
||||||
|
}) {
|
||||||
|
Ok(direct) => {
|
||||||
|
for (id, total, pinned) in direct {
|
||||||
|
counts.insert(id, OfflineCount { total, pinned });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Not fatal: the tray then reads "nothing kept", which is the safe
|
||||||
|
// direction — it offers a download rather than claiming a trip is
|
||||||
|
// already on the device.
|
||||||
|
log::debug!("reading offline state: {e}");
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in rows.iter().rev() {
|
||||||
|
let Some(parent) = row.collection.parent else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let child = counts.get(&row.collection.id).copied().unwrap_or_default();
|
||||||
|
if child.total == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entry = counts.entry(parent).or_default();
|
||||||
|
entry.total += child.total;
|
||||||
|
entry.pinned += child.pinned;
|
||||||
|
}
|
||||||
|
|
||||||
|
counts
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the bitmap that travels under the cursor.
|
/// Build the bitmap that travels under the cursor.
|
||||||
///
|
///
|
||||||
/// One image is drawn as itself. Several are **fanned**, back to front with the
|
/// One image is drawn as itself. Several are **fanned**, back to front with the
|
||||||
@@ -626,6 +802,48 @@ pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) {
|
|||||||
/// this is the range file managers have settled on for the same gesture.
|
/// this is the range file managers have settled on for the same gesture.
|
||||||
const SPRING_DELAY_MS: u64 = 500;
|
const SPRING_DELAY_MS: u64 = 500;
|
||||||
|
|
||||||
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
/// How long a cell must be held before the grid enters selection mode.
|
||||||
|
///
|
||||||
|
/// The platform convention, and the reason to match it rather than pick: every
|
||||||
|
/// gallery on the device this exists for opens a selection on a hold of about
|
||||||
|
/// this length, so a user who has never read a word about DarkRoom already
|
||||||
|
/// knows the gesture. Shorter and a slow tap becomes a selection; longer and
|
||||||
|
/// the hand lets go first, having concluded nothing was going to happen.
|
||||||
|
pub(crate) const HOLD_DELAY_MS: u64 = 450;
|
||||||
|
|
||||||
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
/// Start the timer that turns a held cell into a selection.
|
||||||
|
///
|
||||||
|
/// Restarted per press, and cancelled by the release — see `hold_timer`. The
|
||||||
|
/// press that armed it has *already* selected the cell under the finger, so
|
||||||
|
/// what firing adds is the mode: from here taps toggle rather than open, and
|
||||||
|
/// the header's buttons appear to act on what has been gathered.
|
||||||
|
fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>) {
|
||||||
|
let timer = slint::Timer::default();
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl_cb = ctl.clone();
|
||||||
|
|
||||||
|
timer.start(
|
||||||
|
slint::TimerMode::SingleShot,
|
||||||
|
std::time::Duration::from_millis(HOLD_DELAY_MS),
|
||||||
|
move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
ctl_cb.select_mode.set(true);
|
||||||
|
w.set_library_select_mode(true);
|
||||||
|
|
||||||
|
// The release that follows this hold must not also open the image:
|
||||||
|
// the user asked for a selection and would land in develop instead.
|
||||||
|
// Reusing `modified_press` rather than adding a second flag — the
|
||||||
|
// click already consults it, and it means exactly this: "the press
|
||||||
|
// was a selection gesture".
|
||||||
|
ctl_cb.modified_press.set(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
*ctl.hold_timer.borrow_mut() = Some(timer);
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether hovering this row should schedule a spring expansion.
|
/// Whether hovering this row should schedule a spring expansion.
|
||||||
///
|
///
|
||||||
/// Pure so the rule is testable: only a *collapsed parent* has anything to
|
/// Pure so the rule is testable: only a *collapsed parent* has anything to
|
||||||
@@ -1090,6 +1308,160 @@ pub fn wire<S, R, C>(
|
|||||||
// the next scroll.
|
// the next scroll.
|
||||||
let offset = w.get_library_offset().max(0) as usize;
|
let offset = w.get_library_offset().max(0) as usize;
|
||||||
select_row(&w, &ctl, &ids, offset, row as usize, ctrl_held, shift_held);
|
select_row(&w, &ctl, &ids, offset, row as usize, ctrl_held, shift_held);
|
||||||
|
|
||||||
|
// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
// And start counting, in case this press is a hold. The press has
|
||||||
|
// already selected this one cell; what the hold adds is the *mode*,
|
||||||
|
// so the taps that follow go on selecting instead of opening the
|
||||||
|
// next photograph the user touches.
|
||||||
|
//
|
||||||
|
// Not started when the mode is already on: it is on, and a second
|
||||||
|
// hold would have nothing to do but suppress the tap that ends it.
|
||||||
|
if !ctl.select_mode.get() {
|
||||||
|
arm_hold(&w, &ctl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
// A double tap in selection mode: take everything between the cell the
|
||||||
|
// selection started from and this one.
|
||||||
|
//
|
||||||
|
// This is shift-click, reached by the one gesture touch has left. Hold to
|
||||||
|
// start selecting, double-tap the far end, and a run of forty photographs
|
||||||
|
// is three touches — then the whole selection drags onto a collection as
|
||||||
|
// one, which is the thing this sequence exists to make possible.
|
||||||
|
//
|
||||||
|
// The range is *added*, not replaced, so a second run can be picked up
|
||||||
|
// without losing the first — ctrl+shift's behaviour, and the right one
|
||||||
|
// here: a mode the user entered deliberately should accumulate rather than
|
||||||
|
// throw away what they have already gathered.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
let visible = visible_ids.clone();
|
||||||
|
window.on_library_cell_double_clicked(move |row| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
// Outside selection mode a double tap is two ordinary clicks, and
|
||||||
|
// the first has already opened the image. Nothing to do.
|
||||||
|
if !ctl.select_mode.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The hold that would have fired mid-double-tap.
|
||||||
|
*ctl.hold_timer.borrow_mut() = None;
|
||||||
|
|
||||||
|
let ids = visible();
|
||||||
|
let offset = w.get_library_offset().max(0) as usize;
|
||||||
|
|
||||||
|
// Extended from the cell the user started at, not from the one the
|
||||||
|
// two taps just moved the anchor onto.
|
||||||
|
extend_to_row(
|
||||||
|
&mut ctl.selection.borrow_mut(),
|
||||||
|
&mut ctl.anchor.borrow_mut(),
|
||||||
|
ctl.previous_anchor.get(),
|
||||||
|
&ids,
|
||||||
|
offset,
|
||||||
|
row as usize,
|
||||||
|
);
|
||||||
|
ctl.set_cursor(Some(offset + row as usize));
|
||||||
|
sync_selection(&w, &ctl, &ids);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
// The press ended — lifted, or taken by the Flickable when the finger
|
||||||
|
// travelled. Either way the hold is off.
|
||||||
|
{
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
window.on_library_cell_press_ended(move || {
|
||||||
|
*ctl.hold_timer.borrow_mut() = None;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
// The button half of selection mode. The long press is faster and this is
|
||||||
|
// the one that can be found — a gesture with no visible counterpart is a
|
||||||
|
// feature only its author knows about.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
let visible = visible_ids.clone();
|
||||||
|
window.on_library_toggle_select_mode(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let on = !ctl.select_mode.get();
|
||||||
|
ctl.select_mode.set(on);
|
||||||
|
w.set_library_select_mode(on);
|
||||||
|
*ctl.hold_timer.borrow_mut() = None;
|
||||||
|
|
||||||
|
// Leaving the mode drops the selection. "Done" reads as finishing
|
||||||
|
// with these photographs, and a selection that outlived the mode
|
||||||
|
// would still be acted on by the buttons in the header — which is
|
||||||
|
// how forty images get exported by a user who thought they had put
|
||||||
|
// them down.
|
||||||
|
if !on {
|
||||||
|
ctl.clear_selection();
|
||||||
|
sync_selection(&w, &ctl, &visible());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-7 | FR-UI-4
|
||||||
|
// File the selection in a collection without dragging it there.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
let catalog = catalog.clone();
|
||||||
|
let visible = visible_ids.clone();
|
||||||
|
let reload = on_scope_changed.clone();
|
||||||
|
window.on_library_file_in_collection(move |id, moves| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let target = CollectionId(id as u64);
|
||||||
|
let chosen = ctl.selected();
|
||||||
|
if chosen.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let borrow = catalog.borrow();
|
||||||
|
let Some(cat) = borrow.as_ref() else { return };
|
||||||
|
|
||||||
|
// Moving is only a move when there is somewhere to move *from*.
|
||||||
|
// Unscoped, the sheet does not offer it; this guards the callback
|
||||||
|
// anyway, and degrades to filing rather than refusing.
|
||||||
|
let from = if moves { *ctl.scope.borrow() } else { None };
|
||||||
|
let result = match from {
|
||||||
|
Some(from) => coll::move_images(cat.connection(), from, target, &chosen),
|
||||||
|
None => coll::add_images(cat.connection(), target, &chosen),
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(filed) => {
|
||||||
|
w.set_collection_error(slint::SharedString::new());
|
||||||
|
// The honest count, as the drop reports it: claiming all
|
||||||
|
// twelve when nine were already there teaches the user to
|
||||||
|
// distrust every number the app shows them.
|
||||||
|
let msg = if from.is_some() {
|
||||||
|
format!("Moved {filed} of {}", chosen.len())
|
||||||
|
} else if filed == chosen.len() {
|
||||||
|
format!("Added {filed} to collection")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Added {filed} of {} — the rest were already there",
|
||||||
|
chosen.len()
|
||||||
|
)
|
||||||
|
};
|
||||||
|
w.set_library_status(msg.into());
|
||||||
|
|
||||||
|
refresh_tree(&w, &ctl, cat);
|
||||||
|
sync_badges(&w, cat, &visible());
|
||||||
|
// A move changes what the collection on screen holds, so
|
||||||
|
// the grid has to be reread; filing elsewhere does not, but
|
||||||
|
// the badge count on every cell just changed and the reread
|
||||||
|
// is one query.
|
||||||
|
drop(borrow);
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
Err(e) => w.set_collection_error(format!("filing: {e}").into()),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1943,6 +2315,105 @@ mod tests {
|
|||||||
assert_eq!(sel.len(), 3, "rows 10..=12");
|
assert_eq!(sel.len(), 3, "rows 10..=12");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One tap in selection mode: a press reported as ctrl-held, which is what
|
||||||
|
/// `library.slint` sends while the mode is on.
|
||||||
|
fn tap(
|
||||||
|
sel: &mut BTreeSet<ImageId>,
|
||||||
|
anchor: &mut Option<usize>,
|
||||||
|
previous: &mut Option<usize>,
|
||||||
|
all: &[ImageId],
|
||||||
|
row: usize,
|
||||||
|
) {
|
||||||
|
press_remembering_anchor(sel, anchor, previous, all, 0, row, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_double_tap_takes_the_run_between_it_and_where_selecting_began() {
|
||||||
|
// The whole touch gesture, in the order a finger performs it: hold one
|
||||||
|
// photograph to start selecting, then double-tap the far end of the run.
|
||||||
|
// Both taps of that double tap land on the same cell — the first turns
|
||||||
|
// it on, the second turns it off — and the double tap that follows has
|
||||||
|
// to select the range anyway.
|
||||||
|
let all = ids(20);
|
||||||
|
let mut sel = BTreeSet::new();
|
||||||
|
let mut anchor = None;
|
||||||
|
let mut previous = None;
|
||||||
|
|
||||||
|
// The long press: an ordinary plain press, which is what the cell got
|
||||||
|
// before the hold timer fired.
|
||||||
|
press_remembering_anchor(
|
||||||
|
&mut sel,
|
||||||
|
&mut anchor,
|
||||||
|
&mut previous,
|
||||||
|
&all,
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
|
||||||
|
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
|
||||||
|
assert!(
|
||||||
|
!sel.contains(&ImageId(12)),
|
||||||
|
"the two taps cancelled out, which is what makes the double tap's \
|
||||||
|
job to select the range rather than to add one cell"
|
||||||
|
);
|
||||||
|
|
||||||
|
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 11);
|
||||||
|
|
||||||
|
assert_eq!(sel.len(), 8, "rows 4..=11");
|
||||||
|
assert!(sel.contains(&ImageId(5)) && sel.contains(&ImageId(12)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_double_tap_keeps_a_run_gathered_earlier() {
|
||||||
|
// Two runs, which is why the extension unions rather than replaces: a
|
||||||
|
// user in selection mode is gathering, and the second gesture must not
|
||||||
|
// throw away the first.
|
||||||
|
let all = ids(30);
|
||||||
|
let mut sel = BTreeSet::new();
|
||||||
|
let mut anchor = None;
|
||||||
|
let mut previous = None;
|
||||||
|
|
||||||
|
press_remembering_anchor(
|
||||||
|
&mut sel,
|
||||||
|
&mut anchor,
|
||||||
|
&mut previous,
|
||||||
|
&all,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
tap(&mut sel, &mut anchor, &mut previous, &all, 3);
|
||||||
|
tap(&mut sel, &mut anchor, &mut previous, &all, 3);
|
||||||
|
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 3);
|
||||||
|
assert_eq!(sel.len(), 4, "rows 0..=3");
|
||||||
|
|
||||||
|
// A second run, begun with a plain tap somewhere else.
|
||||||
|
tap(&mut sel, &mut anchor, &mut previous, &all, 20);
|
||||||
|
tap(&mut sel, &mut anchor, &mut previous, &all, 25);
|
||||||
|
tap(&mut sel, &mut anchor, &mut previous, &all, 25);
|
||||||
|
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 25);
|
||||||
|
|
||||||
|
assert_eq!(sel.len(), 10, "rows 0..=3 and 20..=25");
|
||||||
|
assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(26)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_double_tap_with_nothing_to_extend_from_selects_only_that_cell() {
|
||||||
|
// The degenerate case: selection mode entered from the header's button
|
||||||
|
// rather than by holding a cell, so nothing has anchored yet.
|
||||||
|
let all = ids(10);
|
||||||
|
let mut sel = BTreeSet::new();
|
||||||
|
let mut anchor = None;
|
||||||
|
let previous = None;
|
||||||
|
|
||||||
|
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 6);
|
||||||
|
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(7)]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ctrl_shift_adds_a_second_range_to_the_selection() {
|
fn ctrl_shift_adds_a_second_range_to_the_selection() {
|
||||||
// Picking up a second run without losing the first: the one case where
|
// Picking up a second run without losing the first: the one case where
|
||||||
@@ -2244,6 +2715,131 @@ mod tests {
|
|||||||
assert_eq!(second, "New collection 2");
|
assert_eq!(second, "New collection 2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-NC-6a | FR-NC-6c
|
||||||
|
/// A catalog with `n` images, for the offline roll-up tests.
|
||||||
|
fn catalog_with_images(n: usize) -> (Catalog, Vec<ImageId>) {
|
||||||
|
let cat = Catalog::in_memory().unwrap();
|
||||||
|
cat.connection()
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO roots (id, kind, label) VALUES (1, 'remote', 'test')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
for i in 0..n {
|
||||||
|
cat.connection()
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO images (root_id, source_ref, added_at)
|
||||||
|
VALUES (1, ?1, 0)",
|
||||||
|
rusqlite::params![format!("Photos/img{i:03}.CR2")],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
ids.push(ImageId(cat.connection().last_insert_rowid() as u64));
|
||||||
|
}
|
||||||
|
(cat, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record an image as held on this device, as a finished download would.
|
||||||
|
fn mark_held(cat: &Catalog, image: ImageId) {
|
||||||
|
cat.connection()
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO image_cache (image_id, tier_actual, tier_desired, bytes, pinned)
|
||||||
|
VALUES (?1, ?2, ?2, 100, 1)",
|
||||||
|
rusqlite::params![image.0 as i64, dr_types::Tier::Original.stored()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_parent_reads_as_kept_only_when_its_children_are_too() {
|
||||||
|
// The tray on a parent answers for the subtree, because that is what
|
||||||
|
// keeping a parent downloads. A parent that showed a full tray over a
|
||||||
|
// child with nothing downloaded would be a promise the aeroplane
|
||||||
|
// breaks.
|
||||||
|
let (cat, images) = catalog_with_images(4);
|
||||||
|
let c = cat.connection();
|
||||||
|
let parent = coll::create(c, "Trip", None, CollectionKind::Manual).unwrap();
|
||||||
|
let child = coll::create(c, "Day one", Some(parent), CollectionKind::Manual).unwrap();
|
||||||
|
coll::add_images(c, parent, &images[0..2]).unwrap();
|
||||||
|
coll::add_images(c, child, &images[2..4]).unwrap();
|
||||||
|
|
||||||
|
// Everything the parent holds directly, and nothing of the child's.
|
||||||
|
mark_held(&cat, images[0]);
|
||||||
|
mark_held(&cat, images[1]);
|
||||||
|
|
||||||
|
let rows = coll::tree(c).unwrap();
|
||||||
|
let state = offline_state(&cat, &rows);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
state[&parent],
|
||||||
|
OfflineCount {
|
||||||
|
total: 4,
|
||||||
|
pinned: 2
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state[&child],
|
||||||
|
OfflineCount {
|
||||||
|
total: 2,
|
||||||
|
pinned: 0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
mark_held(&cat, images[2]);
|
||||||
|
mark_held(&cat, images[3]);
|
||||||
|
let state = offline_state(&cat, &coll::tree(c).unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
state[&parent],
|
||||||
|
OfflineCount {
|
||||||
|
total: 4,
|
||||||
|
pinned: 4
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_grandchild_rolls_all_the_way_up() {
|
||||||
|
// The reverse pass has to survive depth: a photograph three levels down
|
||||||
|
// is still part of what the top-level collection would download.
|
||||||
|
let (cat, images) = catalog_with_images(1);
|
||||||
|
let c = cat.connection();
|
||||||
|
let top = coll::create(c, "2024", None, CollectionKind::Manual).unwrap();
|
||||||
|
let mid = coll::create(c, "Corsica", Some(top), CollectionKind::Manual).unwrap();
|
||||||
|
let leaf = coll::create(c, "Bonifacio", Some(mid), CollectionKind::Manual).unwrap();
|
||||||
|
coll::add_images(c, leaf, &images).unwrap();
|
||||||
|
|
||||||
|
let state = offline_state(&cat, &coll::tree(c).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
state[&top],
|
||||||
|
OfflineCount {
|
||||||
|
total: 1,
|
||||||
|
pinned: 0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state[&mid],
|
||||||
|
OfflineCount {
|
||||||
|
total: 1,
|
||||||
|
pinned: 0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_collection_has_nothing_to_keep() {
|
||||||
|
// Guards the reading that would put a full tray beside an empty
|
||||||
|
// collection: every one of its zero photographs is here, which is true
|
||||||
|
// and useless. `refresh_tree` requires a non-zero total before drawing
|
||||||
|
// one, and this is the fact it relies on.
|
||||||
|
let (cat, _) = catalog_with_images(0);
|
||||||
|
let c = cat.connection();
|
||||||
|
let empty = coll::create(c, "Nothing yet", None, CollectionKind::Manual).unwrap();
|
||||||
|
|
||||||
|
let state = offline_state(&cat, &coll::tree(c).unwrap());
|
||||||
|
assert_eq!(state.get(&empty).copied().unwrap_or_default().total, 0);
|
||||||
|
}
|
||||||
|
|
||||||
/// A catalog holding one top-level collection, and its id.
|
/// A catalog holding one top-level collection, and its id.
|
||||||
fn with_one(name: &str) -> (Catalog, CollectionId) {
|
fn with_one(name: &str) -> (Catalog, CollectionId) {
|
||||||
let cat = Catalog::in_memory().unwrap();
|
let cat = Catalog::in_memory().unwrap();
|
||||||
|
|||||||
+389
-46
@@ -188,6 +188,19 @@ pub struct LibraryController {
|
|||||||
/// Drains the pin downloader. Held so a second pin replaces the timer
|
/// Drains the pin downloader. Held so a second pin replaces the timer
|
||||||
/// rather than leaving two draining the same finished channel.
|
/// rather than leaving two draining the same finished channel.
|
||||||
pin_timer: RefCell<Option<slint::Timer>>,
|
pin_timer: RefCell<Option<slint::Timer>>,
|
||||||
|
/// TRACES: FR-NC-6a
|
||||||
|
/// Which collection the offline question is being asked about.
|
||||||
|
///
|
||||||
|
/// Held rather than passed through the window because the prompt's three
|
||||||
|
/// answers arrive as three separate callbacks, and a dialogue that read its
|
||||||
|
/// subject back out of a string property would act on whatever the sidebar
|
||||||
|
/// had been rebuilt to say since.
|
||||||
|
offline_target: std::cell::Cell<Option<dr_types::CollectionId>>,
|
||||||
|
/// TRACES: FR-NC-6a | FR-UI-2
|
||||||
|
/// The timer that turns a held sidebar row into that question. Dropped on
|
||||||
|
/// release, so a tap — or a press the Flickable takes for a scroll — is not
|
||||||
|
/// a dialogue a moment later.
|
||||||
|
row_hold_timer: RefCell<Option<slint::Timer>>,
|
||||||
/// Narrow the grid to images whose original is stored locally.
|
/// Narrow the grid to images whose original is stored locally.
|
||||||
///
|
///
|
||||||
/// A `Cell` beside `filter` rather than a field inside it: the rating
|
/// A `Cell` beside `filter` rather than a field inside it: the rating
|
||||||
@@ -253,6 +266,8 @@ impl LibraryController {
|
|||||||
outbox_timer: RefCell::new(None),
|
outbox_timer: RefCell::new(None),
|
||||||
outbox_maybe_dirty: std::cell::Cell::new(true),
|
outbox_maybe_dirty: std::cell::Cell::new(true),
|
||||||
pin_timer: RefCell::new(None),
|
pin_timer: RefCell::new(None),
|
||||||
|
offline_target: std::cell::Cell::new(None),
|
||||||
|
row_hold_timer: RefCell::new(None),
|
||||||
local_only: std::cell::Cell::new(false),
|
local_only: std::cell::Cell::new(false),
|
||||||
// The catalog's own floor until the settings page reports what the
|
// The catalog's own floor until the settings page reports what the
|
||||||
// user has stored, which it does at startup before any fetch.
|
// user has stored, which it does at startup before any fetch.
|
||||||
@@ -874,31 +889,102 @@ fn start_rescan(
|
|||||||
drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path);
|
drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-NC-6a
|
/// TRACES: FR-NC-6a | FR-NC-6c
|
||||||
/// Pin the scoped collection for offline use, or release it.
|
/// What a collection would cost to take with you, and what it is holding now.
|
||||||
///
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
/// Pinning is two separate things, and keeping them separate is what makes the
|
struct OfflineSummary {
|
||||||
/// button feel immediate: recording the *intent* is a local catalog write that
|
/// Photographs in the collection and its children, deduplicated.
|
||||||
/// completes at once, and downloading the bytes is a background transfer that
|
total: usize,
|
||||||
/// may take a very long time. The button reflects the first.
|
/// Of those, how many have their original on this device.
|
||||||
fn toggle_pin_scope(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
held: usize,
|
||||||
let Some(scope) = *ctl.scope.borrow() else {
|
/// Disk those originals occupy — what releasing would give back.
|
||||||
return;
|
held_bytes: u64,
|
||||||
};
|
/// What the rest would cost to fetch, from the sizes the scan recorded.
|
||||||
let Some(cache) = ctl.cache() else {
|
/// Zero where nothing has been stat-ed yet, which reads as "unknown"
|
||||||
window.set_library_error("No cache directory for this library.".into());
|
/// rather than "free" in the label built from it.
|
||||||
return;
|
missing_bytes: u64,
|
||||||
};
|
}
|
||||||
|
|
||||||
|
impl OfflineSummary {
|
||||||
|
fn missing(self) -> usize {
|
||||||
|
self.total.saturating_sub(self.held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the offline summary for a set of images.
|
||||||
|
///
|
||||||
|
/// One query with the ids inlined as placeholders — the same shape
|
||||||
|
/// [`collection_images`] uses, and for the same reason: a collection is tens to
|
||||||
|
/// thousands of rows, and a round trip per photograph to answer one dialogue is
|
||||||
|
/// not a trade worth making.
|
||||||
|
fn offline_summary(catalog: &Catalog, images: &[dr_types::ImageId]) -> OfflineSummary {
|
||||||
|
if images.is_empty() {
|
||||||
|
return OfflineSummary::default();
|
||||||
|
}
|
||||||
|
let placeholders = std::iter::repeat_n("?", images.len())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
// `tier_actual`, never `tier_desired`: the question is what can be opened
|
||||||
|
// on the aeroplane, and a pin whose download has not run yet answers no.
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT count(*),
|
||||||
|
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 1 ELSE 0 END), 0),
|
||||||
|
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN c.bytes ELSE 0 END), 0),
|
||||||
|
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 0
|
||||||
|
ELSE coalesce(i.file_size, 0) END), 0)
|
||||||
|
FROM images i
|
||||||
|
LEFT JOIN image_cache c ON c.image_id = i.id
|
||||||
|
WHERE i.id IN ({placeholders})"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut params: Vec<rusqlite::types::Value> = vec![rusqlite::types::Value::Integer(
|
||||||
|
dr_types::Tier::Original.stored(),
|
||||||
|
)];
|
||||||
|
params.extend(
|
||||||
|
images
|
||||||
|
.iter()
|
||||||
|
.map(|i| rusqlite::types::Value::Integer(i.0 as i64)),
|
||||||
|
);
|
||||||
|
|
||||||
|
catalog
|
||||||
|
.connection()
|
||||||
|
.query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| {
|
||||||
|
Ok(OfflineSummary {
|
||||||
|
total: r.get::<_, i64>(0)? as usize,
|
||||||
|
held: r.get::<_, i64>(1)? as usize,
|
||||||
|
held_bytes: r.get::<_, i64>(2)? as u64,
|
||||||
|
missing_bytes: r.get::<_, i64>(3)? as u64,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
log::debug!("reading offline summary: {e}");
|
||||||
|
OfflineSummary::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-NC-6a | FR-NC-6c
|
||||||
|
/// Ask what should happen to a collection's local copies.
|
||||||
|
///
|
||||||
|
/// Both answers are expensive — one commits the device to a download of
|
||||||
|
/// gigabytes, the other deletes gigabytes it already holds — so this is a
|
||||||
|
/// question rather than a toggle, and the counts and sizes go in the buttons
|
||||||
|
/// where they are read *before* the tap rather than in a second dialogue after
|
||||||
|
/// it (FR-NC-6c: an operation requiring absent data says so, with the size,
|
||||||
|
/// before starting).
|
||||||
|
fn open_offline_prompt(
|
||||||
|
window: &AppWindow,
|
||||||
|
ctl: &Rc<LibraryController>,
|
||||||
|
id: dr_types::CollectionId,
|
||||||
|
) {
|
||||||
let borrow = ctl.catalog.borrow();
|
let borrow = ctl.catalog.borrow();
|
||||||
let Some(catalog) = borrow.as_ref() else {
|
let Some(catalog) = borrow.as_ref() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Descendants, matching what the grid shows when scoped to a set: pinning
|
// Descendants, matching what the grid shows when scoped to this row:
|
||||||
// a parent whose children hold the photographs must pin the photographs,
|
// keeping a parent whose children hold the photographs must keep the
|
||||||
// or the button would appear to do nothing.
|
// photographs, or the answer would appear to do nothing.
|
||||||
let ids = match dr_catalog::collections::descendants(catalog.connection(), scope) {
|
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
|
||||||
Ok(ids) => ids,
|
Ok(ids) => ids,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
window.set_library_error(format!("resolving collection: {e}").into());
|
window.set_library_error(format!("resolving collection: {e}").into());
|
||||||
@@ -906,37 +992,212 @@ fn toggle_pin_scope(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let images = collection_images(catalog, &ids);
|
let images = collection_images(catalog, &ids);
|
||||||
if images.is_empty() {
|
let summary = offline_summary(catalog, &images);
|
||||||
window.set_library_error("Nothing in that collection to keep offline.".into());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pinning = !window.get_library_scope_pinned();
|
let name = dr_catalog::collections::tree(catalog.connection())
|
||||||
let result = if pinning {
|
.ok()
|
||||||
cache.pin(catalog.connection(), &images)
|
.and_then(|rows| {
|
||||||
} else {
|
rows.into_iter()
|
||||||
cache.unpin(catalog.connection(), &images)
|
.find(|r| r.collection.id == id)
|
||||||
|
.map(|r| r.collection.name)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "This collection".to_string());
|
||||||
|
|
||||||
|
ctl.offline_target.set(Some(id));
|
||||||
|
window.set_offline_prompt_title(name.as_str().into());
|
||||||
|
window.set_offline_prompt_detail(
|
||||||
|
if summary.total == 0 {
|
||||||
|
"Nothing in here yet. Put some photographs in it first.".to_string()
|
||||||
|
} else if summary.held == summary.total {
|
||||||
|
format!(
|
||||||
|
"All {} on this device · {}",
|
||||||
|
summary.total,
|
||||||
|
crate::activity::describe_bytes(summary.held_bytes)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} photographs · {} already on this device",
|
||||||
|
summary.total, summary.held
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.as_str()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
window.set_offline_prompt_keep_label(
|
||||||
|
// The size is named where the scan has recorded one. Where it has not,
|
||||||
|
// the label says what it will do and not what it will cost, which is
|
||||||
|
// honest — a "0 B" download would be a lie about a gigabyte.
|
||||||
|
if summary.missing_bytes > 0 {
|
||||||
|
format!(
|
||||||
|
"Download {} · {}",
|
||||||
|
summary.missing(),
|
||||||
|
crate::activity::describe_bytes(summary.missing_bytes)
|
||||||
|
)
|
||||||
|
} else if summary.missing() > 0 {
|
||||||
|
format!("Download {}", summary.missing())
|
||||||
|
} else {
|
||||||
|
"Everything is already here".to_string()
|
||||||
|
}
|
||||||
|
.as_str()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
window.set_offline_prompt_release_label(
|
||||||
|
format!(
|
||||||
|
"Remove {} local copies · frees {}",
|
||||||
|
summary.held,
|
||||||
|
crate::activity::describe_bytes(summary.held_bytes)
|
||||||
|
)
|
||||||
|
.as_str()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
window.set_offline_prompt_can_keep(summary.missing() > 0);
|
||||||
|
window.set_offline_prompt_can_release(summary.held > 0);
|
||||||
|
window.set_offline_prompt_busy(window.get_library_pin_total() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the offline question without answering it.
|
||||||
|
fn close_offline_prompt(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||||
|
ctl.offline_target.set(None);
|
||||||
|
// The title is what the prompt's visibility is bound to: one fact, so a
|
||||||
|
// dialogue cannot be up with nothing written on it.
|
||||||
|
window.set_offline_prompt_title(slint::SharedString::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-NC-6a
|
||||||
|
/// Keep a collection on this device: record the pin, then start the transfer.
|
||||||
|
///
|
||||||
|
/// Two separate things, and keeping them separate is what makes the answer feel
|
||||||
|
/// immediate — recording the intent is a local catalog write that completes at
|
||||||
|
/// once, and downloading the bytes may take a very long time. The pin also
|
||||||
|
/// survives the app being closed halfway through, which is what makes the
|
||||||
|
/// transfer resumable rather than something to start again.
|
||||||
|
fn keep_collection_offline(
|
||||||
|
window: &AppWindow,
|
||||||
|
ctl: &Rc<LibraryController>,
|
||||||
|
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||||
|
) {
|
||||||
|
let Some(id) = ctl.offline_target.get() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(cache) = ctl.cache() else {
|
||||||
|
window.set_library_error("No cache directory for this library.".into());
|
||||||
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = result {
|
let images = {
|
||||||
window.set_library_error(format!("pinning: {e}").into());
|
let borrow = ctl.catalog.borrow();
|
||||||
return;
|
let Some(catalog) = borrow.as_ref() else {
|
||||||
}
|
return;
|
||||||
|
};
|
||||||
|
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
|
||||||
|
Ok(ids) => ids,
|
||||||
|
Err(e) => {
|
||||||
|
window.set_library_error(format!("resolving collection: {e}").into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let images = collection_images(catalog, &ids);
|
||||||
|
if images.is_empty() {
|
||||||
|
window.set_library_error("Nothing in that collection to keep offline.".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = cache.pin(catalog.connection(), &images) {
|
||||||
|
window.set_library_error(format!("pinning: {e}").into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
images
|
||||||
|
};
|
||||||
|
|
||||||
window.set_library_scope_pinned(pinning);
|
log::info!("pinned {} image(s) for offline use", images.len());
|
||||||
window.set_library_error(slint::SharedString::new());
|
window.set_library_error(slint::SharedString::new());
|
||||||
drop(borrow);
|
if ctl.scope.borrow().as_ref() == Some(&id) {
|
||||||
|
window.set_library_scope_pinned(true);
|
||||||
|
}
|
||||||
|
close_offline_prompt(window, ctl);
|
||||||
|
start_pin_fetch(window, ctl);
|
||||||
|
refresh_collection_tree(window, ctl, coll_ctl);
|
||||||
|
}
|
||||||
|
|
||||||
if pinning {
|
/// TRACES: FR-NC-6a | FR-NC-6b
|
||||||
log::info!("pinned {} image(s) for offline use", images.len());
|
/// Give the disk back: release the pin *and* delete the originals it held.
|
||||||
start_pin_fetch(window, ctl);
|
///
|
||||||
} else {
|
/// Deliberately destructive, where unpinning alone is not. "Remove the local
|
||||||
// The bytes stay until the budget needs the room, so there is nothing
|
/// copies" is asked by someone whose device is full, and answering it by
|
||||||
// to run here — unpinning withdraws a guarantee rather than deleting.
|
/// withdrawing a promise and leaving the gigabytes for a future eviction to
|
||||||
log::info!("released the pin on {} image(s)", images.len());
|
/// notice is not an answer. Nothing is lost that cannot be fetched again: the
|
||||||
window.set_library_pin_total(0);
|
/// originals are on the server, and the ratings, the edit graph and the
|
||||||
window.set_library_pin_done(0);
|
/// thumbnails are all untouched — they are authoritative and small.
|
||||||
refresh_local_count(window, ctl);
|
fn release_collection_offline(
|
||||||
|
window: &AppWindow,
|
||||||
|
ctl: &Rc<LibraryController>,
|
||||||
|
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||||
|
) {
|
||||||
|
let Some(id) = ctl.offline_target.get() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(cache) = ctl.cache() else {
|
||||||
|
window.set_library_error("No cache directory for this library.".into());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let released = {
|
||||||
|
let borrow = ctl.catalog.borrow();
|
||||||
|
let Some(catalog) = borrow.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
|
||||||
|
Ok(ids) => ids,
|
||||||
|
Err(e) => {
|
||||||
|
window.set_library_error(format!("resolving collection: {e}").into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let images = collection_images(catalog, &ids);
|
||||||
|
match cache.release(catalog.connection(), &images) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
window.set_library_error(format!("removing local copies: {e}").into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (count, freed) = released;
|
||||||
|
log::info!(
|
||||||
|
"released {count} image(s), freeing {}",
|
||||||
|
crate::activity::describe_bytes(freed)
|
||||||
|
);
|
||||||
|
window.set_library_error(slint::SharedString::new());
|
||||||
|
window.set_library_status(
|
||||||
|
format!(
|
||||||
|
"Removed local copies · {} freed",
|
||||||
|
crate::activity::describe_bytes(freed)
|
||||||
|
)
|
||||||
|
.as_str()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
if ctl.scope.borrow().as_ref() == Some(&id) {
|
||||||
|
window.set_library_scope_pinned(false);
|
||||||
|
}
|
||||||
|
// A download that was still running for this collection has just had its
|
||||||
|
// reason withdrawn; the worker checks `pending_pins` per file, so it stops
|
||||||
|
// finding work rather than being killed.
|
||||||
|
window.set_library_pin_total(0);
|
||||||
|
window.set_library_pin_done(0);
|
||||||
|
close_offline_prompt(window, ctl);
|
||||||
|
refresh_local_count(window, ctl);
|
||||||
|
refresh_collection_tree(window, ctl, coll_ctl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redraw the sidebar, so the trays reflect what was just kept or released.
|
||||||
|
fn refresh_collection_tree(
|
||||||
|
window: &AppWindow,
|
||||||
|
ctl: &Rc<LibraryController>,
|
||||||
|
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||||
|
) {
|
||||||
|
let borrow = ctl.catalog.borrow();
|
||||||
|
if let Some(catalog) = borrow.as_ref() {
|
||||||
|
crate::collections_ui::refresh_tree(window, coll_ctl, catalog);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3392,13 +3653,95 @@ pub fn wire<F>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: FR-NC-6a
|
// TRACES: FR-NC-6a
|
||||||
// Pin or unpin the collection the grid is scoped to.
|
// The header's way in to the offline question, for the collection the grid
|
||||||
|
// is scoped to. It opens the same prompt the sidebar's tray and the long
|
||||||
|
// press open, rather than pinning outright: three affordances that did two
|
||||||
|
// different things — one asking, two acting — is how a user comes to avoid
|
||||||
|
// all three.
|
||||||
{
|
{
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
let ctl = ctl.clone();
|
let ctl = ctl.clone();
|
||||||
window.on_library_toggle_pin_scope(move || {
|
window.on_library_toggle_pin_scope(move || {
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
toggle_pin_scope(&w, &ctl);
|
let Some(scope) = *ctl.scope.borrow() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
open_offline_prompt(&w, &ctl, scope);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-NC-6a | FR-UI-4
|
||||||
|
// The sidebar's way in: the tray on a row, tapped.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
window.on_collection_offline_menu(move |id| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
*ctl.row_hold_timer.borrow_mut() = None;
|
||||||
|
open_offline_prompt(&w, &ctl, dr_types::CollectionId(id as u64));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-NC-6a | FR-UI-2 | FR-UI-4
|
||||||
|
// And the touch way in: hold the collection's name.
|
||||||
|
//
|
||||||
|
// The release that ends the hold still reaches the row's `clicked` and
|
||||||
|
// scopes the grid to that collection. Left deliberately: the user is now
|
||||||
|
// looking at the photographs they are being asked about, which is context
|
||||||
|
// rather than a side effect — and suppressing it would mean a second
|
||||||
|
// "ignore the next click" flag threaded through the sidebar for no gain.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
window.on_collection_row_press(move |id, down| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
if !down {
|
||||||
|
*ctl.row_hold_timer.borrow_mut() = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer = slint::Timer::default();
|
||||||
|
let weak = w.as_weak();
|
||||||
|
let ctl_cb = ctl.clone();
|
||||||
|
timer.start(
|
||||||
|
slint::TimerMode::SingleShot,
|
||||||
|
std::time::Duration::from_millis(crate::collections_ui::HOLD_DELAY_MS),
|
||||||
|
move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
open_offline_prompt(&w, &ctl_cb, dr_types::CollectionId(id as u64));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
*ctl.row_hold_timer.borrow_mut() = Some(timer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three answers.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
let coll = coll_ctl.clone();
|
||||||
|
window.on_offline_prompt_keep(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
keep_collection_offline(&w, &ctl, &coll);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
let coll = coll_ctl.clone();
|
||||||
|
window.on_offline_prompt_release(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
release_collection_offline(&w, &ctl, &coll);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
window.on_offline_prompt_dismiss(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
close_offline_prompt(&w, &ctl);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+82
-1
@@ -3,7 +3,7 @@ import { AdjustPanel, GeometryPanel, ParamRow, TransferPanel } from "adjust.slin
|
|||||||
import { LaunchScreen } from "launch.slint";
|
import { LaunchScreen } from "launch.slint";
|
||||||
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
|
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
|
||||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||||
import { CollectionsPanel, CollectionRow } from "collections.slint";
|
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
||||||
import { HistogramPanel, HistogramView } from "histogram.slint";
|
import { HistogramPanel, HistogramView } from "histogram.slint";
|
||||||
import { SettingsPage } from "settings.slint";
|
import { SettingsPage } from "settings.slint";
|
||||||
|
|
||||||
@@ -395,6 +395,20 @@ export component AppWindow inherits Window {
|
|||||||
in property <int> library-pin-total: 0;
|
in property <int> library-pin-total: 0;
|
||||||
callback library-toggle-pin-scope();
|
callback library-toggle-pin-scope();
|
||||||
|
|
||||||
|
// The question a held collection asks: keep these on the device, or give
|
||||||
|
// the disk back. Driven entirely from Rust — the counts and the byte sizes
|
||||||
|
// in the labels are catalog reads, and an empty `title` is what closes it.
|
||||||
|
in property <string> offline-prompt-title: "";
|
||||||
|
in property <string> offline-prompt-detail: "";
|
||||||
|
in property <string> offline-prompt-keep-label: "";
|
||||||
|
in property <string> offline-prompt-release-label: "";
|
||||||
|
in property <bool> offline-prompt-can-keep: false;
|
||||||
|
in property <bool> offline-prompt-can-release: false;
|
||||||
|
in property <bool> offline-prompt-busy: false;
|
||||||
|
callback offline-prompt-keep();
|
||||||
|
callback offline-prompt-release();
|
||||||
|
callback offline-prompt-dismiss();
|
||||||
|
|
||||||
in property <string> library-root-label: "";
|
in property <string> library-root-label: "";
|
||||||
in-out property <[TimelineBar]> library-timeline;
|
in-out property <[TimelineBar]> library-timeline;
|
||||||
in property <string> library-timeline-label: "";
|
in property <string> library-timeline-label: "";
|
||||||
@@ -451,6 +465,27 @@ export component AppWindow inherits Window {
|
|||||||
callback collection-toggle(int);
|
callback collection-toggle(int);
|
||||||
callback collection-new();
|
callback collection-new();
|
||||||
callback collection-menu(int);
|
callback collection-menu(int);
|
||||||
|
/// TRACES: FR-NC-6a
|
||||||
|
/// Ask about a collection's local copies: the tray on a row, or a long
|
||||||
|
/// press anywhere on it.
|
||||||
|
callback collection-offline-menu(int);
|
||||||
|
/// A row is held or released — the long press is timed in Rust.
|
||||||
|
callback collection-row-press(int, bool);
|
||||||
|
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||||
|
/// File the grid's selection in a collection without dragging it there:
|
||||||
|
/// 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-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.
|
||||||
|
in property <bool> library-select-mode: false;
|
||||||
|
callback library-toggle-select-mode();
|
||||||
|
/// A press on a cell ended, so the long-press timer can be cancelled.
|
||||||
|
callback library-cell-press-ended();
|
||||||
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
/// Two taps on a cell: in selection mode, the far end of a range.
|
||||||
|
callback library-cell-double-clicked(int);
|
||||||
/// Renaming started on a row, by id — a double-click, or `F2`.
|
/// Renaming started on a row, by id — a double-click, or `F2`.
|
||||||
callback collection-rename-start(int);
|
callback collection-rename-start(int);
|
||||||
/// A rename was committed: the collection's id and the new name.
|
/// A rename was committed: the collection's id and the new name.
|
||||||
@@ -754,6 +789,15 @@ in property <bool> panel-visible: true;
|
|||||||
// the inner scopes that use it for something more specific — a
|
// the inner scopes that use it for something more specific — a
|
||||||
// rename in the sidebar — see it first and accept it there.
|
// rename in the sidebar — see it first and accept it there.
|
||||||
if (event.text == Key.Back || event.text == Key.Escape) {
|
if (event.text == Key.Back || event.text == Key.Escape) {
|
||||||
|
// A dialogue takes "go up one" first, and it is the *only*
|
||||||
|
// thing that goes up. Without this, Android's Back button
|
||||||
|
// would leave the library from behind an open question — the
|
||||||
|
// view changing underneath a modal, which reads as the app
|
||||||
|
// having lost its place.
|
||||||
|
if (root.offline-prompt-title != "") {
|
||||||
|
root.offline-prompt-dismiss();
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
return root.back-requested() ? accept : reject;
|
return root.back-requested() ? accept : reject;
|
||||||
}
|
}
|
||||||
return reject;
|
return reject;
|
||||||
@@ -912,6 +956,8 @@ in property <bool> panel-visible: true;
|
|||||||
toggle(id) => { root.collection-toggle(id); }
|
toggle(id) => { root.collection-toggle(id); }
|
||||||
new-collection() => { root.collection-new(); }
|
new-collection() => { root.collection-new(); }
|
||||||
row-menu(id) => { root.collection-menu(id); }
|
row-menu(id) => { root.collection-menu(id); }
|
||||||
|
offline-menu(id) => { root.collection-offline-menu(id); }
|
||||||
|
row-press(id, down) => { root.collection-row-press(id, down); }
|
||||||
|
|
||||||
renaming-id: root.collection-renaming;
|
renaming-id: root.collection-renaming;
|
||||||
rename-start(id) => { root.collection-rename-start(id); }
|
rename-start(id) => { root.collection-rename-start(id); }
|
||||||
@@ -1011,6 +1057,16 @@ in property <bool> panel-visible: true;
|
|||||||
cell-pressed(i, ctrl, shift) => {
|
cell-pressed(i, ctrl, shift) => {
|
||||||
root.library-cell-pressed(i, ctrl, shift);
|
root.library-cell-pressed(i, ctrl, shift);
|
||||||
}
|
}
|
||||||
|
cell-press-ended() => { root.library-cell-press-ended(); }
|
||||||
|
cell-double-clicked(i) => { root.library-cell-double-clicked(i); }
|
||||||
|
select-mode: root.library-select-mode;
|
||||||
|
toggle-select-mode() => { root.library-toggle-select-mode(); }
|
||||||
|
// The sidebar's rows, not a second model: the sheet files
|
||||||
|
// into the same tree the sidebar draws.
|
||||||
|
collections: root.collection-rows;
|
||||||
|
file-in-collection(id, moves) => {
|
||||||
|
root.library-file-in-collection(id, moves);
|
||||||
|
}
|
||||||
cursor: root.library-cursor;
|
cursor: root.library-cursor;
|
||||||
move-cursor(delta, extend) => {
|
move-cursor(delta, extend) => {
|
||||||
root.library-move-cursor(delta, extend);
|
root.library-move-cursor(delta, extend);
|
||||||
@@ -1635,5 +1691,30 @@ in property <bool> panel-visible: true;
|
|||||||
indeterminate: !root.activity-determinate;
|
indeterminate: !root.activity-determinate;
|
||||||
fraction: root.activity-fraction;
|
fraction: root.activity-fraction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-NC-6a
|
||||||
|
// The offline question, over the shell rather than inside the sidebar
|
||||||
|
// that raises it. The sidebar is 232px wide and closes entirely in the
|
||||||
|
// compact layout, so a dialogue living in there would be unreadable on
|
||||||
|
// the device the gesture exists for — and the answer commits gigabytes
|
||||||
|
// either way, which deserves the width to say so.
|
||||||
|
//
|
||||||
|
// Below the load bar, above everything else: a download started from
|
||||||
|
// here shows its progress in that bar, and the bar must not be the
|
||||||
|
// thing the dialogue covers.
|
||||||
|
OfflinePrompt {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
title: root.offline-prompt-title;
|
||||||
|
detail: root.offline-prompt-detail;
|
||||||
|
keep-label: root.offline-prompt-keep-label;
|
||||||
|
release-label: root.offline-prompt-release-label;
|
||||||
|
can-keep: root.offline-prompt-can-keep;
|
||||||
|
can-release: root.offline-prompt-can-release;
|
||||||
|
busy: root.offline-prompt-busy;
|
||||||
|
keep() => { root.offline-prompt-keep(); }
|
||||||
|
release() => { root.offline-prompt-release(); }
|
||||||
|
dismiss() => { root.offline-prompt-dismiss(); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,17 @@ export struct CollectionRow {
|
|||||||
expanded: bool,
|
expanded: bool,
|
||||||
// A saved filter. Cannot be dropped onto — its membership is its selector.
|
// A saved filter. Cannot be dropped onto — its membership is its selector.
|
||||||
smart: bool,
|
smart: bool,
|
||||||
|
// TRACES: FR-NC-6a
|
||||||
|
// Whether every photograph in here — and in its children — is pinned for
|
||||||
|
// offline use. A collection holding nothing is never `pinned`: "all zero of
|
||||||
|
// them are kept" is true and useless, and it would draw a full tray beside
|
||||||
|
// an empty collection.
|
||||||
|
pinned: bool,
|
||||||
|
// Pinned in part: some of what is in here is kept, some is not. Distinct
|
||||||
|
// from `pinned` because the two say different things to a user about to
|
||||||
|
// board an aeroplane, and one bool could only say the more optimistic of
|
||||||
|
// them.
|
||||||
|
partly-pinned: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A collection row: disclosure arrow, name, count, and a drop highlight.
|
// A collection row: disclosure arrow, name, count, and a drop highlight.
|
||||||
@@ -70,6 +81,17 @@ component TreeRow inherits Rectangle {
|
|||||||
/// Images were dropped on this row.
|
/// Images were dropped on this row.
|
||||||
callback dropped();
|
callback dropped();
|
||||||
callback context-menu();
|
callback context-menu();
|
||||||
|
/// TRACES: FR-NC-6a
|
||||||
|
/// Ask what should happen to this collection's local copies — keep them, or
|
||||||
|
/// give the disk back. The question is asked rather than a toggle silently
|
||||||
|
/// flipped, because both answers are expensive: one starts a download of
|
||||||
|
/// gigabytes, the other deletes them.
|
||||||
|
callback offline-menu();
|
||||||
|
/// The row is being held, or has been let go. A long press is the touch
|
||||||
|
/// route to [`offline-menu`], and the timer that decides how long is long
|
||||||
|
/// lives in Rust — Slint has no long-press gesture, and a hand-rolled one
|
||||||
|
/// here would need a `Timer` per row in the tree.
|
||||||
|
callback press-changed(bool);
|
||||||
/// Start renaming this row — a double-click on the name.
|
/// Start renaming this row — a double-click on the name.
|
||||||
callback rename-requested();
|
callback rename-requested();
|
||||||
/// The new name, committed with Enter or by clicking away.
|
/// The new name, committed with Enter or by clicking away.
|
||||||
@@ -224,6 +246,54 @@ component TreeRow inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-NC-6a | FR-NC-6c
|
||||||
|
// Keep this collection on the device. Here rather than only in the
|
||||||
|
// grid header because the header's version acts on whatever the grid
|
||||||
|
// is *scoped* to: reaching it means selecting the collection, closing
|
||||||
|
// the sidebar, opening the action row and finding the button — four
|
||||||
|
// steps on a tablet to say "take this trip with me". This is one.
|
||||||
|
//
|
||||||
|
// Drawn only where there is something to keep, and on a row that is
|
||||||
|
// already keeping something: an outline tray beside every collection
|
||||||
|
// in the tree is chrome the eye has to filter past to read the names,
|
||||||
|
// which is what the sidebar is actually for. A smart collection is
|
||||||
|
// excluded for the same reason it refuses a drop — its membership is a
|
||||||
|
// rule that has not been evaluated here.
|
||||||
|
if !root.entry.smart
|
||||||
|
&& (root.entry.deep-count > 0 || root.entry.direct-count > 0)
|
||||||
|
&& (root.selected || root.entry.pinned || root.entry.partly-pinned
|
||||||
|
|| touch.has-hover): Rectangle {
|
||||||
|
width: 18px;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
height: 18px;
|
||||||
|
|
||||||
|
Icon {
|
||||||
|
name: root.entry.pinned ? "offline-held" : "offline";
|
||||||
|
// Held is the state worth colouring: it is a promise the app
|
||||||
|
// has made about a gigabyte of transfer, and it should be
|
||||||
|
// legible from across the room. A partial hold is dimmer than
|
||||||
|
// a full one and brighter than an offer, which is the order
|
||||||
|
// the three actually stand in.
|
||||||
|
ink: root.entry.pinned ? Theme.active
|
||||||
|
: (root.entry.partly-pinned ? Theme.active-dim : Theme.ink-faint);
|
||||||
|
size: 12px;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wider than it is drawn, and no taller. A 44px square here — the
|
||||||
|
// touch minimum — would be 18px taller than the row it sits in and
|
||||||
|
// would take presses aimed at the collections above and below it,
|
||||||
|
// which is a worse failure than a small target: the user hits a
|
||||||
|
// row and a different collection's download starts.
|
||||||
|
TouchArea {
|
||||||
|
width: 30px;
|
||||||
|
height: parent.height;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
clicked => { root.offline-menu(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The count. A parent shows its deep total, since its own direct
|
// The count. A parent shows its deep total, since its own direct
|
||||||
// membership is usually zero and "0" beside a full subtree reads as
|
// membership is usually zero and "0" beside a full subtree reads as
|
||||||
// broken. The distinction is spelled out in the tooltip-less way
|
// broken. The distinction is spelled out in the tooltip-less way
|
||||||
@@ -253,6 +323,21 @@ component TreeRow inherits Rectangle {
|
|||||||
if (ev.kind == PointerEventKind.down
|
if (ev.kind == PointerEventKind.down
|
||||||
&& ev.button == PointerEventButton.right) {
|
&& ev.button == PointerEventButton.right) {
|
||||||
root.context-menu();
|
root.context-menu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The hold that opens the offline question. Reported both ways:
|
||||||
|
// Rust starts a timer on the press and cancels it on the release,
|
||||||
|
// so a tap that happens to be slow is still a tap.
|
||||||
|
//
|
||||||
|
// `cancel` matters as much as `up` here — the sidebar's Flickable
|
||||||
|
// takes the pointer the moment the finger travels, and a press
|
||||||
|
// that became a scroll must not fire a dialogue over the list the
|
||||||
|
// user is scrolling.
|
||||||
|
if (ev.kind == PointerEventKind.down) {
|
||||||
|
root.press-changed(true);
|
||||||
|
}
|
||||||
|
if (ev.kind == PointerEventKind.up || ev.kind == PointerEventKind.cancel) {
|
||||||
|
root.press-changed(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,6 +362,13 @@ export component CollectionsPanel inherits Rectangle {
|
|||||||
callback new-collection();
|
callback new-collection();
|
||||||
// Right-click on a row: rename, delete, new child.
|
// Right-click on a row: rename, delete, new child.
|
||||||
callback row-menu(int);
|
callback row-menu(int);
|
||||||
|
/// TRACES: FR-NC-6a
|
||||||
|
/// Ask about a collection's local copies, by id. Reached by tapping the
|
||||||
|
/// tray on a row, or by holding the row itself.
|
||||||
|
callback offline-menu(int);
|
||||||
|
/// A row is being held, or was let go: its id, and which. The long-press
|
||||||
|
/// timer is in Rust; see `TreeRow.press-changed`.
|
||||||
|
callback row-press(int, bool);
|
||||||
|
|
||||||
/// Which collection is being renamed, by id. 0 is none.
|
/// Which collection is being renamed, by id. 0 is none.
|
||||||
///
|
///
|
||||||
@@ -423,6 +515,8 @@ export component CollectionsPanel inherits Rectangle {
|
|||||||
clicked => { root.select(row.id); }
|
clicked => { root.select(row.id); }
|
||||||
toggle => { root.toggle(row.id); }
|
toggle => { root.toggle(row.id); }
|
||||||
context-menu => { root.row-menu(row.id); }
|
context-menu => { root.row-menu(row.id); }
|
||||||
|
offline-menu => { root.offline-menu(row.id); }
|
||||||
|
press-changed(down) => { root.row-press(row.id, down); }
|
||||||
dropped => { root.dropped-on(row.id); }
|
dropped => { root.dropped-on(row.id); }
|
||||||
drag-over(over) => { root.drag-over(row.id, over); }
|
drag-over(over) => { root.drag-over(row.id, over); }
|
||||||
rename-requested => { root.rename-start(row.id); }
|
rename-requested => { root.rename-start(row.id); }
|
||||||
@@ -564,3 +658,121 @@ export component CollectionsPanel inherits Rectangle {
|
|||||||
background: Theme.rule;
|
background: Theme.rule;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-NC-6a | FR-NC-6c | FR-UI-3
|
||||||
|
// What a held collection asks: keep these photographs on this device, or give
|
||||||
|
// the disk back.
|
||||||
|
//
|
||||||
|
// A question rather than a toggle, because the two answers are not symmetrical
|
||||||
|
// conveniences — one commits the device to downloading gigabytes over whatever
|
||||||
|
// connection it has, and the other deletes gigabytes it already holds. Neither
|
||||||
|
// is something to discover by having done it.
|
||||||
|
//
|
||||||
|
// The sizes are in the labels rather than in a second confirmation, the same
|
||||||
|
// way the grid's "Export 40" puts its count in the button: a number read before
|
||||||
|
// the tap is worth more than a dialogue asking the same question after it.
|
||||||
|
//
|
||||||
|
// Drawn over the whole window rather than inside the sidebar. The sidebar is
|
||||||
|
// 232px wide and collapses entirely in the compact layout — a dialogue that
|
||||||
|
// lived in there would be unreachable on the device this gesture exists for.
|
||||||
|
export component OfflinePrompt inherits Rectangle {
|
||||||
|
/// The collection being asked about. Empty closes the prompt — a single
|
||||||
|
/// source for "is this open", rather than a bool that can disagree with the
|
||||||
|
/// name beside it.
|
||||||
|
in property <string> title;
|
||||||
|
/// What is in there and how much of it is already here, in one line.
|
||||||
|
in property <string> detail;
|
||||||
|
/// The affirmative label, with the transfer it commits to: Rust knows the
|
||||||
|
/// count and the bytes, and this file should not be doing arithmetic on
|
||||||
|
/// them.
|
||||||
|
in property <string> keep-label;
|
||||||
|
/// The destructive label, with the disk it frees.
|
||||||
|
in property <string> release-label;
|
||||||
|
/// Whether there is anything left to download, and anything held to delete.
|
||||||
|
/// Both can be false — an empty collection — and the prompt then says only
|
||||||
|
/// what it holds and offers the way out.
|
||||||
|
in property <bool> can-keep: false;
|
||||||
|
in property <bool> can-release: false;
|
||||||
|
/// Whether a download is already running for this collection, so the
|
||||||
|
/// affirmative reads as "already going" rather than inviting a second one.
|
||||||
|
in property <bool> busy: false;
|
||||||
|
|
||||||
|
callback keep();
|
||||||
|
callback release();
|
||||||
|
callback dismiss();
|
||||||
|
|
||||||
|
visible: root.title != "";
|
||||||
|
background: #000000CC;
|
||||||
|
|
||||||
|
// Swallows everything that misses the card, and dismisses. Declared before
|
||||||
|
// the card so the card's own controls sit above it.
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.dismiss(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: min(420px, parent.width - 2 * Theme.gap-lg);
|
||||||
|
height: card.preferred-height;
|
||||||
|
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;
|
||||||
|
|
||||||
|
// A press on the card must not reach the scrim behind it and close the
|
||||||
|
// thing being read.
|
||||||
|
TouchArea { }
|
||||||
|
|
||||||
|
card := VerticalLayout {
|
||||||
|
padding: Theme.gap-lg;
|
||||||
|
spacing: Theme.gap;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Keep offline";
|
||||||
|
color: Theme.ink-faint;
|
||||||
|
font-size: Theme.text-sm;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.title;
|
||||||
|
color: Theme.ink;
|
||||||
|
font-size: Theme.text-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.detail;
|
||||||
|
color: Theme.ink-dim;
|
||||||
|
font-size: Theme.text;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 1px; background: Theme.rule; }
|
||||||
|
|
||||||
|
// Stacked rather than a row of three. At 420px on a tablet the
|
||||||
|
// labels carry counts and byte sizes, and three of those side by
|
||||||
|
// side elide to "Keep 128 pho…" — which loses exactly the number
|
||||||
|
// the label exists to show.
|
||||||
|
Button {
|
||||||
|
text: root.busy ? "Downloading…" : root.keep-label;
|
||||||
|
primary: true;
|
||||||
|
enabled: root.can-keep && !root.busy;
|
||||||
|
clicked => { root.keep(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.can-release: Button {
|
||||||
|
text: root.release-label;
|
||||||
|
clicked => { root.release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Cancel";
|
||||||
|
clicked => { root.dismiss(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ component Ink inherits Path {
|
|||||||
// chevron-right chevron-down chevron-left arrow-up
|
// chevron-right chevron-down chevron-left arrow-up
|
||||||
// rotate-ccw rotate-cw flip-h flip-v
|
// rotate-ccw rotate-cw flip-h flip-v
|
||||||
// collection collection-smart
|
// collection collection-smart
|
||||||
|
// offline offline-held
|
||||||
export component Icon inherits Rectangle {
|
export component Icon inherits Rectangle {
|
||||||
in property <string> name;
|
in property <string> name;
|
||||||
/// The single colour the whole drawing takes. Named `ink` rather than
|
/// The single colour the whole drawing takes. Named `ink` rather than
|
||||||
@@ -253,4 +254,35 @@ export component Icon inherits Rectangle {
|
|||||||
stroke-width: root.weight;
|
stroke-width: root.weight;
|
||||||
commands: "M 12 9 L 15 12 L 12 15 L 9 12 Z";
|
commands: "M 12 9 L 15 12 L 12 15 L 9 12 Z";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- availability ------------------------------------------------------
|
||||||
|
//
|
||||||
|
// TRACES: FR-NC-6a | FR-NC-6c
|
||||||
|
// "Kept on this device": an arrow coming down into a tray. The download
|
||||||
|
// shape rather than a pin, because what the gesture does is *fetch* — a
|
||||||
|
// pin reads as "stick this to the top of the list", which is what it means
|
||||||
|
// in every other sidebar the user has seen.
|
||||||
|
//
|
||||||
|
// Outline and solid are the same tray with the same arrow, so a collection
|
||||||
|
// that finishes downloading does not shift under the finger that started
|
||||||
|
// it. Solid fills the tray only: an arrow filled as well loses the shaft
|
||||||
|
// at 12px and reads as a blob.
|
||||||
|
|
||||||
|
if root.name == "offline": Ink {
|
||||||
|
stroke: root.ink;
|
||||||
|
stroke-width: root.weight;
|
||||||
|
commands: "M 12 3.6 L 12 13.8 M 7.4 9.4 L 12 14 L 16.6 9.4 "
|
||||||
|
+ "M 4.2 15.4 L 4.2 19.8 L 19.8 19.8 L 19.8 15.4";
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.name == "offline-held": Ink {
|
||||||
|
stroke: root.ink;
|
||||||
|
stroke-width: root.weight;
|
||||||
|
commands: "M 12 3.6 L 12 13.8 M 7.4 9.4 L 12 14 L 16.6 9.4";
|
||||||
|
}
|
||||||
|
if root.name == "offline-held": Ink {
|
||||||
|
fill: root.ink;
|
||||||
|
stroke-width: root.weight;
|
||||||
|
commands: "M 4.2 15.4 L 19.8 15.4 L 19.8 19.8 L 4.2 19.8 Z";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+301
-3
@@ -10,6 +10,10 @@
|
|||||||
|
|
||||||
import { Theme } from "theme.slint";
|
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 } 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";
|
||||||
|
|
||||||
// One bar of the capture-time histogram.
|
// One bar of the capture-time histogram.
|
||||||
export struct TimelineBar {
|
export struct TimelineBar {
|
||||||
@@ -449,6 +453,11 @@ export component FlagMark inherits Rectangle {
|
|||||||
component HeaderActions inherits HorizontalLayout {
|
component HeaderActions inherits HorizontalLayout {
|
||||||
in property <string> scope-label;
|
in property <string> scope-label;
|
||||||
in property <int> selected-count: 0;
|
in property <int> selected-count: 0;
|
||||||
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
/// Whether taps are selecting rather than opening. The non-gesture half of
|
||||||
|
/// touch multi-selection: the long press is the quick way in, and this is
|
||||||
|
/// the way that can be *found*.
|
||||||
|
in property <bool> select-mode: false;
|
||||||
in property <bool> scanning: false;
|
in property <bool> scanning: false;
|
||||||
in property <bool> syncing: false;
|
in property <bool> syncing: false;
|
||||||
in property <bool> scope-pinned: false;
|
in property <bool> scope-pinned: false;
|
||||||
@@ -469,6 +478,9 @@ component HeaderActions inherits HorizontalLayout {
|
|||||||
callback cancel-export();
|
callback cancel-export();
|
||||||
callback paste-settings-to-selection();
|
callback paste-settings-to-selection();
|
||||||
callback remove-from-collection();
|
callback remove-from-collection();
|
||||||
|
/// Open the sheet that files the selection in a collection.
|
||||||
|
callback add-to-collection();
|
||||||
|
callback toggle-select-mode();
|
||||||
callback change-library();
|
callback change-library();
|
||||||
callback toggle-pin-scope();
|
callback toggle-pin-scope();
|
||||||
callback sync-now();
|
callback sync-now();
|
||||||
@@ -477,6 +489,35 @@ component HeaderActions inherits HorizontalLayout {
|
|||||||
|
|
||||||
spacing: Theme.gap;
|
spacing: Theme.gap;
|
||||||
|
|
||||||
|
// TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7
|
||||||
|
// Selection mode, first in the row because it is what the rest of the row
|
||||||
|
// acts on: every button after this one appears once something is selected.
|
||||||
|
//
|
||||||
|
// It exists at all because the desktop's way in — ctrl-click, shift-click —
|
||||||
|
// has no touch equivalent, and the drag that files a photograph in a
|
||||||
|
// collection needs a *selection* before it can carry more than one. On a
|
||||||
|
// tablet the long press does this faster; a gesture with no visible
|
||||||
|
// counterpart is a feature only its author knows about (FR-UI-4).
|
||||||
|
//
|
||||||
|
// "Done" rather than "Selecting": the label should say what pressing it
|
||||||
|
// does, and the inverted fill already says which state we are in.
|
||||||
|
Button {
|
||||||
|
text: root.select-mode ? "Done" : "Select";
|
||||||
|
active: root.select-mode;
|
||||||
|
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||||||
|
clicked => { root.toggle-select-mode(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-7 | FR-UI-4
|
||||||
|
// File the selection in a collection without dragging it there. The drag
|
||||||
|
// is the faster gesture with a pointer and impossible with one finger on a
|
||||||
|
// grid that scrolls, which is the whole reason this button exists.
|
||||||
|
if root.selected-count > 0: Button {
|
||||||
|
text: "Add to collection";
|
||||||
|
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||||||
|
clicked => { root.add-to-collection(); }
|
||||||
|
}
|
||||||
|
|
||||||
// TRACES: FR-DEV-6
|
// TRACES: FR-DEV-6
|
||||||
// Batch-apply the copied settings. Shown only with both a selection and a
|
// Batch-apply the copied settings. Shown only with both a selection and a
|
||||||
// clipboard, because it is meaningless without either — and because a
|
// clipboard, because it is meaningless without either — and because a
|
||||||
@@ -544,9 +585,13 @@ component HeaderActions inherits HorizontalLayout {
|
|||||||
// request, and a button that meant either depending on invisible state
|
// request, and a button that meant either depending on invisible state
|
||||||
// would be a trap.
|
// would be a trap.
|
||||||
if root.scope-label != "": Button {
|
if root.scope-label != "": Button {
|
||||||
// No tick on the pinned label: `active` already inverts the button,
|
// No tick on the kept label: `active` already inverts the button,
|
||||||
// which says the same thing without a symbol inside a string.
|
// which says the same thing without a symbol inside a string.
|
||||||
text: root.scope-pinned ? "Pinned" : "Pin offline";
|
//
|
||||||
|
// It opens the same question the sidebar's tray and a held row open,
|
||||||
|
// rather than pinning outright — see the note on the callback in
|
||||||
|
// `library_ui::wire`.
|
||||||
|
text: root.scope-pinned ? "Kept offline" : "Keep offline";
|
||||||
active: root.scope-pinned;
|
active: root.scope-pinned;
|
||||||
enabled: !root.scanning;
|
enabled: !root.scanning;
|
||||||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||||||
@@ -718,6 +763,29 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
/// Modifier state at press time, so Rust can decide replace / add / extend
|
/// Modifier state at press time, so Rust can decide replace / add / extend
|
||||||
/// without the .slint file encoding the selection policy.
|
/// without the .slint file encoding the selection policy.
|
||||||
callback cell-pressed(int, bool, bool);
|
callback cell-pressed(int, bool, bool);
|
||||||
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
/// The press on a cell ended — lifted, or taken away by the Flickable when
|
||||||
|
/// the finger travelled. Cancels the long-press timer that would otherwise
|
||||||
|
/// turn a scroll into a selection. Rust owns that timer: Slint has no
|
||||||
|
/// long-press gesture, and a hand-rolled one here would need a `Timer` per
|
||||||
|
/// visible cell.
|
||||||
|
callback cell-press-ended();
|
||||||
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
/// Two taps on the same cell. In selection mode this is the touch form of
|
||||||
|
/// shift-click: everything from where the selection started to here. Rust
|
||||||
|
/// decides — outside that mode a double tap is two ordinary clicks and the
|
||||||
|
/// first has already opened the image.
|
||||||
|
callback cell-double-clicked(int);
|
||||||
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
/// Whether a tap selects rather than opens.
|
||||||
|
///
|
||||||
|
/// Held in Rust beside the selection it modifies, so the long press and the
|
||||||
|
/// header's button are two doors into one state rather than two states that
|
||||||
|
/// can disagree. In this mode a plain tap toggles a cell — exactly what
|
||||||
|
/// ctrl-click does with a pointer, which is why the press below passes it
|
||||||
|
/// as the ctrl flag rather than as a third selection policy.
|
||||||
|
in property <bool> select-mode: false;
|
||||||
|
callback toggle-select-mode();
|
||||||
/// The drag payload: the selected image ids, wrapped by Rust. Called when a
|
/// The drag payload: the selected image ids, wrapped by Rust. Called when a
|
||||||
/// drag starts, so it always reflects the selection as it is at that moment.
|
/// drag starts, so it always reflects the selection as it is at that moment.
|
||||||
pure callback drag-payload() -> data-transfer;
|
pure callback drag-payload() -> data-transfer;
|
||||||
@@ -828,6 +896,31 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
/// thing this button does.
|
/// thing this button does.
|
||||||
callback remove-from-collection();
|
callback remove-from-collection();
|
||||||
|
|
||||||
|
// --- filing the selection (FR-CAT-7, FR-UI-4) ---------------------------
|
||||||
|
//
|
||||||
|
// The drag onto the sidebar is the fast way to file photographs, and it is
|
||||||
|
// a pointer gesture: a one-finger drag beginning inside the grid belongs to
|
||||||
|
// the Flickable that scrolls it, which is the arbitration described at the
|
||||||
|
// top of `collections_ui.rs` working exactly as it should. So touch needs a
|
||||||
|
// way in that is not a drag, and that is this sheet.
|
||||||
|
//
|
||||||
|
// The rows are the sidebar's own model, passed through rather than queried
|
||||||
|
// again: two lists of collections is one list too many, and the one that
|
||||||
|
// goes stale is always the one nobody is looking at.
|
||||||
|
in property <[CollectionRow]> collections;
|
||||||
|
/// Whether the sheet is up. Local, because it is a disclosure rather than a
|
||||||
|
/// preference — nothing outside this file needs to know it is open, and
|
||||||
|
/// what closes it is choosing a collection or dismissing it.
|
||||||
|
property <bool> filing: false;
|
||||||
|
/// Whether the images should *leave* the collection being shown, rather
|
||||||
|
/// than being filed in a second one as well. Only meaningful while scoped,
|
||||||
|
/// and reset every time the sheet opens: a destructive default that
|
||||||
|
/// remembered itself between uses is how photographs go missing.
|
||||||
|
property <bool> filing-moves: false;
|
||||||
|
/// File the selection: the target collection's id, and whether to take it
|
||||||
|
/// out of the one currently being shown.
|
||||||
|
callback file-in-collection(int, bool);
|
||||||
|
|
||||||
// Cell geometry. Columns are derived from the available width so the grid
|
// Cell geometry. Columns are derived from the available width so the grid
|
||||||
// reflows with the window rather than fixing a count (FR-UI-1).
|
// reflows with the window rather than fixing a count (FR-UI-1).
|
||||||
// Zoomable, so the grid serves both jobs: fewer, larger images for
|
// Zoomable, so the grid serves both jobs: fewer, larger images for
|
||||||
@@ -943,6 +1036,17 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
cancel-export => { root.cancel-export(); }
|
cancel-export => { root.cancel-export(); }
|
||||||
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
||||||
remove-from-collection => { root.remove-from-collection(); }
|
remove-from-collection => { root.remove-from-collection(); }
|
||||||
|
select-mode: root.select-mode;
|
||||||
|
toggle-select-mode => { root.toggle-select-mode(); }
|
||||||
|
add-to-collection => {
|
||||||
|
// Reset every time it opens: see `filing-moves`.
|
||||||
|
root.filing-moves = false;
|
||||||
|
root.filing = true;
|
||||||
|
// The disclosure has served its purpose and would
|
||||||
|
// otherwise sit behind the sheet, so the row it belongs
|
||||||
|
// to is still there when the sheet closes.
|
||||||
|
root.actions-open = false;
|
||||||
|
}
|
||||||
change-library => { root.change-library(); }
|
change-library => { root.change-library(); }
|
||||||
toggle-pin-scope => { root.toggle-pin-scope(); }
|
toggle-pin-scope => { root.toggle-pin-scope(); }
|
||||||
sync-now => { root.sync-now(); }
|
sync-now => { root.sync-now(); }
|
||||||
@@ -994,6 +1098,17 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
cancel-export => { root.cancel-export(); }
|
cancel-export => { root.cancel-export(); }
|
||||||
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
||||||
remove-from-collection => { root.remove-from-collection(); }
|
remove-from-collection => { root.remove-from-collection(); }
|
||||||
|
select-mode: root.select-mode;
|
||||||
|
toggle-select-mode => { root.toggle-select-mode(); }
|
||||||
|
add-to-collection => {
|
||||||
|
// Reset every time it opens: see `filing-moves`.
|
||||||
|
root.filing-moves = false;
|
||||||
|
root.filing = true;
|
||||||
|
// The disclosure has served its purpose and would
|
||||||
|
// otherwise sit behind the sheet, so the row it belongs
|
||||||
|
// to is still there when the sheet closes.
|
||||||
|
root.actions-open = false;
|
||||||
|
}
|
||||||
change-library => { root.change-library(); }
|
change-library => { root.change-library(); }
|
||||||
toggle-pin-scope => { root.toggle-pin-scope(); }
|
toggle-pin-scope => { root.toggle-pin-scope(); }
|
||||||
sync-now => { root.sync-now(); }
|
sync-now => { root.sync-now(); }
|
||||||
@@ -1300,6 +1415,23 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
init => { self.focus(); }
|
init => { self.focus(); }
|
||||||
|
|
||||||
key-pressed(event) => {
|
key-pressed(event) => {
|
||||||
|
// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
// Back and Escape close what is open here, innermost
|
||||||
|
// first, before the shell above gets to read them as
|
||||||
|
// "leave the library". On Android that is the system Back
|
||||||
|
// 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.filing) {
|
||||||
|
root.filing = false;
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
if (root.select-mode) {
|
||||||
|
root.toggle-select-mode();
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
return reject;
|
||||||
|
}
|
||||||
if (event.text == "0") { root.judged(0, -1); return accept; }
|
if (event.text == "0") { root.judged(0, -1); return accept; }
|
||||||
if (event.text == "1") { root.judged(1, -1); return accept; }
|
if (event.text == "1") { root.judged(1, -1); return accept; }
|
||||||
if (event.text == "2") { root.judged(2, -1); return accept; }
|
if (event.text == "2") { root.judged(2, -1); return accept; }
|
||||||
@@ -1678,14 +1810,30 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
// Selected on *press*, not on release: the drag that may
|
// Selected on *press*, not on release: the drag that may
|
||||||
// follow reads the selection to build its payload, and by
|
// follow reads the selection to build its payload, and by
|
||||||
// release the pointer is over the sidebar.
|
// release the pointer is over the sidebar.
|
||||||
|
//
|
||||||
|
// In selection mode the press is reported as though ctrl
|
||||||
|
// were held. That is not a shortcut: toggling one cell
|
||||||
|
// while keeping the rest *is* what ctrl-click means, and
|
||||||
|
// giving touch its own policy would be a second copy of
|
||||||
|
// the rules in `collections_ui::apply_press` to keep in
|
||||||
|
// step with the first.
|
||||||
pointer-event(ev) => {
|
pointer-event(ev) => {
|
||||||
if (ev.kind == PointerEventKind.down) {
|
if (ev.kind == PointerEventKind.down) {
|
||||||
root.cell-pressed(
|
root.cell-pressed(
|
||||||
i,
|
i,
|
||||||
ev.modifiers.control,
|
ev.modifiers.control || root.select-mode,
|
||||||
ev.modifiers.shift,
|
ev.modifiers.shift,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Both endings, and `cancel` is the important one:
|
||||||
|
// the Flickable takes the pointer as soon as the
|
||||||
|
// finger travels, so without this a scroll that
|
||||||
|
// began on a cell would come to rest as a long
|
||||||
|
// press and select it.
|
||||||
|
if (ev.kind == PointerEventKind.up
|
||||||
|
|| ev.kind == PointerEventKind.cancel) {
|
||||||
|
root.cell-press-ended();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A *plain* click opens the image; a modified one is purely
|
// A *plain* click opens the image; a modified one is purely
|
||||||
@@ -1695,6 +1843,11 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
// records it and Rust decides — `cell-clicked` is only
|
// records it and Rust decides — `cell-clicked` is only
|
||||||
// honoured when the press was unmodified.
|
// honoured when the press was unmodified.
|
||||||
clicked => { root.cell-clicked(i); }
|
clicked => { root.cell-clicked(i); }
|
||||||
|
// The far end of a range, in selection mode. Slint
|
||||||
|
// delivers `clicked` for the first tap as well, which
|
||||||
|
// is why the toggling is idempotent-by-union in Rust
|
||||||
|
// rather than this file trying to swallow one of them.
|
||||||
|
double-clicked => { root.cell-double-clicked(i); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- the rating strip, ABOVE the cell's own hit area ---
|
// --- the rating strip, ABOVE the cell's own hit area ---
|
||||||
@@ -1735,4 +1888,149 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- the filing sheet (FR-CAT-7, FR-UI-4) -------------------------------
|
||||||
|
//
|
||||||
|
// "Put these in…", for the times a drag is not available: one finger on a
|
||||||
|
// scrolling grid, or a selection made across a scrub where the sidebar has
|
||||||
|
// long since been closed to give the photographs the width.
|
||||||
|
//
|
||||||
|
// Last in the file, so it draws over the grid — and outside the
|
||||||
|
// VerticalLayout above, so appearing does not reflow the header and the
|
||||||
|
// cells underneath it.
|
||||||
|
if root.filing: 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.filing = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: min(420px, parent.width - 2 * Theme.gap-lg);
|
||||||
|
// Tall enough for the list, but never taller than the window: a
|
||||||
|
// library with forty collections must still leave the buttons on
|
||||||
|
// screen, which is what the Flickable inside is for.
|
||||||
|
height: min(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 { }
|
||||||
|
|
||||||
|
sheet := VerticalLayout {
|
||||||
|
padding: Theme.gap-lg;
|
||||||
|
spacing: Theme.gap;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.selected-count == 1
|
||||||
|
? "File 1 photograph in…"
|
||||||
|
: "File " + root.selected-count + " photographs in…";
|
||||||
|
color: Theme.ink;
|
||||||
|
font-size: Theme.text-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filing is a *copy*: a photograph can be in as many
|
||||||
|
// collections as it belongs in, which is what a join table
|
||||||
|
// means and what the drag has always done. Moving is the
|
||||||
|
// exception and has to be asked for, because it is the one
|
||||||
|
// that takes something away.
|
||||||
|
if root.scope-label != "": Button {
|
||||||
|
text: root.filing-moves
|
||||||
|
? "Moving out of " + root.scope-label
|
||||||
|
: "Also keep in " + root.scope-label;
|
||||||
|
active: root.filing-moves;
|
||||||
|
clicked => { root.filing-moves = !root.filing-moves; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 1px; background: Theme.rule; }
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
vertical-stretch: 1;
|
||||||
|
// A floor, so the list is not squeezed out of existence by
|
||||||
|
// the buttons around it on a short window.
|
||||||
|
min-height: 120px;
|
||||||
|
viewport-height: root.collections.length * (Theme.touch-target + 2px);
|
||||||
|
|
||||||
|
for row[i] in root.collections: Rectangle {
|
||||||
|
y: i * (Theme.touch-target + 2px);
|
||||||
|
width: parent.width;
|
||||||
|
// A full touch target per row, where the sidebar's
|
||||||
|
// equivalent is 26px. The sidebar is a place to look;
|
||||||
|
// 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: row-touch.pressed ? Theme.pressed
|
||||||
|
: (row-touch.has-hover ? Theme.hover : transparent);
|
||||||
|
border-radius: Theme.radius-sm;
|
||||||
|
// A saved filter's membership is its selector, so it
|
||||||
|
// cannot be filed into — the same refusal the sidebar
|
||||||
|
// makes on a drag, made here before the tap rather
|
||||||
|
// than after it.
|
||||||
|
opacity: row.smart ? 0.4 : 1.0;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: Theme.gap-sm + row.depth * Theme.indent;
|
||||||
|
padding-right: Theme.gap-sm;
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
|
||||||
|
Icon {
|
||||||
|
name: row.smart ? "collection-smart" : "collection";
|
||||||
|
ink: Theme.ink-faint;
|
||||||
|
size: 14px;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: row.name;
|
||||||
|
color: Theme.ink;
|
||||||
|
font-size: Theme.text;
|
||||||
|
vertical-alignment: center;
|
||||||
|
overflow: elide;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: row.smart ? "computed"
|
||||||
|
: (row.deep-count > 0 ? row.deep-count + "" : "");
|
||||||
|
color: Theme.ink-faint;
|
||||||
|
font-size: Theme.text-sm;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row-touch := TouchArea {
|
||||||
|
enabled: !row.smart;
|
||||||
|
clicked => {
|
||||||
|
root.file-in-collection(row.id, root.filing-moves);
|
||||||
|
root.filing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.collections.length == 0: Text {
|
||||||
|
text: "No collections yet. Make one with + in the sidebar.";
|
||||||
|
color: Theme.ink-faint;
|
||||||
|
font-size: Theme.text-sm;
|
||||||
|
wrap: word-wrap;
|
||||||
|
width: parent.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 1px; background: Theme.rule; }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Cancel";
|
||||||
|
clicked => { root.filing = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user