Say a photograph is downloading, and how far, instead of failing
The develop view reported a remote original on its way through the error message, so it read "Could not load image" over "Downloading…". It did so on every step along the roll, including a cached frame that was ready within a tick, so each step flashed the error. Waiting is now its own state. On the step, the grid's thumbnail of the photograph stands in at once. Only when a transfer is really on the wire does it dim under "Not on this device yet", with a line like "Downloading — 12.4 of 38.0 MB" and a progress bar. The bytes come from a new RemoteBackend::get_reporting. The Nextcloud backend overrides it to read the body chunk by chunk; the default reports once at the end. Progress is kept in the in-flight registry by path, because a step usually lands on a frame the prefetcher is already fetching. The catalog's file length stands in when the server sends no Content-Length.
This commit is contained in:
@@ -376,6 +376,33 @@ impl RemoteBackend for NextcloudBackend {
|
|||||||
Ok(body)
|
Ok(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_reporting(
|
||||||
|
&self,
|
||||||
|
id: &RemoteId,
|
||||||
|
progress: &(dyn Fn(u64, Option<u64>) + Send + Sync),
|
||||||
|
) -> Result<Vec<u8>, RemoteError> {
|
||||||
|
let url = self.url_for_id(id)?;
|
||||||
|
let mut resp = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.basic_auth(&self.login, Some(&self.password))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(map_send_error)?;
|
||||||
|
map_status(resp.status(), &url)?;
|
||||||
|
|
||||||
|
// Read chunk by chunk rather than with `bytes()`, which is the same
|
||||||
|
// transfer with nothing to say until it ends.
|
||||||
|
let declared = resp.content_length();
|
||||||
|
let mut body = Vec::with_capacity(declared.unwrap_or(0) as usize);
|
||||||
|
progress(0, declared);
|
||||||
|
while let Some(chunk) = resp.chunk().await.map_err(map_send_error)? {
|
||||||
|
body.extend_from_slice(&chunk);
|
||||||
|
progress(body.len() as u64, declared);
|
||||||
|
}
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
async fn put(
|
async fn put(
|
||||||
&self,
|
&self,
|
||||||
path: &RemotePath,
|
path: &RemotePath,
|
||||||
|
|||||||
@@ -98,6 +98,26 @@ pub trait RemoteBackend: Send + Sync {
|
|||||||
/// either way; [`Capabilities::range_reads`] says whether it was cheap.
|
/// either way; [`Capabilities::range_reads`] says whether it was cheap.
|
||||||
async fn get(&self, id: &RemoteId, range: Option<Range<u64>>) -> Result<Vec<u8>, RemoteError>;
|
async fn get(&self, id: &RemoteId, range: Option<Range<u64>>) -> Result<Vec<u8>, RemoteError>;
|
||||||
|
|
||||||
|
/// Fetch a whole object, saying how much of it has arrived as it arrives.
|
||||||
|
///
|
||||||
|
/// `progress` is called with the bytes received so far and the length the
|
||||||
|
/// server declared, if it declared one. For the one transfer a person
|
||||||
|
/// watches: an original opened in develop is tens of megabytes, and a
|
||||||
|
/// view that can only say "downloading" for that long reads as stuck.
|
||||||
|
///
|
||||||
|
/// The default fetches with [`get`](Self::get) and reports once, at the
|
||||||
|
/// end — right for a backend whose `get` is a local read, where there is
|
||||||
|
/// no wait to report on.
|
||||||
|
async fn get_reporting(
|
||||||
|
&self,
|
||||||
|
id: &RemoteId,
|
||||||
|
progress: &(dyn Fn(u64, Option<u64>) + Send + Sync),
|
||||||
|
) -> Result<Vec<u8>, RemoteError> {
|
||||||
|
let body = self.get(id, None).await?;
|
||||||
|
progress(body.len() as u64, Some(body.len() as u64));
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
/// Upload, optionally guarded by a precondition.
|
/// Upload, optionally guarded by a precondition.
|
||||||
///
|
///
|
||||||
/// Backends handle chunking internally based on body size — chunked
|
/// Backends handle chunking internally based on body size — chunked
|
||||||
|
|||||||
+41
-41
File diff suppressed because one or more lines are too long
@@ -415,6 +415,31 @@ pub fn describe_bytes(bytes: u64) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-NC-6a
|
||||||
|
/// A download someone is watching, as the line under its bar and how full the
|
||||||
|
/// bar is: `received` of `total` bytes, and a fraction in 0..1 — or below
|
||||||
|
/// zero when there is no total, which the bar draws as indeterminate rather
|
||||||
|
/// than as a position it would have to invent.
|
||||||
|
pub fn describe_download(received: u64, total: Option<u64>) -> (String, f32) {
|
||||||
|
match total.filter(|&t| t > 0) {
|
||||||
|
// Nothing yet: the size alone says what the wait is for, where
|
||||||
|
// "0 kB of 38.0 MB" would read as a transfer that has stalled.
|
||||||
|
Some(t) if received == 0 => (format!("Downloading {}", describe_bytes(t)), 0.0),
|
||||||
|
// A file that grew since the scan measured it can overrun the
|
||||||
|
// catalog's length; the bar stops full rather than past its end.
|
||||||
|
Some(t) => (
|
||||||
|
format!(
|
||||||
|
"Downloading — {} of {}",
|
||||||
|
describe_bytes(received),
|
||||||
|
describe_bytes(t.max(received))
|
||||||
|
),
|
||||||
|
(received as f64 / t as f64).min(1.0) as f32,
|
||||||
|
),
|
||||||
|
None if received > 0 => (format!("Downloading — {}", describe_bytes(received)), -1.0),
|
||||||
|
None => ("Downloading…".to_string(), -1.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A running job, held by whatever is reporting on it.
|
/// 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
|
/// Every method is idempotent and every one is a no-op once the job has
|
||||||
@@ -498,6 +523,25 @@ impl Drop for Activity {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_download_reads_as_what_it_knows() {
|
||||||
|
let mb = 1024 * 1024;
|
||||||
|
assert_eq!(describe_download(0, None), ("Downloading…".to_string(), -1.0));
|
||||||
|
assert_eq!(
|
||||||
|
describe_download(0, Some(38 * mb)),
|
||||||
|
("Downloading 38.0 MB".to_string(), 0.0)
|
||||||
|
);
|
||||||
|
let (text, fraction) = describe_download(19 * mb, Some(38 * mb));
|
||||||
|
assert_eq!(text, "Downloading — 19.0 MB of 38.0 MB");
|
||||||
|
assert_eq!(fraction, 0.5);
|
||||||
|
let (text, fraction) = describe_download(12 * mb, None);
|
||||||
|
assert_eq!(text, "Downloading — 12.0 MB");
|
||||||
|
assert!(fraction < 0.0, "no total, no position");
|
||||||
|
let (text, fraction) = describe_download(40 * mb, Some(38 * mb));
|
||||||
|
assert_eq!(text, "Downloading — 40.0 MB of 40.0 MB");
|
||||||
|
assert_eq!(fraction, 1.0, "an overrun stops full");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_running_job_makes_the_bar_busy() {
|
fn a_running_job_makes_the_bar_busy() {
|
||||||
let log = ActivityLog::new();
|
let log = ActivityLog::new();
|
||||||
|
|||||||
+40
-2
@@ -411,6 +411,12 @@ fn is_supported(p: &Path) -> bool {
|
|||||||
/// rect chosen for the first — or, since local masking became a mode, would
|
/// rect chosen for the first — or, since local masking became a mode, would
|
||||||
/// open the next photograph with a mask stack it does not have.
|
/// open the next photograph with a mask stack it does not have.
|
||||||
fn reset_view_state(window: &AppWindow) {
|
fn reset_view_state(window: &AppWindow) {
|
||||||
|
// Whatever the last open was waiting for, this one is not — until the
|
||||||
|
// remote path says otherwise.
|
||||||
|
window.set_load_pending(false);
|
||||||
|
window.set_load_waiting("".into());
|
||||||
|
window.set_load_fraction(-1.0);
|
||||||
|
window.set_has_load_preview(false);
|
||||||
window.global::<Develop>().set_view_mode(ViewMode::Photo);
|
window.global::<Develop>().set_view_mode(ViewMode::Photo);
|
||||||
window.set_zoom(1.0);
|
window.set_zoom(1.0);
|
||||||
window.set_zoomed(false);
|
window.set_zoomed(false);
|
||||||
@@ -2769,7 +2775,17 @@ fn wire_remote_open(
|
|||||||
|
|
||||||
log::info!("fetching {path} for develop");
|
log::info!("fetching {path} for develop");
|
||||||
|
|
||||||
w.set_load_error("Downloading…".into());
|
// TRACES: FR-NC-6a
|
||||||
|
// The grid's thumbnail stands in from this moment, so the step
|
||||||
|
// lands on this photograph rather than on the last one's pixels
|
||||||
|
// or an empty frame. Whether to say anything about a download is
|
||||||
|
// decided below, once one is actually running.
|
||||||
|
let (preview, size) = library.preview_for_path(&w, &path);
|
||||||
|
if let Some(p) = preview {
|
||||||
|
w.set_load_preview(p);
|
||||||
|
w.set_has_load_preview(true);
|
||||||
|
}
|
||||||
|
w.set_load_pending(true);
|
||||||
|
|
||||||
let rx = library::spawn_full_fetch(conn, path.clone(), cache);
|
let rx = library::spawn_full_fetch(conn, path.clone(), cache);
|
||||||
|
|
||||||
@@ -2803,7 +2819,26 @@ fn wire_remote_open(
|
|||||||
slint::TimerMode::Repeated,
|
slint::TimerMode::Repeated,
|
||||||
std::time::Duration::from_millis(50),
|
std::time::Duration::from_millis(50),
|
||||||
move || {
|
move || {
|
||||||
let Ok(got) = rx.try_recv() else { return };
|
let Ok(got) = rx.try_recv() else {
|
||||||
|
// TRACES: FR-NC-6a
|
||||||
|
// Still coming. Said only once the original is on the
|
||||||
|
// wire — a read from the cache lands before there is
|
||||||
|
// a transfer to find, and so never flashes a headline.
|
||||||
|
// Read by path, because the transfer is as often the
|
||||||
|
// prefetcher's as this open's own.
|
||||||
|
if current.get() == mine {
|
||||||
|
if let (Some(w), Some((received, declared))) =
|
||||||
|
(weak.upgrade(), library::transfer_progress(&path))
|
||||||
|
{
|
||||||
|
let (text, fraction) =
|
||||||
|
activity::describe_download(received, declared.or(size));
|
||||||
|
w.set_load_waiting(text.as_str().into());
|
||||||
|
w.set_load_fraction(fraction);
|
||||||
|
job.detail(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
// Landed — this timer has done its job.
|
// Landed — this timer has done its job.
|
||||||
held.stop();
|
held.stop();
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
@@ -2819,6 +2854,9 @@ fn wire_remote_open(
|
|||||||
log::debug!("{name}: landed after the view moved on");
|
log::debug!("{name}: landed after the view moved on");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
w.set_load_pending(false);
|
||||||
|
w.set_load_waiting("".into());
|
||||||
|
w.set_has_load_preview(false);
|
||||||
|
|
||||||
let bytes = match got {
|
let bytes = match got {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
|
|||||||
@@ -588,9 +588,31 @@ pub fn spawn_full_fetch(
|
|||||||
/// because that is the one name every caller has.
|
/// because that is the one name every caller has.
|
||||||
static IN_FLIGHT: std::sync::LazyLock<InFlight> = std::sync::LazyLock::new(InFlight::default);
|
static IN_FLIGHT: std::sync::LazyLock<InFlight> = std::sync::LazyLock::new(InFlight::default);
|
||||||
|
|
||||||
|
/// TRACES: FR-NC-6a
|
||||||
|
/// How far one original's transfer has got: bytes received, and the length
|
||||||
|
/// the server declared (zero until it has, or if it never does).
|
||||||
|
///
|
||||||
|
/// Held in the registry beside the claim rather than handed to the caller,
|
||||||
|
/// because the caller watching is often not the one downloading: a step along
|
||||||
|
/// the roll usually lands on a frame the [`Prefetcher`] is already fetching,
|
||||||
|
/// and the click waits on that transfer instead of starting its own.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct Transfer {
|
||||||
|
received: std::sync::atomic::AtomicU64,
|
||||||
|
declared: std::sync::atomic::AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-NC-6a
|
||||||
|
/// Bytes received so far and bytes expected, for an original being fetched
|
||||||
|
/// right now by anyone. `None` when nothing is fetching `path` — it is in the
|
||||||
|
/// cache, or the transfer has not reached the network yet, or it has ended.
|
||||||
|
pub fn transfer_progress(path: &str) -> Option<(u64, Option<u64>)> {
|
||||||
|
IN_FLIGHT.progress(path)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub(super) struct InFlight {
|
pub(super) struct InFlight {
|
||||||
busy: std::sync::Mutex<std::collections::HashSet<String>>,
|
busy: std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<Transfer>>>,
|
||||||
freed: std::sync::Condvar,
|
freed: std::sync::Condvar,
|
||||||
/// Threads parked in [`InFlight::claim`], counted under the lock so a
|
/// Threads parked in [`InFlight::claim`], counted under the lock so a
|
||||||
/// test can release the holder only once a waiter is really waiting.
|
/// test can release the holder only once a waiter is really waiting.
|
||||||
@@ -607,21 +629,32 @@ impl InFlight {
|
|||||||
/// stored what the caller was about to download.
|
/// stored what the caller was about to download.
|
||||||
fn claim(&self, path: &str) -> Option<InFlightGuard<'_>> {
|
fn claim(&self, path: &str) -> Option<InFlightGuard<'_>> {
|
||||||
let mut busy = self.busy.lock().unwrap_or_else(|e| e.into_inner());
|
let mut busy = self.busy.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
if busy.insert(path.to_string()) {
|
if !busy.contains_key(path) {
|
||||||
|
let transfer = std::sync::Arc::new(Transfer::default());
|
||||||
|
busy.insert(path.to_string(), transfer.clone());
|
||||||
return Some(InFlightGuard {
|
return Some(InFlightGuard {
|
||||||
of: self,
|
of: self,
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
|
transfer,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
self.waiting
|
self.waiting
|
||||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
while busy.contains(path) {
|
while busy.contains_key(path) {
|
||||||
busy = self.freed.wait(busy).unwrap_or_else(|e| e.into_inner());
|
busy = self.freed.wait(busy).unwrap_or_else(|e| e.into_inner());
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn progress(&self, path: &str) -> Option<(u64, Option<u64>)> {
|
||||||
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
|
let busy = self.busy.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let t = busy.get(path)?;
|
||||||
|
let declared = t.declared.load(Relaxed);
|
||||||
|
Some((t.received.load(Relaxed), (declared > 0).then_some(declared)))
|
||||||
|
}
|
||||||
|
|
||||||
fn release(&self, path: &str) {
|
fn release(&self, path: &str) {
|
||||||
self.busy
|
self.busy
|
||||||
.lock()
|
.lock()
|
||||||
@@ -636,6 +669,7 @@ impl InFlight {
|
|||||||
pub(super) struct InFlightGuard<'a> {
|
pub(super) struct InFlightGuard<'a> {
|
||||||
of: &'a InFlight,
|
of: &'a InFlight,
|
||||||
path: String,
|
path: String,
|
||||||
|
transfer: std::sync::Arc<Transfer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for InFlightGuard<'_> {
|
impl Drop for InFlightGuard<'_> {
|
||||||
@@ -678,7 +712,7 @@ pub(super) fn fetch_original(
|
|||||||
|
|
||||||
// Miss, claim, and if the claim had to wait, look again: the thread that
|
// Miss, claim, and if the claim had to wait, look again: the thread that
|
||||||
// held the path has finished with it, and what it fetched is on disk.
|
// held the path has finished with it, and what it fetched is on disk.
|
||||||
let _claim = loop {
|
let claim = loop {
|
||||||
if let Some(bytes) = from_cache() {
|
if let Some(bytes) = from_cache() {
|
||||||
return Ok(bytes);
|
return Ok(bytes);
|
||||||
}
|
}
|
||||||
@@ -693,7 +727,14 @@ pub(super) fn fetch_original(
|
|||||||
let backend = crate::remote::connect(&conn).map_err(FetchFailure::local)?;
|
let backend = crate::remote::connect(&conn).map_err(FetchFailure::local)?;
|
||||||
|
|
||||||
let id = RemoteId::Path(RemotePath::new(path));
|
let id = RemoteId::Path(RemotePath::new(path));
|
||||||
let bytes = backend.get(&id, None).await?;
|
let transfer = &claim.transfer;
|
||||||
|
let bytes = backend
|
||||||
|
.get_reporting(&id, &|received, declared| {
|
||||||
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
|
transfer.received.store(received, Relaxed);
|
||||||
|
transfer.declared.store(declared.unwrap_or(0), Relaxed);
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Store before returning, so the bytes are on disk by the time the
|
// Store before returning, so the bytes are on disk by the time the
|
||||||
// image is on screen. Doing it after would leave a window where
|
// image is on screen. Doing it after would leave a window where
|
||||||
@@ -936,6 +977,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whoever is watching a path reads the holder's progress through the
|
||||||
|
/// registry, and loses it when the transfer ends — a step onto a frame
|
||||||
|
/// the prefetcher is fetching shows that transfer's bar.
|
||||||
|
#[test]
|
||||||
|
fn progress_is_readable_by_path_while_claimed() {
|
||||||
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
|
let registry = InFlight::default();
|
||||||
|
assert_eq!(registry.progress("a.CR2"), None);
|
||||||
|
|
||||||
|
let claim = registry.claim("a.CR2").unwrap();
|
||||||
|
assert_eq!(registry.progress("a.CR2"), Some((0, None)));
|
||||||
|
|
||||||
|
claim.transfer.received.store(1024, Relaxed);
|
||||||
|
claim.transfer.declared.store(4096, Relaxed);
|
||||||
|
assert_eq!(registry.progress("a.CR2"), Some((1024, Some(4096))));
|
||||||
|
assert_eq!(registry.progress("b.CR2"), None);
|
||||||
|
|
||||||
|
drop(claim);
|
||||||
|
assert_eq!(registry.progress("a.CR2"), None);
|
||||||
|
}
|
||||||
|
|
||||||
/// Different photographs never wait on each other.
|
/// Different photographs never wait on each other.
|
||||||
#[test]
|
#[test]
|
||||||
fn distinct_paths_are_claimed_independently() {
|
fn distinct_paths_are_claimed_independently() {
|
||||||
|
|||||||
@@ -614,6 +614,34 @@ impl LibraryController {
|
|||||||
.map(|id| dr_types::ImageId(*id as u64))
|
.map(|id| dr_types::ImageId(*id as u64))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-NC-6a
|
||||||
|
/// What the grid already holds for `path`: the thumbnail its cell is
|
||||||
|
/// drawing, if it has one yet, and the file's length as the scan recorded
|
||||||
|
/// it.
|
||||||
|
///
|
||||||
|
/// For the develop view while the original comes down. The thumbnail is
|
||||||
|
/// the one already decoded for the grid — no store read, no decode — and
|
||||||
|
/// the length gives the progress bar a denominator when the server sends
|
||||||
|
/// none of its own.
|
||||||
|
pub fn preview_for_path(
|
||||||
|
&self,
|
||||||
|
window: &crate::AppWindow,
|
||||||
|
path: &str,
|
||||||
|
) -> (Option<slint::Image>, Option<u64>) {
|
||||||
|
use slint::{ComponentHandle as _, Model as _};
|
||||||
|
let Some(row) = self.paths.borrow().iter().position(|p| p == path) else {
|
||||||
|
return (None, None);
|
||||||
|
};
|
||||||
|
let size = self.sizes.borrow().get(row).copied().filter(|&s| s > 0);
|
||||||
|
let thumbnail = window
|
||||||
|
.global::<crate::Library>()
|
||||||
|
.get_library_cells()
|
||||||
|
.row_data(row)
|
||||||
|
.filter(|c| c.has_thumb)
|
||||||
|
.map(|c| c.thumbnail);
|
||||||
|
(thumbnail, size)
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-NC-6a | FR-UI-4
|
/// TRACES: FR-NC-6a | FR-UI-4
|
||||||
/// The photographs within `depth` of `path` on the roll, closest first
|
/// The photographs within `depth` of `path` on the roll, closest first
|
||||||
/// and working outwards: next, previous, next-but-one, previous-but-one…
|
/// and working outwards: next, previous, next-but-one, previous-but-one…
|
||||||
|
|||||||
+56
-15
@@ -9,7 +9,7 @@ import { LaunchScreen } from "launch.slint";
|
|||||||
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
|
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
|
||||||
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip, Library } from "library.slint";
|
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip, Library } from "library.slint";
|
||||||
import { GestureRow, GestureSheet } from "gestures.slint";
|
import { GestureRow, GestureSheet } from "gestures.slint";
|
||||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow, Scrolling, ScrollBar } from "widgets.slint";
|
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, WaitingState, ProgressBar, ActivityRow, Scrolling, ScrollBar } from "widgets.slint";
|
||||||
import { CollectionsPanel, CollectionRow, Collections, OfflinePrompt, CollectionMenu,
|
import { CollectionsPanel, CollectionRow, Collections, OfflinePrompt, CollectionMenu,
|
||||||
MembershipSheet, MembershipRow } from "collections.slint";
|
MembershipSheet, MembershipRow } from "collections.slint";
|
||||||
import { HistogramPanel, HistogramView, Levels } from "histogram.slint";
|
import { HistogramPanel, HistogramView, Levels } from "histogram.slint";
|
||||||
@@ -150,6 +150,32 @@ export component AppWindow inherits Window {
|
|||||||
in property <int> total: 0;
|
in property <int> total: 0;
|
||||||
in property <string> load-error: "";
|
in property <string> load-error: "";
|
||||||
|
|
||||||
|
/// TRACES: FR-NC-6a
|
||||||
|
/// A photograph has been asked for and is not on the canvas yet — it is
|
||||||
|
/// being read from the cache, or downloaded, or decoded. The previous
|
||||||
|
/// frame is gone from view by then; the grid's thumbnail stands in.
|
||||||
|
in property <bool> load-pending: false;
|
||||||
|
/// And it is not on this device: what has arrived, as a sentence
|
||||||
|
/// ("12.4 of 38.0 MB"). Empty while it is only being read from disk, which
|
||||||
|
/// is too quick to be worth a headline.
|
||||||
|
///
|
||||||
|
/// Its own property rather than a message in `load-error`, which is where
|
||||||
|
/// it used to live — and so the view headed a download that was going
|
||||||
|
/// fine with "Could not load image", on every step, cached or not.
|
||||||
|
in property <string> load-waiting: "";
|
||||||
|
/// How much of it, 0..1, or below zero while the size is not known yet.
|
||||||
|
in property <float> load-fraction: -1;
|
||||||
|
/// The grid's thumbnail of the photograph being fetched, shown dimmed
|
||||||
|
/// behind the bar, so stepping along the roll moves from picture to
|
||||||
|
/// picture rather than through an empty frame.
|
||||||
|
in property <image> load-preview;
|
||||||
|
in property <bool> has-load-preview: false;
|
||||||
|
|
||||||
|
/// A photograph is on the canvas and is the one named: not an empty
|
||||||
|
/// folder, not a failure, not a download still coming in. Everything that
|
||||||
|
/// acts on the picture is gated on this.
|
||||||
|
property <bool> has-photo: root.total > 0 && root.load-error == "" && !root.load-pending;
|
||||||
|
|
||||||
/// TRACES: FR-CULL-3
|
/// TRACES: FR-CULL-3
|
||||||
/// The focus marks themselves, and whether they describe *this* frame.
|
/// The focus marks themselves, and whether they describe *this* frame.
|
||||||
/// Rust's, because they come from a compute pass — see `peaking.slint` for
|
/// Rust's, because they come from a compute pass — see `peaking.slint` for
|
||||||
@@ -1061,7 +1087,7 @@ in property <bool> panel-visible: true;
|
|||||||
root.export-from-sheet();
|
root.export-from-sheet();
|
||||||
return accept;
|
return accept;
|
||||||
}
|
}
|
||||||
if (root.copy-sheet-open && root.total > 0 && root.load-error == "") {
|
if (root.copy-sheet-open && root.has-photo) {
|
||||||
root.copy-sheet-open = false;
|
root.copy-sheet-open = false;
|
||||||
Transfer.copy();
|
Transfer.copy();
|
||||||
return accept;
|
return accept;
|
||||||
@@ -1733,7 +1759,7 @@ in property <bool> panel-visible: true;
|
|||||||
x: 0;
|
x: 0;
|
||||||
y: 0;
|
y: 0;
|
||||||
height: parent.height;
|
height: parent.height;
|
||||||
enabled: root.total > 0 && root.load-error == "";
|
enabled: root.has-photo;
|
||||||
mode: Develop.view-mode;
|
mode: Develop.view-mode;
|
||||||
picked(m) => { root.mode-picked(m); }
|
picked(m) => { root.mode-picked(m); }
|
||||||
|
|
||||||
@@ -1792,7 +1818,7 @@ in property <bool> panel-visible: true;
|
|||||||
image-rendering: root.magnified
|
image-rendering: root.magnified
|
||||||
? ImageRendering.pixelated
|
? ImageRendering.pixelated
|
||||||
: ImageRendering.smooth;
|
: ImageRendering.smooth;
|
||||||
visible: root.total > 0 && root.load-error == "";
|
visible: root.has-photo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: FR-DSP-4
|
// TRACES: FR-DSP-4
|
||||||
@@ -1837,7 +1863,7 @@ in property <bool> panel-visible: true;
|
|||||||
// it is a diagnostic and not an edit: it must not reach
|
// it is a diagnostic and not an edit: it must not reach
|
||||||
// the histogram, an export, or the texture the develop
|
// the histogram, an export, or the texture the develop
|
||||||
// pass hands the compositor.
|
// pass hands the compositor.
|
||||||
if root.overlay-on && !Masking.overlay-hidden && root.total > 0: Image {
|
if root.overlay-on && !Masking.overlay-hidden && root.has-photo: Image {
|
||||||
x: parent.shown-x;
|
x: parent.shown-x;
|
||||||
y: parent.shown-y;
|
y: parent.shown-y;
|
||||||
width: parent.shown-w;
|
width: parent.shown-w;
|
||||||
@@ -1867,7 +1893,7 @@ in property <bool> panel-visible: true;
|
|||||||
// The focus marks, over the same fitted rect. See
|
// The focus marks, over the same fitted rect. See
|
||||||
// `peaking.slint` for why they are a layer over the canvas
|
// `peaking.slint` for why they are a layer over the canvas
|
||||||
// rather than a tint in it.
|
// rather than a tint in it.
|
||||||
if root.focus-overlay-ready && root.total > 0: FocusMarks {
|
if root.focus-overlay-ready && root.has-photo: FocusMarks {
|
||||||
x: parent.shown-x;
|
x: parent.shown-x;
|
||||||
y: parent.shown-y;
|
y: parent.shown-y;
|
||||||
width: parent.shown-w;
|
width: parent.shown-w;
|
||||||
@@ -1900,6 +1926,21 @@ in property <bool> panel-visible: true;
|
|||||||
: "Pass a folder or file on the command line.";
|
: "Pass a folder or file on the command line.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-NC-6a
|
||||||
|
// Waiting for an original. Not an empty state and not an
|
||||||
|
// error: the photograph is known, and the grid's thumbnail
|
||||||
|
// of it stands in so a step along the roll lands on *this*
|
||||||
|
// frame at once — with a bar, once it turns out to be a
|
||||||
|
// download, saying how long until it can be worked on.
|
||||||
|
if root.total > 0 && root.load-error == "" && root.load-pending: WaitingState {
|
||||||
|
headline: "Not on this device yet";
|
||||||
|
detail: root.load-waiting;
|
||||||
|
fraction: root.load-fraction;
|
||||||
|
what: "Downloading " + root.filename;
|
||||||
|
preview: root.load-preview;
|
||||||
|
has-preview: root.has-load-preview;
|
||||||
|
}
|
||||||
|
|
||||||
// --- zoom and pan ------------------------------------------
|
// --- zoom and pan ------------------------------------------
|
||||||
//
|
//
|
||||||
// Below the crop overlay in z-order so that, in crop mode, the
|
// Below the crop overlay in z-order so that, in crop mode, the
|
||||||
@@ -1935,7 +1976,7 @@ in property <bool> panel-visible: true;
|
|||||||
// Anchored on the midpoint between the fingers, which is
|
// Anchored on the midpoint between the fingers, which is
|
||||||
// what makes a pinch feel like it is moving the picture
|
// what makes a pinch feel like it is moving the picture
|
||||||
// rather than the viewport.
|
// rather than the viewport.
|
||||||
if root.total > 0 && root.load-error == "": ScaleRotateGestureHandler {
|
if root.has-photo: ScaleRotateGestureHandler {
|
||||||
x: 0; y: 0;
|
x: 0; y: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1982,7 +2023,7 @@ in property <bool> panel-visible: true;
|
|||||||
// it.** `GradientHandles` further down is the other end of
|
// it.** `GradientHandles` further down is the other end of
|
||||||
// the same rule, and is why dragging a handle has always
|
// the same rule, and is why dragging a handle has always
|
||||||
// worked while everything between it and here did not.
|
// worked while everything between it and here did not.
|
||||||
if root.total > 0 && root.load-error == "": TouchArea {
|
if root.has-photo: TouchArea {
|
||||||
x: 0; y: 0;
|
x: 0; y: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -2073,7 +2114,7 @@ in property <bool> panel-visible: true;
|
|||||||
// picking wants a click, and interleaving the two in one
|
// picking wants a click, and interleaving the two in one
|
||||||
// handler is how a drag ends up selecting a region the
|
// handler is how a drag ends up selecting a region the
|
||||||
// user was only scrolling past.
|
// user was only scrolling past.
|
||||||
if root.region-picking && root.total > 0 && root.load-error == "": pick := TouchArea {
|
if root.region-picking && root.has-photo: pick := TouchArea {
|
||||||
x: parent.shown-x;
|
x: parent.shown-x;
|
||||||
y: parent.shown-y;
|
y: parent.shown-y;
|
||||||
width: parent.shown-w;
|
width: parent.shown-w;
|
||||||
@@ -2111,7 +2152,7 @@ in property <bool> panel-visible: true;
|
|||||||
// two want the same press, and `region-picking` is false
|
// two want the same press, and `region-picking` is false
|
||||||
// whenever this is armed so only one of them exists at a
|
// whenever this is armed so only one of them exists at a
|
||||||
// time.
|
// time.
|
||||||
if root.painting && root.total > 0 && root.load-error == "": paint := TouchArea {
|
if root.painting && root.has-photo: paint := TouchArea {
|
||||||
x: parent.shown-x;
|
x: parent.shown-x;
|
||||||
y: parent.shown-y;
|
y: parent.shown-y;
|
||||||
width: parent.shown-w;
|
width: parent.shown-w;
|
||||||
@@ -2154,7 +2195,7 @@ in property <bool> panel-visible: true;
|
|||||||
// Below the circles declared further down, so a press that
|
// Below the circles declared further down, so a press that
|
||||||
// lands on an existing repair takes hold of it instead of
|
// lands on an existing repair takes hold of it instead of
|
||||||
// making another one on top.
|
// making another one on top.
|
||||||
if Develop.repairing && root.total > 0 && root.load-error == "": TouchArea {
|
if Develop.repairing && root.has-photo: TouchArea {
|
||||||
x: parent.shown-x;
|
x: parent.shown-x;
|
||||||
y: parent.shown-y;
|
y: parent.shown-y;
|
||||||
width: parent.shown-w;
|
width: parent.shown-w;
|
||||||
@@ -2185,7 +2226,7 @@ in property <bool> panel-visible: true;
|
|||||||
// were recorded the stack would fill with a hundred
|
// were recorded the stack would fill with a hundred
|
||||||
// temperatures nobody chose. The arming state is what says
|
// temperatures nobody chose. The arming state is what says
|
||||||
// the next click will do something instead.
|
// the next click will do something instead.
|
||||||
if root.sampling && root.total > 0 && root.load-error == "": TouchArea {
|
if root.sampling && root.has-photo: TouchArea {
|
||||||
x: parent.shown-x;
|
x: parent.shown-x;
|
||||||
y: parent.shown-y;
|
y: parent.shown-y;
|
||||||
width: parent.shown-w;
|
width: parent.shown-w;
|
||||||
@@ -2211,7 +2252,7 @@ in property <bool> panel-visible: true;
|
|||||||
//
|
//
|
||||||
// Placed over the fitted image, so the fractions it draws are
|
// Placed over the fitted image, so the fractions it draws are
|
||||||
// fractions of the picture. See `CropOverlay` in crop.slint.
|
// fractions of the picture. See `CropOverlay` in crop.slint.
|
||||||
if Develop.cropping && root.total > 0 && root.load-error == "": CropOverlay {
|
if Develop.cropping && root.has-photo: CropOverlay {
|
||||||
x: canvas-area.shown-x;
|
x: canvas-area.shown-x;
|
||||||
y: canvas-area.shown-y;
|
y: canvas-area.shown-y;
|
||||||
width: canvas-area.shown-w;
|
width: canvas-area.shown-w;
|
||||||
@@ -2813,7 +2854,7 @@ in property <bool> panel-visible: true;
|
|||||||
// window away from the thing being confirmed is a
|
// window away from the thing being confirmed is a
|
||||||
// confirmation you take on trust. The rail is now the
|
// confirmation you take on trust. The rail is now the
|
||||||
// second way out rather than the first.
|
// second way out rather than the first.
|
||||||
if root.total > 0 && root.load-error == "": HorizontalLayout {
|
if root.has-photo: HorizontalLayout {
|
||||||
// **Clear of the photo roll, or these do nothing.**
|
// **Clear of the photo roll, or these do nothing.**
|
||||||
//
|
//
|
||||||
// The roll's swipe handler consumes every press that
|
// The roll's swipe handler consumes every press that
|
||||||
@@ -3415,7 +3456,7 @@ in property <bool> panel-visible: true;
|
|||||||
scope-kinds: root.copy-scope-kinds;
|
scope-kinds: root.copy-scope-kinds;
|
||||||
scope-empty: root.copy-scope-empty;
|
scope-empty: root.copy-scope-empty;
|
||||||
summary: Transfer.summary;
|
summary: Transfer.summary;
|
||||||
can-copy: root.total > 0 && root.load-error == "";
|
can-copy: root.has-photo;
|
||||||
scope-toggled(name) => { root.copy-scope-toggled(name); }
|
scope-toggled(name) => { root.copy-scope-toggled(name); }
|
||||||
copy => {
|
copy => {
|
||||||
root.copy-sheet-open = false;
|
root.copy-sheet-open = false;
|
||||||
|
|||||||
@@ -987,6 +987,63 @@ export component EmptyState inherits VerticalLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What a view shows while the thing it will show is still on its way.
|
||||||
|
//
|
||||||
|
// The third answer beside `EmptyState`'s two. A photograph being downloaded is
|
||||||
|
// neither missing nor broken, and the develop view used to put it under the
|
||||||
|
// error heading — "Could not load image", then "Downloading…" — which reads as
|
||||||
|
// a failure followed by a retry. This one shows what is already in hand (the
|
||||||
|
// grid's thumbnail) so the step lands on *this* photograph at once, and, only
|
||||||
|
// once there is a real wait (`detail` set), dims it under a headline, how far
|
||||||
|
// along, and a bar. A read from disk never gets that far, so it never
|
||||||
|
// flashes text.
|
||||||
|
export component WaitingState inherits Rectangle {
|
||||||
|
in property <string> headline;
|
||||||
|
/// Empty while the wait is too short to talk about.
|
||||||
|
in property <string> detail;
|
||||||
|
/// 0..1, or below zero while the size is not known.
|
||||||
|
in property <float> fraction: -1;
|
||||||
|
/// What the bar is announced as.
|
||||||
|
in property <string> what;
|
||||||
|
in property <image> preview;
|
||||||
|
in property <bool> has-preview: false;
|
||||||
|
|
||||||
|
if root.has-preview: Image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
source: root.preview;
|
||||||
|
image-fit: contain;
|
||||||
|
opacity: root.detail != "" ? 0.4 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.detail != "": VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: Theme.gap;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.headline;
|
||||||
|
color: Theme.ink;
|
||||||
|
font-size: Theme.text-lg;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
Caption {
|
||||||
|
text: root.detail;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
ProgressBar {
|
||||||
|
width: min(240px, root.width * 60%);
|
||||||
|
fraction: root.fraction;
|
||||||
|
indeterminate: root.fraction < 0;
|
||||||
|
label: root.what;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A scrolling list that only builds the rows you can see.
|
// A scrolling list that only builds the rows you can see.
|
||||||
//
|
//
|
||||||
// # Why this exists rather than `Flickable { VerticalLayout { for … } }`
|
// # Why this exists rather than `Flickable { VerticalLayout { for … } }`
|
||||||
|
|||||||
Reference in New Issue
Block a user