Say what the application is doing, in one bar and one list

Every background job reported into a window property of its own —
library-thumbs-done, library-pin-total, library-syncing — which only the
grid ever read. A pin download that outlived the view it was started from
drew nothing at all once the user opened an image, and there was no answer
anywhere to "what is this busy with", because the answer was spread across
eight properties nothing collected.

They report to one register now (ui/dr-ui/src/activity.rs). It publishes an
aggregate, which draws a three-pixel bar across the top of the shell in
every view, and a row per job, which the settings page lists: scans,
thumbnail batches, pin and open downloads, sidecar uploads, the sync and
the trash. Failures stay on the list until they are cleared; routine
successes do not, or a scroll would bury them.

The handle removes a still-running job when it drops, so a worker that dies
mid-transfer takes its row with it rather than leaving the bar sweeping for
the rest of the session.

Also carries in-flight work from a parallel session — the drawn icon set
and the dr-pipeline ops split. dr-pipeline's build script does not compile
at this commit; ui/dr-ui does, with clippy clean and its tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 18:32:09 +02:00
co-authored by Claude Opus 5
parent 70435b712e
commit 65e6a96a65
27 changed files with 3873 additions and 236 deletions
+613
View File
@@ -0,0 +1,613 @@
//! TRACES: FR-CAT-1 | FR-NC-6 | FR-NC-6c
//! One register of the work running behind the interface.
//!
//! Every background operation in this crate has the same shape: a worker
//! thread with an `mpsc` channel, drained by a `slint::Timer` on the UI thread.
//! Each of them used to report into a window property of its own —
//! `library-thumbs-done`, `library-pin-total`, `library-syncing` — and that had
//! two consequences worth removing.
//!
//! - **Only the grid could see them.** A pin download outlives the view it was
//! started from, so opening an image made an hour of transfers invisible: the
//! properties are read by `LibraryGrid` and nothing else.
//! - **There was no answer to "what is this doing".** The answer was spread
//! across eight properties that no code path ever collected, which is why the
//! settings page could not show a list of transfers: there was nothing to
//! list.
//!
//! So the jobs report here instead, and this publishes twice: an aggregate that
//! drives the bar across the top of the shell, and a row per job for the
//! settings page.
//!
//! # Everything here is single-threaded on purpose
//!
//! No `Arc`, no lock. The workers already hand their progress to the UI thread
//! through a channel, and the drain that reads it is the only thing that talks
//! to this register — so the shared-state problem was solved before this file
//! existed, and re-solving it with a mutex would only add a lock that is never
//! contended.
//!
//! # A job that stops reporting cannot hang the bar
//!
//! [`Activity`] is a handle whose `Drop` removes a still-running job. The drain
//! closures own their handle, so a worker that dies mid-transfer, a timer
//! replaced by a newer batch, or a window that closes all take their rows with
//! them. Without that, one lost `Finished` message would leave the bar sweeping
//! for the rest of the session — and a progress indicator that lies about
//! whether anything is happening is worse than none.
use std::cell::{Cell, RefCell};
use std::rc::{Rc, Weak};
use slint::ComponentHandle;
use crate::{ActivityRow, AppWindow};
/// How many stopped jobs to keep.
///
/// A short history rather than none: "did the sync work?" is asked *after* the
/// sync, and a list that empties the instant a job ends can only ever answer
/// questions about the present. Short, because it is a status list and not a
/// log — the ones worth keeping past this are failures, and those are held
/// until the user clears them.
const KEEP_STOPPED: usize = 8;
/// How often the register publishes into the window.
///
/// Publishing on every change would rebuild the row model once per drained
/// message, and a thumbnail batch drains a hundred in a tick. This is fast
/// enough that a bar looks live and slow enough that a burst of completions
/// costs one update rather than a hundred.
const PUBLISH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(150);
/// What kind of work a job is.
///
/// Coarser than the set of functions that start jobs: the user's question is
/// "is something downloading", not which of three call sites issued the fetch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
/// Walking the remote library (FR-CAT-1).
Scan,
/// Reading capture times across the whole library.
Index,
/// Previews for the cells on screen.
Thumbnails,
/// Original files coming down — a pin, or an image being opened.
Download,
/// Sidecars and derived data going up.
Upload,
/// The two-way exchange of catalog and shards.
Sync,
/// Moving files to the trash, restoring them, or emptying it.
Trash,
}
impl Kind {
/// Whether this job moves bytes over the network.
///
/// FR-NC-6c: the user's question on a slow or metered connection is about
/// *transfers* specifically, so the row says which jobs are ones.
pub fn is_transfer(self) -> bool {
match self {
Kind::Scan
| Kind::Thumbnails
| Kind::Download
| Kind::Upload
| Kind::Sync
| Kind::Trash => true,
// Reads headers over the network today, but it is bounded by the
// catalog rather than by anything the user asked to move, and it
// stores nothing. Calling it a transfer would put an hours-long
// background sweep in the same sentence as a download they are
// waiting on.
Kind::Index => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Running,
Done,
Failed,
}
/// One job's row in the register.
#[derive(Debug, Clone)]
struct Record {
id: u64,
kind: Kind,
title: String,
detail: String,
done: usize,
/// Zero means "no denominator" — a directory walk cannot know its extent,
/// and a percentage invented for it is worse than admitting as much.
total: usize,
state: State,
}
impl Record {
fn fraction(&self) -> f32 {
if self.total == 0 {
return 0.0;
}
(self.done as f32 / self.total as f32).clamp(0.0, 1.0)
}
fn row(&self) -> ActivityRow {
ActivityRow {
title: self.title.clone().into(),
detail: self.detail.clone().into(),
fraction: self.fraction(),
determinate: self.total > 0,
running: self.state == State::Running,
failed: self.state == State::Failed,
transfer: self.kind.is_transfer(),
}
}
}
/// The aggregate the shell's bar is drawn from.
#[derive(Debug, Clone, PartialEq)]
pub struct Summary {
pub running: usize,
/// Stopped jobs still listed — recent successes and unread failures.
pub kept: usize,
/// `None` where nothing running knows its own extent.
pub fraction: Option<f32>,
}
/// Every background job the application is running.
pub struct ActivityLog {
records: RefCell<Vec<Record>>,
next_id: Cell<u64>,
/// Where to publish. Absent in tests, which is what makes the whole
/// register testable without a window.
window: RefCell<Option<slint::Weak<AppWindow>>>,
pump: RefCell<Option<slint::Timer>>,
dirty: Cell<bool>,
}
impl Default for ActivityLog {
/// An empty register attached to nothing.
///
/// Publishing is a no-op until [`ActivityLog::attach`] gives it a window,
/// which is what lets a controller hold one in a test with no interface
/// running — and what makes this file's own tests possible.
fn default() -> Self {
Self {
records: RefCell::new(Vec::new()),
next_id: Cell::new(1),
window: RefCell::new(None),
pump: RefCell::new(None),
dirty: Cell::new(false),
}
}
}
impl ActivityLog {
pub fn new() -> Rc<Self> {
Rc::new(Self::default())
}
/// Start publishing into `window`.
///
/// The timer holds a *weak* reference back: an `Rc` here would be a cycle
/// through the closure, and the register would outlive the window it is
/// describing.
pub fn attach(self: &Rc<Self>, window: &AppWindow) {
*self.window.borrow_mut() = Some(window.as_weak());
let weak = Rc::downgrade(self);
let timer = slint::Timer::default();
timer.start(slint::TimerMode::Repeated, PUBLISH_INTERVAL, move || {
let Some(log) = Weak::upgrade(&weak) else {
return;
};
if log.dirty.replace(false) {
log.publish();
}
});
*self.pump.borrow_mut() = Some(timer);
// Once immediately, so an empty register starts out saying so rather
// than leaving whatever the properties defaulted to.
self.publish();
}
/// Register a job and hand back the handle that reports on it.
pub fn begin(self: &Rc<Self>, kind: Kind, title: impl Into<String>) -> Activity {
let id = self.next_id.get();
self.next_id.set(id + 1);
self.records.borrow_mut().push(Record {
id,
kind,
title: title.into(),
detail: String::new(),
done: 0,
total: 0,
state: State::Running,
});
self.touch();
Activity {
log: self.clone(),
id,
}
}
/// Drop every stopped job, keeping what is still running.
pub fn clear_finished(&self) {
self.records
.borrow_mut()
.retain(|r| r.state == State::Running);
self.touch();
}
pub fn summary(&self) -> Summary {
let records = self.records.borrow();
let running: Vec<&Record> = records
.iter()
.filter(|r| r.state == State::Running)
.collect();
// Summed across jobs rather than averaged: two downloads of wildly
// different size are one wait as far as the person watching is
// concerned, and averaging their percentages would have the bar jump
// backwards whenever a small job joined a large one.
let (done, total) = running
.iter()
.filter(|r| r.total > 0)
.fold((0usize, 0usize), |(d, t), r| (d + r.done, t + r.total));
Summary {
running: running.len(),
kept: records.len() - running.len(),
fraction: (total > 0).then(|| (done as f32 / total as f32).clamp(0.0, 1.0)),
}
}
/// The rows the settings page lists, running jobs first.
///
/// Stable within each group — insertion order — so a list being watched
/// does not reshuffle itself under the reader every time a count changes.
fn rows(&self) -> Vec<ActivityRow> {
let records = self.records.borrow();
records
.iter()
.filter(|r| r.state == State::Running)
.chain(records.iter().filter(|r| r.state != State::Running))
.map(Record::row)
.collect()
}
fn publish(&self) {
let Some(window) = self.window.borrow().as_ref().and_then(slint::Weak::upgrade) else {
return;
};
// Built before anything is written, so no property setter can run with
// `records` borrowed.
let rows = self.rows();
let summary = self.summary();
window.set_activity_rows(slint::ModelRc::new(slint::VecModel::from(rows)));
window.set_activity_busy(summary.running > 0);
window.set_activity_running(summary.running as i32);
window.set_activity_kept(summary.kept as i32);
window.set_activity_determinate(summary.fraction.is_some());
window.set_activity_fraction(summary.fraction.unwrap_or(0.0));
}
fn touch(&self) {
self.dirty.set(true);
}
/// Edit a *running* job.
///
/// A stopped one is left alone: a drain loop reads whatever the channel
/// still holds after the message that ended the job, and a queued progress
/// update arriving second would otherwise overwrite the failure the user
/// needs to read with a count that no longer means anything.
fn with(&self, id: u64, f: impl FnOnce(&mut Record)) {
if let Some(record) = self
.records
.borrow_mut()
.iter_mut()
.find(|r| r.id == id && r.state == State::Running)
{
f(record);
}
self.touch();
}
/// Stop a job, keeping it in the list.
///
/// First outcome wins, for the reason given on [`ActivityLog::with`]: a
/// worker that reports a failure and then closes its channel has failed,
/// and the closing must not relabel it as finished.
fn stop(&self, id: u64, state: State, detail: String) {
{
let mut records = self.records.borrow_mut();
let Some(record) = records
.iter_mut()
.find(|r| r.id == id && r.state == State::Running)
else {
return;
};
record.state = state;
record.detail = detail;
// A finished job reads as complete whatever it counted: a scan that
// ends having found forty of an unknown number is done, not 40%.
if state == State::Done && record.total > 0 {
record.done = record.total;
}
trim(&mut records);
}
self.touch();
}
/// Take a running job out of the register entirely.
///
/// Returns whether there was one to remove, which is how [`Activity::drop`]
/// tells "the drain forgot about this" from "it stopped properly".
fn remove(&self, id: u64) -> bool {
let removed = {
let mut records = self.records.borrow_mut();
let before = records.len();
records.retain(|r| !(r.id == id && r.state == State::Running));
records.len() != before
};
self.touch();
removed
}
}
/// Drop the oldest stopped jobs past [`KEEP_STOPPED`].
///
/// Failures are exempt: a transfer that failed while the user was elsewhere is
/// the single most useful thing this list holds, and letting eight successful
/// thumbnail batches push it out would lose exactly the row worth keeping.
fn trim(records: &mut Vec<Record>) {
let mut excess = records
.iter()
.filter(|r| r.state == State::Done)
.count()
.saturating_sub(KEEP_STOPPED);
records.retain(|r| {
if excess > 0 && r.state == State::Done {
excess -= 1;
return false;
}
true
});
}
/// Bytes as a figure to put beside a transfer.
///
/// Three scales rather than one: a sidecar is a few kilobytes and a RAW file is
/// tens of megabytes, and a single unit makes one of them read as "0.0" or as
/// six digits. One decimal at most — this is a status line, not a measurement.
pub fn describe_bytes(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = 1024.0 * KB;
const GB: f64 = 1024.0 * MB;
let b = bytes as f64;
if b >= GB {
format!("{:.1} GB", b / GB)
} else if b >= MB {
format!("{:.1} MB", b / MB)
} else {
format!("{:.0} kB", (b / KB).ceil())
}
}
/// A running job, held by whatever is reporting on it.
///
/// Every method is idempotent and every one is a no-op once the job has
/// stopped, because the drains that call them are loops over a channel that may
/// deliver a late message after the one that ended the job.
pub struct Activity {
log: Rc<ActivityLog>,
id: u64,
}
impl Activity {
/// What the job is currently doing, in the job's own words.
pub fn detail(&self, detail: impl Into<String>) {
let detail = detail.into();
self.log.with(self.id, |r| r.detail = detail);
}
/// How much work there is. Zero leaves the job indeterminate.
pub fn total(&self, total: usize) {
self.log.with(self.id, |r| r.total = total);
}
/// More work turned up mid-flight — a batch that grew once the plan came
/// back. Added rather than replaced, so the count already served stays
/// meaningful.
pub fn add_total(&self, extra: usize) {
self.log.with(self.id, |r| r.total += extra);
}
/// One unit of work finished.
pub fn advance(&self) {
self.log.with(self.id, |r| r.done += 1);
}
/// Set both counts at once, for workers that report a running total.
pub fn progress(&self, done: usize, total: usize) {
self.log.with(self.id, |r| {
r.done = done;
r.total = total;
});
}
/// The job finished. `note` is what the list shows afterwards.
pub fn finish(&self, note: impl Into<String>) {
self.log.stop(self.id, State::Done, note.into());
}
/// The job finished and is not worth remembering.
///
/// For routine work the user never asked for by name: scrolling the grid
/// starts a thumbnail batch every second or so, and keeping those would
/// push a failed transfer out of the list within moments of it happening.
/// A failure is still kept — only [`Activity::finish`]'s history is
/// skipped.
pub fn finish_quietly(&self) {
self.log.remove(self.id);
}
/// The job stopped without doing what it set out to do.
///
/// Kept in the list until the user clears it: this is the row they came to
/// the settings page to find.
pub fn fail(&self, message: impl Into<String>) {
self.log.stop(self.id, State::Failed, message.into());
}
}
impl Drop for Activity {
/// A job whose handle goes away while it is still running is removed, not
/// marked failed. Nobody is coming back to report on it, and a row frozen
/// at "downloading, 12 of 900" would sit in the list claiming to be live
/// for the rest of the session.
fn drop(&mut self) {
if self.log.remove(self.id) {
log::debug!("a background job ended without saying so");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_running_job_makes_the_bar_busy() {
let log = ActivityLog::new();
assert_eq!(log.summary().running, 0);
let scan = log.begin(Kind::Scan, "Scanning");
assert_eq!(log.summary().running, 1);
// A scan has no denominator, so the bar must not claim a position.
assert_eq!(log.summary().fraction, None);
scan.finish("up to date");
assert_eq!(log.summary().running, 0);
}
#[test]
fn progress_is_summed_across_jobs() {
let log = ActivityLog::new();
let a = log.begin(Kind::Thumbnails, "Thumbnails");
let b = log.begin(Kind::Download, "Downloading");
a.progress(1, 10);
b.progress(4, 10);
// 5 of 20, not the average of 10% and 40%.
assert_eq!(log.summary().fraction, Some(0.25));
}
#[test]
fn a_job_without_a_denominator_does_not_dilute_one_that_has_it() {
let log = ActivityLog::new();
let scan = log.begin(Kind::Scan, "Scanning");
let fetch = log.begin(Kind::Download, "Downloading");
fetch.progress(3, 4);
scan.detail("120 folders");
assert_eq!(log.summary().fraction, Some(0.75));
}
#[test]
fn a_dropped_handle_takes_its_row_with_it() {
let log = ActivityLog::new();
{
let _thumbs = log.begin(Kind::Thumbnails, "Thumbnails");
assert_eq!(log.summary().running, 1);
}
// The worker died, or a newer batch replaced the timer holding this.
// Either way the bar must not sweep for ever.
assert_eq!(log.summary().running, 0);
assert_eq!(log.summary().kept, 0);
}
#[test]
fn a_finished_job_is_kept_and_reads_as_complete() {
let log = ActivityLog::new();
let sweep = log.begin(Kind::Index, "Indexing");
sweep.progress(40, 100);
// Ended early with everything it was going to do done.
sweep.finish("4000 dated");
drop(sweep);
let summary = log.summary();
assert_eq!(summary.running, 0);
assert_eq!(summary.kept, 1, "a finished job stays in the list");
let rows = log.rows();
assert_eq!(rows[0].fraction, 1.0);
assert!(!rows[0].running);
assert!(!rows[0].failed);
}
#[test]
fn a_failure_survives_a_run_of_successes() {
let log = ActivityLog::new();
log.begin(Kind::Download, "Downloading")
.fail("host is down");
for i in 0..KEEP_STOPPED * 2 {
log.begin(Kind::Thumbnails, format!("Batch {i}"))
.finish("done");
}
let rows = log.rows();
assert!(
rows.iter().any(|r| r.failed),
"the one row worth keeping was pushed out by routine successes"
);
assert!(rows.len() <= KEEP_STOPPED + 1, "history is bounded");
}
#[test]
fn clearing_leaves_running_jobs_alone() {
let log = ActivityLog::new();
let scan = log.begin(Kind::Scan, "Scanning");
log.begin(Kind::Sync, "Syncing").fail("timed out");
log.clear_finished();
assert_eq!(log.summary().kept, 0);
assert_eq!(log.summary().running, 1);
drop(scan);
}
#[test]
fn running_jobs_are_listed_first() {
let log = ActivityLog::new();
log.begin(Kind::Sync, "Syncing").finish("nothing to do");
let scan = log.begin(Kind::Scan, "Scanning");
let rows = log.rows();
assert_eq!(rows[0].title, "Scanning");
assert!(rows[0].running);
drop(scan);
}
#[test]
fn a_late_message_cannot_restart_a_stopped_job() {
let log = ActivityLog::new();
let fetch = log.begin(Kind::Download, "Downloading");
fetch.fail("connection reset");
// The drain reads one more queued message before it notices.
fetch.advance();
assert_eq!(log.summary().running, 0);
assert!(log.rows()[0].failed);
}
}
+40 -2
View File
@@ -142,11 +142,20 @@ pub struct CollectionsController {
/// the tree, the grid cells — and doing that from inside the `dropped`
/// handler destroys the elements Slint is still using to deliver the event.
dropped_on: RefCell<Option<CollectionId>>,
/// Where the trash workers report what they are doing.
///
/// Shared with [`crate::library_ui`]: a delete and a scan are two jobs in
/// one list, and the user asking what the application is busy with does not
/// care which module started them.
activity: Rc<crate::activity::ActivityLog>,
}
impl CollectionsController {
pub fn new() -> Rc<Self> {
Rc::new(Self::default())
pub fn new(activity: Rc<crate::activity::ActivityLog>) -> Rc<Self> {
Rc::new(Self {
activity,
..Default::default()
})
}
/// Which collection the grid is scoped to, for [`crate::library_ui`] to
@@ -786,6 +795,7 @@ fn start_trash(
// survive as a set of ids the user can no longer see.
ctl.clear_selection();
let count = moves.len();
let rx = crate::trash::spawn_move(
creds,
sess.user_id.clone(),
@@ -800,6 +810,7 @@ fn start_trash(
catalog.clone(),
rx,
reload.clone(),
format!("Moving {count} photograph(s) to the trash"),
);
}
@@ -861,6 +872,7 @@ fn start_restore(
// would survive as ids the user can no longer see.
ctl.clear_selection();
let count = moves.len();
let rx = crate::trash::spawn_move(
creds,
sess.user_id.clone(),
@@ -875,6 +887,7 @@ fn start_restore(
catalog.clone(),
rx,
reload.clone(),
format!("Restoring {count} photograph(s)"),
);
}
@@ -888,12 +901,22 @@ fn drain_trash(
catalog: Rc<RefCell<Option<Catalog>>>,
rx: std::sync::mpsc::Receiver<crate::trash::TrashMessage>,
reload: Rc<dyn Fn()>,
// What the register calls this operation. Passed in rather than derived
// here: the three callers move files to the trash, back out of it, and
// delete them outright, and "Deleting 40 photographs" is the one word of
// the three that must not appear over a restore.
title: String,
) {
use crate::trash::TrashMessage;
let timer = slint::Timer::default();
let ctl_cb = ctl.clone();
// A server-side MOVE per file, so it is a transfer in the sense that
// matters: it takes as long as the connection is slow, and it can fail
// halfway with the library in two states at once.
let job = ctl.activity.begin(crate::activity::Kind::Trash, title);
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(120),
@@ -907,6 +930,7 @@ fn drain_trash(
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
// A worker that died without reporting must not leave the
// status line mid-sentence.
job.fail("stopped without finishing");
stop_trash(&ctl_cb);
return;
}
@@ -923,6 +947,10 @@ fn drain_trash(
} else {
format!("{done} / {total}")
};
job.progress(done, total);
if failed > 0 {
job.detail(format!("{failed} failed"));
}
w.set_library_status(status.into());
}
TrashMessage::Done { moved, failed } => {
@@ -934,6 +962,14 @@ fn drain_trash(
} else {
format!("{moved} done · {} failed", failed.len())
};
// A partial failure is a failure in the register: the
// library is now in two states at once, which is
// exactly the thing worth keeping on the list.
if failed.is_empty() {
job.finish(status.clone());
} else {
job.fail(status.clone());
}
w.set_library_status(status.into());
if let Some(first) = failed.first() {
w.set_collection_error(first.as_str().into());
@@ -1396,6 +1432,7 @@ pub fn wire<S, R, C>(
log::info!("emptying trash: {} image(s)", ids.len());
w.set_library_status(format!("Deleting {} image(s)…", ids.len()).into());
let count = ids.len();
let rx = crate::trash::spawn_purge(
creds,
@@ -1412,6 +1449,7 @@ pub fn wire<S, R, C>(
catalog.clone(),
rx,
reload.clone(),
format!("Deleting {count} photograph(s) permanently"),
);
});
}
+26 -2
View File
@@ -14,6 +14,7 @@
//! pipeline what parameters it has and builds a control per answer; no code
//! in `ui/` names an operation or knows a shader exists (FR-DEV-3a).
mod activity;
mod collections_ui;
mod derived_sync;
mod develop;
@@ -434,6 +435,18 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let window = AppWindow::new()?;
// Every background job reports here, and this draws the bar across the top
// of the shell and fills the settings page's list. Built before the
// controllers because they take a handle to it: a job that starts during
// startup — the scan a resumed session begins immediately — has to have
// somewhere to report to before it starts, or its first minute is invisible.
let activity = activity::ActivityLog::new();
activity.attach(&window);
{
let activity = activity.clone();
window.on_activity_clear_finished(move || activity.clear_finished());
}
// Set once `show` exists; see where the library grid is wired below.
#[allow(clippy::type_complexity)]
let open_from_library: Rc<RefCell<Option<Rc<dyn Fn(String)>>>> = Rc::new(RefCell::new(None));
@@ -449,7 +462,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// Declared out here rather than inside the launch block below because the
// develop side reads `paths` to rebuild its browsing list when an image is
// opened from the grid.
let library = library_ui::LibraryController::new();
let library = library_ui::LibraryController::new(activity.clone());
// Launch screen: shown when there is nothing to display — no local paths
// and no configured library. A user who has already signed in and chosen
@@ -460,7 +473,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
window.set_show_launch(startup == launch::Startup::ShowLaunchScreen);
let library = library.clone();
let collections = collections_ui::CollectionsController::new();
let collections = collections_ui::CollectionsController::new(activity.clone());
// The click handler needs `show`, which is built further down because
// it captures the develop session and the GPU context. This cell is
@@ -860,6 +873,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let redraw = redraw.clone();
let rows = rows.clone();
let gpu = gpu.clone();
let activity = activity.clone();
*open_from_library.borrow_mut() = Some(Rc::new(move |path: String| {
let Some(w) = weak.upgrade() else { return };
@@ -895,6 +909,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let rx = library::spawn_full_fetch(creds, user_id, path.clone(), cache);
// The one transfer the user is actively waiting on. It gets a row
// like any other, so a download that is still running after they
// give up and go back to the grid is still accounted for.
//
// No denominator: `spawn_full_fetch` reports a result, not bytes as
// they arrive, so the honest bar here is the indeterminate one.
let job = activity.begin(activity::Kind::Download, format!("Downloading {name}"));
// Polled on the UI thread rather than joined: a join would freeze
// the window for the length of the download.
let weak = w.as_weak();
@@ -916,6 +938,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let bytes = match got {
Ok(b) => b,
Err(e) => {
job.fail(e.message.clone());
log::warn!("{name}: {e}");
// Offline needs its own words. "network error:
// connection refused" over a photograph the user
@@ -931,6 +954,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
return;
}
};
job.finish(activity::describe_bytes(bytes.len() as u64));
log::info!("{name}: {} bytes fetched", bytes.len());
match load_bytes(gpu.as_ref(), &bytes) {
+123 -28
View File
@@ -198,11 +198,19 @@ pub struct LibraryController {
/// small budget still keeps a working set. A metered or small-disk device
/// wants the first.
keep_opened: std::cell::Cell<bool>,
/// Where every worker this module starts reports what it is doing.
///
/// Held on the controller rather than passed to each function because the
/// jobs are started from a dozen callbacks — a scroll, a rescan, a pin, a
/// reconnect — and threading a second argument through all of them would
/// say nothing except that they all report progress.
activity: Rc<crate::activity::ActivityLog>,
}
impl LibraryController {
pub fn new() -> Rc<Self> {
pub fn new(activity: Rc<crate::activity::ActivityLog>) -> Rc<Self> {
Rc::new(Self {
activity,
catalog: Rc::new(RefCell::new(None)),
paths: RefCell::new(Vec::new()),
file_ids: RefCell::new(Vec::new()),
@@ -536,6 +544,20 @@ fn drain_scan(
let timer = slint::Timer::default();
let ctl_cb = ctl.clone();
// Indeterminate for as long as it runs: a recursive walk discovers its own
// extent, so the count it reports is what it has *found*, never a fraction
// of what there is (FR-CAT-1).
//
// Named after the folder, because two accounts or two roots produce rows
// that are otherwise identical.
let title = match ctl.session.borrow().as_ref() {
Some((_, session, _)) if !session.root.is_empty() => {
format!("Scanning {}", session.root)
}
_ => "Scanning the library".to_string(),
};
let job = ctl.activity.begin(crate::activity::Kind::Scan, title);
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(120),
@@ -553,6 +575,7 @@ fn drain_scan(
if w.get_library_scanning() {
w.set_library_scanning(false);
w.set_library_error("scan ended unexpectedly".into());
job.fail("ended unexpectedly");
}
stop(&ctl.scan_timer);
return;
@@ -574,6 +597,7 @@ fn drain_scan(
} else {
format!("{directories} folders · {images} images")
};
job.detail(status.clone());
w.set_library_status(status.into());
}
ScanMessage::Done {
@@ -611,6 +635,7 @@ fn drain_scan(
} else {
format!("{total} images · {secs:.1}s")
};
job.finish(status.clone());
w.set_library_status(status.into());
match Catalog::open(&catalog_path) {
@@ -636,6 +661,11 @@ fn drain_scan(
ScanMessage::Failed { message, offline } => {
log::warn!("scan failed: {message}");
w.set_library_scanning(false);
// Recorded as a failure even where it is only the
// connection: the grid's offline banner says the server
// is unreachable, and this says which piece of work
// stopped because of it.
job.fail(message.clone());
if offline {
// Not an error state. The catalog from the last
@@ -829,6 +859,14 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
let weak = window.as_weak();
let ctl_cb = ctl.clone();
// The longest-running transfer the app does, and the one most likely to be
// watched from another view — which is the whole reason the register
// exists (FR-NC-6, FR-NC-6c).
let job = ctl.activity.begin(
crate::activity::Kind::Download,
"Keeping photographs on this device",
);
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(300),
@@ -840,6 +878,7 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
w.set_library_pin_total(0);
job.fail("stopped without finishing");
stop(&ctl_cb.pin_timer);
return;
}
@@ -849,9 +888,11 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
library::PinMessage::Planned { total } => {
w.set_library_pin_total(total as i32);
w.set_library_pin_done(0);
job.total(total);
}
library::PinMessage::Stored { done } => {
w.set_library_pin_done(done as i32);
job.progress(done, w.get_library_pin_total() as usize);
// The "On this device" count grows as they land, so
// the chip agrees with the progress line beside it.
refresh_local_count(&w, &ctl_cb);
@@ -861,6 +902,10 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
"pin complete: {stored} original(s), {:.1} MB",
bytes as f64 / 1_048_576.0
);
job.finish(format!(
"{stored} photograph(s) · {}",
crate::activity::describe_bytes(bytes)
));
w.set_library_pin_total(0);
w.set_library_pin_done(0);
refresh_local_count(&w, &ctl_cb);
@@ -869,6 +914,7 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
}
library::PinMessage::Failed { message, offline } => {
log::warn!("pin fetch stopped: {message}");
job.fail(message.clone());
w.set_library_pin_total(0);
if offline {
ctl_cb
@@ -1410,12 +1456,21 @@ fn start_sidecar_writes(
return;
};
let count = writes.len();
let rx = library::spawn_sidecar_writes(creds, session.user_id.clone(), writes);
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
// The writer reports once at the end, so there is no per-file progress to
// show — but a cull that has just rated forty frames has forty uploads in
// flight, and "is that saved yet" deserves an answer somewhere.
let job = ctl.activity.begin(
crate::activity::Kind::Upload,
format!("Saving {count} judgement(s)"),
);
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(200),
@@ -1435,6 +1490,10 @@ fn start_sidecar_writes(
"{failed} sidecar write(s) failed: {}",
last_error.clone().unwrap_or_default()
);
job.fail(format!(
"{written} saved · {failed} failed: {}",
last_error.clone().unwrap_or_default()
));
// Said plainly, because the consequence is specific:
// the rating is safe in the catalog but will not
// survive deleting it.
@@ -1447,6 +1506,9 @@ fn start_sidecar_writes(
);
} else {
log::debug!("{written} sidecar(s) written");
// Quietly: a cull produces one of these every few
// seconds and none of them is news.
job.finish_quietly();
}
stop(&ctl_cb.sidecar_timer);
}
@@ -1525,11 +1587,7 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
return;
}
// Reset the counter to this batch, so the bar measures the work actually
// outstanding rather than accumulating across batches.
window.set_library_thumbs_total(wanted.len() as i32);
window.set_library_thumbs_done(0);
let requested = wanted.len();
let rx = library::spawn_thumbnails(
creds,
session.user_id.clone(),
@@ -1537,7 +1595,7 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
library::thumbs_dir(&session.server, &session.user_id),
library::catalog_path(&session.server, &session.user_id),
);
drain_thumbnails(window.as_weak(), ctl.clone(), rx);
drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested);
}
/// Apply thumbnails to the model as they arrive.
@@ -1545,9 +1603,19 @@ fn drain_thumbnails(
weak: slint::Weak<AppWindow>,
ctl: Rc<LibraryController>,
rx: Receiver<ThumbnailMessage>,
requested: usize,
) {
let timer = slint::Timer::default();
let ctl_cb = ctl.clone();
// One row per batch, measured against the cells this window asked for.
// Starting a new batch does not extend the last one: a scroll abandons
// whatever the previous window wanted, and a denominator carried across
// both would describe neither.
let job = ctl
.activity
.begin(crate::activity::Kind::Thumbnails, "Loading thumbnails");
job.total(requested);
// Which window this batch was requested for. Captured at spawn, compared on
// every tick.
let mine = ctl.generation.get();
@@ -1589,7 +1657,11 @@ fn drain_thumbnails(
// The worker finished or died. Either way nothing more
// is coming, so the bar must not sit part-filled
// forever.
w.set_library_thumbs_done(w.get_library_thumbs_total());
//
// Quietly: a scroll starts one of these every second,
// and a history of them would bury anything worth
// reading.
job.finish_quietly();
stop(&ctl_cb.thumb_timer);
return;
}
@@ -1606,7 +1678,7 @@ fn drain_thumbnails(
// Date reads produce no cell, so they are counted into
// the bar's denominator or it finishes while work is
// still running.
w.set_library_thumbs_total(w.get_library_thumbs_total() + dating as i32);
job.add_total(dating);
let mut parts = Vec::new();
if cached > 0 {
@@ -1619,12 +1691,14 @@ fn drain_thumbnails(
parts.push(format!("reading {dating} dates"));
}
if !parts.is_empty() {
w.set_library_status(parts.join(" · ").into());
let status = parts.join(" · ");
job.detail(status.clone());
w.set_library_status(status.into());
}
}
// A header-only date read. Advances the bar; draws nothing.
ThumbnailMessage::DateProgress => {
w.set_library_thumbs_done(w.get_library_thumbs_done() + 1);
job.advance();
}
// Dates landed, so the histogram can now be built. This is
// what makes the timeline appear on a library whose
@@ -1640,7 +1714,7 @@ fn drain_thumbnails(
// successes would stall it on a library where some files
// carry no embedded preview.
ThumbnailMessage::Ready(t) => {
w.set_library_thumbs_done(w.get_library_thumbs_done() + 1);
job.advance();
// Bytes arrived *from the server*, so it is reachable.
// This is what clears the banner when a connection
// returns while the user is simply scrolling, without
@@ -1672,7 +1746,7 @@ fn drain_thumbnails(
}
}
ThumbnailMessage::Unavailable { row, reason } => {
w.set_library_thumbs_done(w.get_library_thumbs_done() + 1);
job.advance();
log::debug!("thumbnail {row}: {reason}");
if let Some(mut r) = model.row_data(row) {
r.unavailable = true;
@@ -1682,18 +1756,16 @@ fn drain_thumbnails(
// TRACES: FR-CAT-9
ThumbnailMessage::Offline { reason } => {
log::info!("thumbnails stopped: {reason}");
// The batch is over, so the bar must not be left
// showing a partial fetch that will never finish — it
// would sweep for ever.
job.fail(reason.clone());
ctl_cb
.reachability
.borrow_mut()
.mark_unreachable(reason, std::time::Instant::now());
refresh_offline(&w, &ctl_cb);
// The batch is over, so the progress counter must not
// be left showing a partial fetch that will never
// finish — it would spin in the header for ever.
w.set_library_thumbs_total(0);
w.set_library_thumbs_done(0);
// Cells left without pixels stay placeholders rather
// than being marked unavailable: the images are fine,
// and a reconnect should fill them in. Marking them
@@ -1784,6 +1856,12 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
let weak = window.as_weak();
let ctl_cb = ctl.clone();
// The sync reports stages rather than counts, so it stays indeterminate and
// says what stage it is in.
let job = ctl
.activity
.begin(crate::activity::Kind::Sync, "Syncing with the server");
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(300),
@@ -1795,6 +1873,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
w.set_library_syncing(false);
job.fail("stopped without finishing");
stop(&ctl_cb.sync_timer);
return;
}
@@ -1802,6 +1881,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
match msg {
crate::derived_sync::SyncMessage::Status(s) => {
job.detail(s.clone());
w.set_library_status(s.into());
}
crate::derived_sync::SyncMessage::Finished(report) => {
@@ -1823,14 +1903,17 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
}
);
w.set_library_syncing(false);
let summary = format!(
"{} shard(s) up, {} down",
report.shards_uploaded, report.shards_downloaded
);
job.finish(if report.did_anything() {
summary.clone()
} else {
"nothing to exchange".to_string()
});
if report.did_anything() {
w.set_library_status(
format!(
"synced · {} shard(s) up, {} down",
report.shards_uploaded, report.shards_downloaded
)
.into(),
);
w.set_library_status(format!("synced · {summary}").into());
}
// Adopted thumbnails and merged collections both change
// what the grid should show.
@@ -1842,6 +1925,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
}
crate::derived_sync::SyncMessage::Failed(e) => {
log::warn!("sync failed: {e}");
job.fail(e.to_string());
w.set_library_syncing(false);
// Not an error banner: a failed sync costs nothing —
// everything is still local and the next pass retries.
@@ -1888,6 +1972,13 @@ fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
let weak = window.as_weak();
let ctl_cb = ctl.clone();
// Hours on a large library, and entirely invisible outside the grid until
// now: the register is where a user who has gone to develop can still see
// that indexing is running and how far it has got.
let job = ctl
.activity
.begin(crate::activity::Kind::Index, "Indexing capture times");
timer.start(
slint::TimerMode::Repeated,
// Slower than the thumbnail drain: this runs for tens of minutes and
@@ -1902,6 +1993,7 @@ fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
w.set_library_sweep_total(0);
job.fail("stopped without finishing");
stop(&ctl_cb.sweep_timer);
return;
}
@@ -1911,9 +2003,11 @@ fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
library::SweepMessage::Total(n) => {
w.set_library_sweep_total(n as i32);
w.set_library_sweep_done(0);
job.total(n);
}
library::SweepMessage::Progress { done, dated } => {
w.set_library_sweep_done(done as i32);
job.progress(done, w.get_library_sweep_total() as usize);
// Rebuild as it goes: the histogram growing while the
// sweep runs is the visible sign it is working.
if dated > 0 {
@@ -1925,6 +2019,7 @@ fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
}
library::SweepMessage::Finished { dated } => {
log::info!("sweep finished: {dated} dated");
job.finish(format!("{dated} dated"));
w.set_library_sweep_total(0);
{
let borrow = ctl_cb.catalog.borrow();
@@ -3261,7 +3356,7 @@ mod tests {
/// the *current* batch's timer and leaves the new fetches undrained.
#[test]
fn a_reload_makes_an_in_flight_thumbnail_batch_stale() {
let ctl = LibraryController::new();
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
// What `drain_thumbnails` captures when the batch is spawned.
let mine = ctl.generation.get();
@@ -3280,7 +3375,7 @@ mod tests {
/// Each load is distinct, so two reloads cannot alias back to a live batch.
#[test]
fn every_window_load_takes_a_fresh_generation() {
let ctl = LibraryController::new();
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
let seen: Vec<u64> = (0..4)
.map(|_| {
let g = ctl.generation.get();
+5 -5
View File
@@ -585,19 +585,19 @@ export component GeometryPanel inherits Rectangle {
clicked => { root.crop-toggled(!root.crop-mode); }
}
// Rotation and flips. Glyphs rather than labels: four controls
// Rotation and flips. Icons rather than labels: four controls
// named in words would wrap the 280px column, and each of
// these shows its own result.
HorizontalLayout {
spacing: Theme.gap-sm;
IconButton {
glyph: "⟲";
icon: "rotate-ccw";
enabled: root.enabled;
clicked => { root.rotate(-1); }
}
IconButton {
glyph: "⟳";
icon: "rotate-cw";
enabled: root.enabled;
clicked => { root.rotate(1); }
}
@@ -605,13 +605,13 @@ export component GeometryPanel inherits Rectangle {
Rectangle { horizontal-stretch: 1; }
IconButton {
glyph: "⇔";
icon: "flip-h";
active: root.flip-h;
enabled: root.enabled;
clicked => { root.flip-h-toggled(); }
}
IconButton {
glyph: "⇕";
icon: "flip-v";
active: root.flip-v;
enabled: root.enabled;
clicked => { root.flip-v-toggled(); }
+46 -6
View File
@@ -2,11 +2,11 @@ import { Theme } from "theme.slint";
import { AdjustPanel, GeometryPanel, ParamRow } from "adjust.slint";
import { LaunchScreen } from "launch.slint";
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState } from "widgets.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
import { CollectionsPanel, CollectionRow } from "collections.slint";
import { SettingsPage } from "settings.slint";
export { LibraryCell, TimelineBar, CollectionRow }
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow }
// Status strip — surfaces the GPU backend and adapter, which matters during
// v0.1 because assumption A1 is exactly "does this compositing path work on
@@ -274,11 +274,30 @@ export component AppWindow inherits Window {
in property <bool> show-library: false;
in-out property <[LibraryCell]> library-cells;
in property <int> library-total: 0;
// --- background activity (FR-CAT-1, FR-NC-6c) ---
//
// Every background job in one register, filled by `activity.rs`. The
// aggregate drives the bar across the top of the shell; the rows are the
// list on the settings page, where a stalled transfer can be identified
// rather than merely felt.
in property <[ActivityRow]> activity-rows;
/// Anything at all running. Drives the bar's visibility, so it is a
/// property rather than `activity-rows.length > 0` — finished rows stay in
/// the list for a while and must not keep the bar alight.
in property <bool> activity-busy: false;
/// Whether the running jobs, taken together, have a denominator. A scan
/// alone does not; a scan beside a download does.
in property <bool> activity-determinate: false;
in property <float> activity-fraction: 0;
in property <int> activity-running: 0;
/// Jobs that stopped and are being kept: finished ones as a short history,
/// failed ones until they are cleared.
in property <int> activity-kept: 0;
callback activity-clear-finished();
in property <bool> library-scanning: false;
in property <string> library-status: "";
in property <string> library-error: "";
in property <int> library-thumbs-done: 0;
in property <int> library-thumbs-total: 0;
// --- offline mode (FR-CAT-9) ---
//
@@ -674,6 +693,11 @@ export component AppWindow inherits Window {
strip-location-toggled(on) => { root.settings-strip-location-toggled(on); }
destination-changed(t) => { root.settings-destination-changed(t); }
activity-rows: root.activity-rows;
activity-running: root.activity-running;
activity-kept: root.activity-kept;
clear-finished() => { root.activity-clear-finished(); }
close() => { root.settings-close(); }
reset-defaults() => { root.settings-reset(); }
}
@@ -773,8 +797,6 @@ export component AppWindow inherits Window {
scanning: root.library-scanning;
scan-status: root.library-status;
scan-error: root.library-error;
thumbs-done: root.library-thumbs-done;
thumbs-total: root.library-thumbs-total;
offline: root.library-offline;
offline-reason: root.library-offline-reason;
@@ -1383,5 +1405,23 @@ export component AppWindow inherits Window {
}
}
}
// --- the load bar -------------------------------------------------
//
// Last in the file, so it is last in z-order and no view can cover it.
//
// At the top of the *shell* rather than inside a view, because that is
// the only place that is true of every view: a pin download outlives
// the grid it was started from, and until this existed it drew nothing
// at all once the user opened an image. Three pixels, no text and no
// hit area — it says only "something is running", and the settings
// page says what (FR-CAT-1, FR-NC-6c).
if root.activity-busy: ProgressBar {
x: 0;
y: 0;
width: 100%;
indeterminate: !root.activity-determinate;
fraction: root.activity-fraction;
}
}
}
+24 -24
View File
@@ -32,7 +32,7 @@
// release rather than the drop being silently discarded after it.
import { Theme } from "theme.slint";
import { Button } from "widgets.slint";
import { Button, Icon } from "widgets.slint";
// One row of the collection tree.
export struct CollectionRow {
@@ -130,12 +130,12 @@ component TreeRow inherits Rectangle {
Rectangle {
width: 14px;
if root.entry.has-children: Text {
text: root.entry.expanded ? "▾" : "▸";
color: Theme.ink-faint;
font-size: Theme.text-sm;
horizontal-alignment: center;
vertical-alignment: center;
if root.entry.has-children: Icon {
name: root.entry.expanded ? "chevron-down" : "chevron-right";
ink: Theme.ink-faint;
size: 10px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
// Its own hit area: toggling open must not also select, or every
@@ -148,11 +148,11 @@ component TreeRow inherits Rectangle {
// Smart collections read differently from manual ones — the icon is
// the only cue that its contents are computed, and that dropping
// images on it will be refused.
Text {
text: root.entry.smart ? "◈" : "▤";
color: root.entry.smart ? Theme.active-dim : Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
Icon {
name: root.entry.smart ? "collection-smart" : "collection";
ink: root.entry.smart ? Theme.active-dim : Theme.ink-faint;
size: 12px;
y: (parent.height - self.height) / 2;
}
// The name, or the field that is replacing it while this row is being
@@ -337,7 +337,7 @@ export component CollectionsPanel inherits Rectangle {
horizontal-stretch: 1;
}
// New collection. A glyph rather than a word: the header is 232px
// New collection. A mark rather than a word: the header is 232px
// wide and the label would crowd out the title.
Rectangle {
width: 22px;
@@ -347,12 +347,12 @@ export component CollectionsPanel inherits Rectangle {
: (add-touch.has-hover ? Theme.hover : transparent);
border-radius: Theme.radius-sm;
Text {
text: "+";
color: Theme.ink-dim;
font-size: Theme.text-lg;
horizontal-alignment: center;
vertical-alignment: center;
Icon {
name: "plus";
ink: Theme.ink-dim;
size: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
add-touch := TouchArea {
clicked => { root.new-collection(); }
@@ -494,11 +494,11 @@ export component CollectionsPanel inherits Rectangle {
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Text {
text: "🗑";
color: root.trash-count > 0 ? Theme.warn-ink : Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
Icon {
name: "trash";
ink: root.trash-count > 0 ? Theme.warn-ink : Theme.ink-faint;
size: 12px;
y: (parent.height - self.height) / 2;
}
Text {
+256
View File
@@ -0,0 +1,256 @@
// The icon set, drawn rather than typed.
//
// **Why this file exists.** Every glyph in this UI used to be a character in a
// Text: `⟲` for rotate-left, `☰` for the sidebar, `★` for a rating, `✓` for a
// tick. That works on a desktop, where the font stack ends in something with
// full symbol coverage, and it fails on Android, where it does not: the system
// font is Roboto, fallback there is resolved per *script* rather than per
// character, and none of the fallback families claimed for the Common script
// carry the Dingbats, Arrows or Miscellaneous Symbols blocks these characters
// live in. The result is that the toolbar renders as a row of tofu — a box
// with an X — and the controls become unusable.
//
// No amount of picking "safer" characters fixes that class of bug, because the
// question "is this codepoint in the font the platform happens to pick" has a
// different answer on every device. So the icons stop being text. Each one is
// an SVG path drawn into a 24×24 view box and rendered by `Path`, which goes
// through the same renderer as every other shape on screen and cannot fall
// back to anything.
//
// **The 24×24 view box is the contract.** `Path` with an explicit view box
// fits *the box* to the element, not the ink inside it, so two icons drawn to
// the same box occupy the same optical size even when their drawings differ in
// extent — a star that reaches the edges and a chevron that does not still
// look like siblings. Icons are therefore always drawn square; a caller that
// wants a different aspect should lay out around the icon, not stretch it.
//
// **Stroke width is in screen pixels, not view-box units.** Slint applies the
// stroke after fitting, so a fixed `stroke-width` would thicken as the icon
// shrank. `weight` scales it with `size` instead, which keeps a 10px tick and
// a 16px sidebar bar at the same apparent weight. It is also why the filled
// paths carry a stroke-width with a transparent stroke: the fit shrinks the
// drawing by the stroke width, so a filled star with no stroke width would
// come out fractionally *larger* than the outlined one beside it, and the two
// would jump as a rating changed.
// One drawing. Everything an icon path shares lives here so the table below is
// nothing but geometry; `fill`, `stroke` and `stroke-width` come from the call
// site because they depend on the enclosing `Icon`'s properties.
component Ink inherits Path {
viewbox-width: 24;
viewbox-height: 24;
width: 100%;
height: 100%;
// Round joins throughout: at 12px a mitred corner on a 1.4px stroke is a
// single dark pixel that reads as dirt on the screen.
stroke-line-cap: round;
stroke-line-join: round;
}
// A single icon, named.
//
// Names are strings rather than an enum because Slint enums cannot be extended
// from another file and the set is consumed across six screens; a typo shows
// up as a blank box, which is loud enough in review. The vocabulary is:
//
// check cross menu trash plus
// star star-outline
// chevron-right chevron-down chevron-left arrow-up
// rotate-ccw rotate-cw flip-h flip-v
// collection collection-smart
export component Icon inherits Rectangle {
in property <string> name;
/// The single colour the whole drawing takes. Named `ink` rather than
/// `color` because Rectangle already has a legacy `color` alias for its
/// background and Slint refuses to shadow it. Icons are monochrome by
/// design — they sit in chrome that is already monochrome, and a two-tone
/// icon would be the only thing on screen carrying colour for decoration.
in property <brush> ink;
/// Edge length. Icons are square; see the view-box note above.
in property <length> size: 16px;
/// Stroke thickness, derived rather than set. The floor keeps the hairline
/// from disappearing entirely on a low-DPI desktop at the smallest sizes.
out property <length> weight: max(1.2px, root.size / 11);
width: root.size;
height: root.size;
horizontal-stretch: 0;
vertical-stretch: 0;
// --- marks -----------------------------------------------------------
if root.name == "check": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 4.2 12.6 L 9.6 18 L 19.8 6.4";
}
if root.name == "cross": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 6.2 6.2 L 17.8 17.8 M 17.8 6.2 L 6.2 17.8";
}
if root.name == "plus": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 12 4.5 L 12 19.5 M 4.5 12 L 19.5 12";
}
// --- rating ----------------------------------------------------------
//
// Solid and outline are the same ten points, so a filled star and an empty
// one sit at exactly the same size and position and the strip does not
// shimmer as a rating is dragged across it.
if root.name == "star": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 12 3.1 L 14.32 9.21 L 20.85 9.53 L 15.76 13.63 "
+ "L 17.46 19.94 L 12 16.35 L 6.54 19.94 L 8.24 13.63 "
+ "L 3.15 9.53 L 9.68 9.21 Z";
}
if root.name == "star-outline": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 12 3.1 L 14.32 9.21 L 20.85 9.53 L 15.76 13.63 "
+ "L 17.46 19.94 L 12 16.35 L 6.54 19.94 L 8.24 13.63 "
+ "L 3.15 9.53 L 9.68 9.21 Z";
}
// --- chrome ----------------------------------------------------------
if root.name == "menu": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 3.5 6.5 L 20.5 6.5 M 3.5 12 L 20.5 12 "
+ "M 3.5 17.5 L 20.5 17.5";
}
if root.name == "trash": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 9.2 6.2 L 9.2 3.8 L 14.8 3.8 L 14.8 6.2 "
+ "M 3.4 6.2 L 20.6 6.2 "
+ "M 5.6 6.2 L 6.7 20.2 L 17.3 20.2 L 18.4 6.2 "
+ "M 10.2 9.8 L 10.6 16.8 M 13.8 9.8 L 13.4 16.8";
}
// Disclosure arrows are solid triangles rather than open chevrons: they
// are the smallest thing drawn in this UI, and at 11px a stroked chevron
// is two anti-aliased diagonals with nothing left of the shape between.
if root.name == "chevron-right": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 9 5.5 L 16.5 12 L 9 18.5 Z";
}
if root.name == "chevron-left": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 15 5.5 L 7.5 12 L 15 18.5 Z";
}
if root.name == "chevron-down": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 5.5 9 L 18.5 9 L 12 16.5 Z";
}
if root.name == "arrow-up": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 12 20 L 12 5 M 5 12 L 12 5 L 19 12";
}
// --- framing ---------------------------------------------------------
//
// The rotation arrows are a 280° arc with a solid head at the end of
// travel. The gap sits at the top, where the eye looks first, so the two
// are told apart by which side the head is on rather than by following a
// curve — at 16px an arrowhead small enough to sit flush with a thin arc
// is a smudge, and both directions look like a plain circle.
if root.name == "rotate-ccw": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 10.82 6.5 A 6.8 6.8 0 1 0 18.39 10.87";
}
if root.name == "rotate-ccw": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 13.1 6.49 L 21.12 9.88 L 15.66 11.87 Z";
}
if root.name == "rotate-cw": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 13.18 6.5 A 6.8 6.8 0 1 1 5.61 10.87";
}
if root.name == "rotate-cw": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 10.9 6.49 L 2.88 9.88 L 8.34 11.87 Z";
}
// A flip is a mirror about an axis, so the icon is literally that: two
// arrowheads facing away from the line they would be reflected across.
if root.name == "flip-h": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 8.2 5.2 L 1.8 12 L 8.2 18.8 Z "
+ "M 15.8 5.2 L 22.2 12 L 15.8 18.8 Z";
}
if root.name == "flip-h": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 12 2.6 L 12 21.4";
}
if root.name == "flip-v": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 5.2 8.2 L 12 1.8 L 18.8 8.2 Z "
+ "M 5.2 15.8 L 12 22.2 L 18.8 15.8 Z";
}
if root.name == "flip-v": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 2.6 12 L 21.4 12";
}
// --- collections -----------------------------------------------------
//
// A manual collection is a list of rows someone put there; a smart one is
// a rule. The shapes are deliberately unalike rather than one shape with a
// badge, because the pair appears down a narrow sidebar at 12px where a
// badge would be three pixels.
//
// Both are drawn thinner than they want to be for the same reason. At 12px
// the interior of a shape is four or five pixels across, so every internal
// line spends one of them and its two neighbours to anti-aliasing: the
// three-rule box this started as, and the diamond with a fat centre, both
// filled in to a solid blob. One rule and a small centre survive.
if root.name == "collection": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 3.4 5.4 L 20.6 5.4 L 20.6 18.6 L 3.4 18.6 Z "
+ "M 3.4 10.2 L 20.6 10.2";
}
if root.name == "collection-smart": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 12 2.8 L 21.2 12 L 12 21.2 L 2.8 12 Z";
}
if root.name == "collection-smart": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 12 9 L 15 12 L 12 15 L 9 12 Z";
}
}
+9 -11
View File
@@ -1,5 +1,5 @@
import { Theme } from "theme.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, Field, Disclosure } from "widgets.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, Field, Disclosure, Icon } from "widgets.slint";
// Launch screen: connect an account, or resume a saved one.
//
@@ -41,18 +41,16 @@ component FormatCheck inherits Rectangle {
border-color: root.checked ? Theme.active : Theme.rule;
background: root.checked ? Theme.active : transparent;
Text {
text: "✓";
Icon {
name: "check";
// Dark on the fill: `active` is near-white, and the white
// tick this carried against a saturated accent is invisible
// against it.
color: Theme.ground;
font-size: 12px;
ink: Theme.ground;
size: 11px;
visible: root.checked;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
@@ -89,7 +87,7 @@ component FolderRow inherits Rectangle {
padding-right: Theme.gap;
spacing: Theme.gap;
Disclosure { text: root.is-parent ? "↑" : "▸"; }
Disclosure { icon: root.is-parent ? "arrow-up" : "chevron-right"; }
Value { text: root.label; overflow: elide; }
}
}
@@ -261,7 +259,7 @@ export component LaunchScreen inherits Rectangle {
}
Caption {
text: "Create one in Nextcloud under Settings → Security → Devices & sessions. It is device-scoped and can be revoked on its own.";
text: "Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own.";
wrap: word-wrap;
}
}
+57 -105
View File
@@ -9,7 +9,7 @@
// must not look identical (FR-NC-6c).
import { Theme } from "theme.slint";
import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip } from "widgets.slint";
import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon } from "widgets.slint";
// One bar of the capture-time histogram.
export struct TimelineBar {
@@ -259,52 +259,11 @@ export struct LibraryCell {
flag: int,
}
// A horizontal progress bar with two modes.
//
// Determinate where a real denominator exists (thumbnails: we know how many
// cells we asked for). Indeterminate where one does not — a directory walk
// discovers its own extent, so any percentage would be invented, and inventing
// one is worse than admitting the work is unbounded.
component ProgressBar inherits Rectangle {
in property <float> fraction: 0;
in property <bool> indeterminate: false;
height: 3px;
background: Theme.rule;
// Determinate: a bar proportional to real progress.
Rectangle {
x: 0;
width: parent.width * clamp(root.fraction, 0, 1);
height: parent.height;
background: Theme.active-dim;
visible: !root.indeterminate;
}
// Indeterminate: a sweep that says "working" without claiming a position.
Rectangle {
width: parent.width * 25%;
height: parent.height;
background: Theme.active-dim;
visible: root.indeterminate;
x: root.indeterminate ? -self.width : 0;
animate x {
duration: 1200ms;
iteration-count: -1;
easing: ease-in-out;
}
states [
running when root.indeterminate: { x: parent.width; }
]
}
}
// A row of five stars, readable at a glance and clickable to set a rating.
//
// **Filled versus empty carries the meaning, not colour.** NFR-A11Y-3 forbids
// status by hue alone, and the palette is achromatic anyway — so a set star is
// a solid glyph at `active` and an unset one is an outline at `ink-faint`. The
// a solid star at `active` and an unset one is an outline at `ink-faint`. The
// two differ in both shape and luminance, which survives greyscale and low
// vision alike.
//
@@ -343,7 +302,7 @@ export component StarStrip inherits Rectangle {
// Separation between the trash target and ★1.
//
// **This gap is load-bearing.** The star targets are 44px over a 22px
// glyph, so they deliberately overlap and a near-miss lands one star out —
// cell, so they deliberately overlap and a near-miss lands one star out —
// harmless, same control, corrected by clicking again. That reasoning does
// not survive a neighbour that *moves a file*, so trash is held off the
// scale by a gap wider than the overhang it would otherwise share with
@@ -373,18 +332,16 @@ export component StarStrip inherits Rectangle {
height: root.star;
visible: root.can-trash;
Text {
text: "🗑";
Icon {
name: "trash";
// Reject and trash are the two destructive ends of this UI and
// share the palette's one hue, so the gesture reads the same
// in both places (NFR-A11Y-3: the glyph carries it, not the
// in both places (NFR-A11Y-3: the shape carries it, not the
// colour).
color: Theme.warn-ink;
font-size: 13px;
width: 100%;
height: 100%;
horizontal-alignment: center;
vertical-alignment: center;
ink: Theme.warn-ink;
size: 13px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
TouchArea {
@@ -411,18 +368,16 @@ export component StarStrip inherits Rectangle {
width: root.star;
height: root.star;
Text {
Icon {
// Solid versus outline: the shape says it, not the colour.
text: root.rating >= n ? "★" : "☆";
color: root.rating >= n ? Theme.active : Theme.ink-dim;
// Large enough to hit the difference between ★ and ☆ at arm's
// length on a tablet; the glyphs differ in fill, which needs
// more pixels to read than a difference in shape would.
font-size: 15px;
width: 100%;
height: 100%;
horizontal-alignment: center;
vertical-alignment: center;
name: root.rating >= n ? "star" : "star-outline";
ink: root.rating >= n ? Theme.active : Theme.ink-dim;
// Large enough to hit the difference between filled and empty
// at arm's length on a tablet; the two differ in fill, which
// needs more pixels to read than a difference in shape would.
size: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
// Grown past the drawn star to meet FR-UI-3's 44pt minimum, and
@@ -453,10 +408,11 @@ export component StarStrip inherits Rectangle {
// The pick/reject mark.
//
// A glyph rather than a colour, for the same NFR-A11Y-3 reason as the stars:
// ✓ and ✗ are distinguishable without hue, and a reject reads as a reject in
// greyscale. A reject also dims its whole cell, which is the cue that carries
// at grid scale — the glyph is confirmation, not the primary signal.
// A shape rather than a colour, for the same NFR-A11Y-3 reason as the stars:
// a tick and a cross are distinguishable without hue, and a reject reads as a
// reject in greyscale. A reject also dims its whole cell, which is the cue
// that carries at grid scale — the mark is confirmation, not the primary
// signal.
export component FlagMark inherits Rectangle {
in property <int> flag: 0;
@@ -467,17 +423,14 @@ export component FlagMark inherits Rectangle {
background: Theme.surface;
opacity: 0.92;
Text {
text: root.flag == 2 ? "✗" : "✓";
Icon {
name: root.flag == 2 ? "cross" : "check";
// Reject earns the one hue in the palette: it is the destructive end
// of the axis and the thing a user must not mistake for a pick.
color: root.flag == 2 ? Theme.warn-ink : Theme.active;
font-size: 10px;
font-weight: 700;
width: 100%;
height: 100%;
horizontal-alignment: center;
vertical-alignment: center;
ink: root.flag == 2 ? Theme.warn-ink : Theme.active;
size: 9px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
@@ -535,10 +488,9 @@ export component LibraryGrid inherits Rectangle {
/// column-count change. Rust resizes the loaded window to match.
callback capacity-changed(int);
// Thumbnail progress. Unlike the scan, this has a real denominator — the
// number of cells we asked for — so the bar can be honest about position.
in property <int> thumbs-done: 0;
in property <int> thumbs-total: 0;
// Thumbnail progress is no longer reported here: the batch belongs to a
// window of the grid rather than to anything the user asked for, and the
// shell's bar now carries it along with every other running job.
// Whole-library indexing, which runs for far longer than one window's
// thumbnails and is reported separately so the two do not fight over the
@@ -549,9 +501,6 @@ export component LibraryGrid inherits Rectangle {
in property <bool> syncing: false;
property <bool> sweeping: root.sweep-total > 0 && root.sweep-done < root.sweep-total;
property <bool> thumbs-running: root.thumbs-total > 0
&& root.thumbs-done < root.thumbs-total;
callback cell-clicked(int);
/// A star was clicked on a cell: row, and the rating 0..5.
callback cell-rated(int, int);
@@ -743,7 +692,7 @@ export component LibraryGrid inherits Rectangle {
// interface with a collapsible sidebar puts one, and the only
// place that stays put whichever way the panel is.
IconButton {
glyph: "☰";
icon: "menu";
active: root.collections-visible;
y: (parent.height - self.height) / 2;
clicked => { root.toggle-collections(); }
@@ -825,7 +774,10 @@ export component LibraryGrid inherits Rectangle {
// much more expensive request, and a button that meant either
// depending on invisible state would be a trap.
if root.scope-label != "": Button {
text: root.scope-pinned ? "Pinned ✓" : "Pin offline";
// No tick on the pinned label: `active` already inverts
// the button, which says the same thing without a symbol
// inside a string (see icons.slint).
text: root.scope-pinned ? "Pinned" : "Pin offline";
active: root.scope-pinned;
enabled: !root.scanning;
y: (parent.height - self.height) / 2;
@@ -908,7 +860,8 @@ export component LibraryGrid inherits Rectangle {
}
for n[i] in [1, 2, 3, 4, 5]: FilterChip {
label: "★" + n + "+";
icon: "star";
label: n + "+";
count: root.rating-counts.length > n ? root.rating-counts[n] : -1;
active: root.filter-min-rating == n;
y: (parent.height - self.height) / 2;
@@ -923,7 +876,8 @@ export component LibraryGrid inherits Rectangle {
Rectangle { width: Theme.gap; }
FilterChip {
label: "✓ Picks";
icon: "check";
label: "Picks";
active: root.filter-flag == 1;
y: (parent.height - self.height) / 2;
clicked => {
@@ -932,7 +886,8 @@ export component LibraryGrid inherits Rectangle {
}
FilterChip {
label: "✗ Rejects";
icon: "cross";
label: "Rejects";
active: root.filter-flag == 2;
y: (parent.height - self.height) / 2;
clicked => {
@@ -978,23 +933,20 @@ export component LibraryGrid inherits Rectangle {
// --- progress -----------------------------------------------------
//
// Indeterminate during the scan: a directory walk cannot know its own
// extent, so a percentage would be fiction. Determinate for
// thumbnails, where the denominator is the cells we requested.
if root.scanning || root.thumbs-running || root.sweeping: ProgressBar {
// Only the sweep has a denominator worth showing. A scan cannot
// know its extent, and the window's own fetches are sized by the
// viewport rather than by anything meaningful to the user.
indeterminate: root.scanning || (!root.sweeping && root.thumbs-running);
fraction: root.sweep-total > 0
? root.sweep-done / root.sweep-total
: 0;
}
// The bar that used to sit here is now the shell's, drawn across the
// top of every view from the activity register (see `activity.rs`).
// Two reasons it moved. It only ever knew about the three things the
// grid happens to report — a download running while the user was in
// develop drew nothing anywhere — and a second bar here would now say
// the same thing twice, one line apart.
//
// The grid keeps its *words*: "indexing 4000 / 17000" in the header
// above says which work is running, which a bar cannot.
// --- pinning ------------------------------------------------------
//
// Its own line rather than the shared progress bar above: that one is
// driven by the scan and the sweep, and a pin runs alongside both.
// Its own line, with the count spelled out: the shell's bar says that
// something is transferring, and this says how much of what.
if root.pin-total > 0: Rectangle {
height: 34px;
background: Theme.surface;
@@ -1464,8 +1416,8 @@ export component LibraryGrid inherits Rectangle {
// hidden: the cull is reversible, and a photo
// that vanished on one keypress would make the
// gesture frightening to use. Dimming is the
// cue that reads at grid scale — the ✗ glyph
// confirms it up close.
// cue that reads at grid scale — the cross on
// the mark confirms it up close.
opacity: cell.lifted ? 0.25
: (cell.flag == 2 ? 0.4 : 1.0);
animate opacity { duration: 120ms; }
+122 -9
View File
@@ -1,5 +1,5 @@
import { Theme } from "theme.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, Field } from "widgets.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, Field, ProgressBar, ActivityRow, Icon } from "widgets.slint";
// Settings: how much disk the app may spend, and what an export defaults to.
//
@@ -146,15 +146,13 @@ component Switch inherits Rectangle {
border-color: root.checked ? Theme.active : Theme.rule;
background: root.checked ? Theme.active : transparent;
Text {
text: "✓";
color: Theme.ground;
font-size: 12px;
Icon {
name: "check";
ink: Theme.ground;
size: 11px;
visible: root.checked;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
@@ -238,7 +236,60 @@ component EntryRow inherits VerticalLayout {
}
}
// One background job: what it is, how far along, and what it last said.
//
// A row rather than a `Value`/`Caption` pair written out at the call site,
// because the list is built by a `for` and every row has to answer the same
// three questions in the same order — otherwise a failure reads as a different
// kind of thing from the job it happened to.
//
// The bar is drawn only while the job runs. A stopped job's fraction is either
// 1 (it finished, and a full bar is noise) or frozen part-way (it failed, and
// a bar sitting at 60% invites the reading "still going").
component ActivityItem inherits VerticalLayout {
in property <ActivityRow> row;
spacing: 4px;
HorizontalLayout {
spacing: Theme.gap;
Label {
text: root.row.title;
body: true;
// Faded once it is over: the list is ordered running-first, and
// this is what makes that ordering visible at a glance rather than
// something the reader has to work out from the text.
opacity: root.row.running ? 1.0 : 0.6;
}
Caption {
text: root.row.detail;
// A failure is the one thing here worth a colour (NFR-A11Y-3 is
// satisfied by the words, which say what failed; the hue only
// finds them faster).
warn: root.row.failed;
horizontal-alignment: right;
horizontal-stretch: 1;
overflow: elide;
}
}
if root.row.running: ProgressBar {
indeterminate: !root.row.determinate;
fraction: root.row.fraction;
}
}
export component SettingsPage inherits Rectangle {
// --- background activity --------------------------------------------
in property <[ActivityRow]> activity-rows;
in property <int> activity-running: 0;
/// Stopped jobs still listed: recent successes, and failures held until
/// they are read.
in property <int> activity-kept: 0;
callback clear-finished();
// --- cache ---------------------------------------------------------
in-out property <string> original-budget;
in property <bool> original-unlimited: false;
@@ -388,6 +439,68 @@ export component SettingsPage inherits Rectangle {
// `min` so a narrow window still uses what it has (FR-UI-1).
property <length> column: min(root.width - 2 * Theme.gap-lg, 680px);
// --- background activity ---------------------------------
//
// First on the page, and the only panel here that is not a
// preference. It earns the position by being the one thing
// that changes while the page is open: a transfer the user
// came here to check on is answered before they have scrolled.
Rectangle {
width: content.column;
height: activity.preferred-height;
activity := Panel {
width: 100%;
HorizontalLayout {
spacing: Theme.gap;
PanelHeading { text: "BACKGROUND ACTIVITY"; }
// The count beside the heading, because the list
// below carries stopped jobs too and "how many are
// running" should not have to be counted by eye.
Caption {
text: root.activity-running > 0
? root.activity-running + " running" : "";
horizontal-alignment: right;
horizontal-stretch: 1;
}
}
Caption {
text: "Scans, transfers and syncs running behind the "
+ "interface. Everything here survives leaving "
+ "this page; nothing here needs it open.";
wrap: word-wrap;
}
Rectangle { height: Theme.gap-sm; }
// Says nothing is running, rather than showing an empty
// box: a list that is empty because the work finished
// and one that is empty because the register is broken
// look identical otherwise.
if root.activity-rows.length == 0: Caption {
text: "Nothing running.";
}
for row in root.activity-rows: ActivityItem {
row: row;
}
if root.activity-kept > 0: Rectangle {
height: Theme.control-height;
Button {
x: 0;
text: "Clear finished";
clicked => { root.clear-finished(); }
}
}
}
}
// --- storage ---------------------------------------------
Rectangle {
width: content.column;
+127 -23
View File
@@ -14,6 +14,9 @@
// colour reached forty call sites with no single place to change it.
import { Theme } from "theme.slint";
import { Icon } from "icons.slint";
export { Icon }
// A text button.
//
@@ -36,7 +39,7 @@ export component Button inherits Rectangle {
/// emphasis stops meaning anything (see the theme preamble).
in property <bool> primary: false;
/// Sustained state — a toggle that is currently on, not a press. The same
/// meaning [`IconButton`] gives it, so a labelled toggle and a glyph
/// meaning [`IconButton`] gives it, so a labelled toggle and an icon
/// toggle read alike.
in property <bool> active: false;
@@ -97,12 +100,13 @@ export component Button inherits Rectangle {
}
}
// A square button carrying a single glyph.
// A square button carrying a single icon.
//
// Square because it has no label to size against: a toolbar affordance whose
// width tracked its glyph would jitter as the glyph changed.
// width tracked its drawing would jitter as the drawing changed.
export component IconButton inherits Rectangle {
in property <string> glyph;
/// A name from the [`Icon`] vocabulary.
in property <string> icon;
in property <bool> enabled: true;
/// Sustained state — a toggle that is currently on, not a press.
in property <bool> active: false;
@@ -131,14 +135,15 @@ export component IconButton inherits Rectangle {
clicked => { root.clicked(); }
}
Text {
text: root.glyph;
color: root.active ? Theme.active : Theme.ink;
font-size: Theme.text;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
Icon {
name: root.icon;
ink: root.active ? Theme.active : Theme.ink;
// Half the button, near enough: the icon carries the whole meaning of
// the control, so it wants more of the square than a glyph set at
// body size used to take, but it must not touch the border.
size: root.height / 2;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
@@ -164,6 +169,11 @@ export component FilterChip inherits Rectangle {
in property <bool> active: false;
/// Images behind this term, or -1 where the count is not known.
in property <int> count: -1;
/// A mark before the label, naming what the term filters on — a star for a
/// rating, a tick for picks. Empty for chips whose word says it already.
/// Drawn rather than prefixed to `label`, because a symbol inside a string
/// is exactly the thing [`Icon`] exists to stop (see icons.slint).
in property <string> icon;
callback clicked();
@@ -195,6 +205,16 @@ export component FilterChip inherits Rectangle {
padding-right: Theme.gap-sm;
spacing: 4px;
if root.icon != "": Icon {
name: root.icon;
// Takes the label's colour, including the inversion on an active
// chip: a mark that stayed light on the near-white fill would be
// the one invisible thing in the row.
ink: root.active ? Theme.ground : Theme.ink;
size: 11px;
y: (parent.height - self.height) / 2;
}
Text {
text: root.label;
// Dark on the active fill, which is near-white — the same
@@ -221,17 +241,26 @@ export component FilterChip inherits Rectangle {
// The arrow beside a row that opens into something: a disclosure triangle on
// a section, an "into this folder" marker in the picker.
//
// **Fixed width, and that is the whole point.** The glyphs differ in advance
// width, so a row that sized to its own arrow would shift its label sideways
// as it opened and closed — the one movement that makes a static list look
// like it is being redrawn. Rotating a single glyph would need a transform on
// a Text; two characters render identically and cost nothing.
export component Disclosure inherits Text {
color: Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
horizontal-alignment: center;
// **Fixed width, and that is the whole point.** The drawings differ in extent,
// so a row that sized to its own arrow would shift its label sideways as it
// opened and closed — the one movement that makes a static list look like it
// is being redrawn. The box stays 14px whatever is in it, and the icon centres
// inside.
export component Disclosure inherits Rectangle {
/// A name from the [`Icon`] vocabulary — `chevron-right` closed,
/// `chevron-down` open, or `arrow-up` for a row that leads back out.
in property <string> icon: "chevron-right";
width: 14px;
horizontal-stretch: 0;
Icon {
name: root.icon;
ink: Theme.ink-faint;
size: 10px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
// A collapsible group with a header that reports whether anything inside has
@@ -305,7 +334,9 @@ export component Section inherits Rectangle {
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Disclosure { text: root.expanded ? "▾" : "▸"; }
Disclosure {
icon: root.expanded ? "chevron-down" : "chevron-right";
}
Text {
text: root.title;
@@ -609,6 +640,79 @@ export component Field inherits Rectangle {
}
}
// A horizontal progress bar with two modes.
//
// Determinate where a real denominator exists (thumbnails: we know how many
// cells we asked for). Indeterminate where one does not — a directory walk
// discovers its own extent, so any percentage would be invented, and inventing
// one is worse than admitting the work is unbounded.
//
// Lives here rather than in library.slint, where it started, because the grid
// is no longer the only view that reports progress: the shell draws one across
// the top of every view and the settings page draws one per running job.
export component ProgressBar inherits Rectangle {
in property <float> fraction: 0;
in property <bool> indeterminate: false;
height: 3px;
background: Theme.rule;
// The sweep below is positioned outside these bounds for half its cycle.
// Without clipping it paints over whatever sits beside the bar, which at
// the top of the shell is the whole window.
clip: true;
// Determinate: a bar proportional to real progress.
Rectangle {
x: 0;
width: parent.width * clamp(root.fraction, 0, 1);
height: parent.height;
background: Theme.active-dim;
visible: !root.indeterminate;
}
// Indeterminate: a sweep that says "working" without claiming a position.
Rectangle {
width: parent.width * 25%;
height: parent.height;
background: Theme.active-dim;
visible: root.indeterminate;
x: root.indeterminate ? -self.width : 0;
animate x {
duration: 1200ms;
iteration-count: -1;
easing: ease-in-out;
}
states [
running when root.indeterminate: { x: parent.width; }
]
}
}
// One background job, as the interface sees it.
//
// Built in Rust by `activity.rs`, which is the only thing that knows a scan
// from a download. Everything here is already a sentence or a number ready to
// draw: the page decides where a row goes, never what it means.
export struct ActivityRow {
// "Scanning Photos", "Keeping 40 photographs offline".
title: string,
// Whatever the job last said about itself — counts, a byte figure, or the
// error where it failed.
detail: string,
// 0..1, meaningless unless `determinate`.
fraction: float,
// Whether this job knows its own extent. A scan does not.
determinate: bool,
running: bool,
// Kept apart from `running`: a finished job and a failed one are both
// stopped, and only one of them is worth the user's attention.
failed: bool,
// Moves bytes over the network, so it is one of the "transfers" the user
// asks about when the connection is slow (FR-NC-6c).
transfer: bool,
}
// What a view says when it has nothing to show.
//
// Not in the S3 brief, but `app.slint` and `library.slint` had the same two