Add the library, collections, and trash views; theme from style.yaml
The UI gains the views the catalog work was building toward: a windowed
library grid with ratings and flags, the collection tree with drag-to-add,
and trash with restore. derived_sync pushes thumbnail shards and the catalog
snapshot to the server's derived folder.
Tokens now have one source of truth. build.rs reads style.yaml and generates
theme.slint into OUT_DIR, which answers every existing
`import { Theme } from "theme.slint"` unchanged, because Slint resolves
imports against the importing file's directory first and the include paths
after. Generating into OUT_DIR rather than beside the hand-written Slint is
the point: a generated file sitting in ui/ looks exactly like the files
around it that are meant to be edited, and an edit to it would survive until
the next touch of style.yaml — a bug that hides for weeks. build.rs fails
loudly if a stale ui/theme.slint exists, which would otherwise shadow the
generated one silently and make every palette change vanish with no error.
The palette moves to near-neutral dark with achromatic signalling, so the
accent means "modified" or "active" rather than "heading". Shared components
land in widgets.slint: a token that binds several values into one concept is
a component, not a row in a YAML file.
Adds an optional live-style feature that makes the tokens in-out so they can
be written at startup — a feature rather than the default because it stops
the properties being constant-folded.
serde_norway is the YAML crate: serde_yaml and serde_yml are both deprecated,
and its mappings preserve insertion order, which is what lets the generated
Slint keep the token ordering the author chose.
Assisted-by: LLM
This commit is contained in:
+28
-1
@@ -19,16 +19,43 @@ dr-plat.workspace = true
|
||||
dr-sync.workspace = true
|
||||
dr-sync-nextcloud.workspace = true
|
||||
dr-pipeline.workspace = true
|
||||
slint = { workspace = true, features = ["compat-1-2", "renderer-femtovg", "backend-winit"] }
|
||||
dr-catalog.workspace = true
|
||||
dr-thumbs.workspace = true
|
||||
# The library module writes scan results straight into the catalog, so it
|
||||
# needs the same SQLite types dr-catalog exposes.
|
||||
rusqlite.workspace = true
|
||||
# The renderer is shared, but the backend is not: winit on desktop,
|
||||
# android-activity on Android, and enabling both makes the backend selector
|
||||
# pick at random. So the backend features live on the target-specific
|
||||
# dependencies below rather than here.
|
||||
slint = { workspace = true, features = ["compat-1-2", "renderer-femtovg"] }
|
||||
wgpu.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
pollster.workspace = true
|
||||
# Runtime YAML only for `live-style`; release builds read the tokens the
|
||||
# Slint compiler folded in at build time and never touch style.yaml.
|
||||
serde_norway = { workspace = true, optional = true }
|
||||
|
||||
# Backend per platform. Slint already declares its android-activity backend
|
||||
# under `cfg(target_os = "android")`, so this only has to name the feature;
|
||||
# cargo resolves it away entirely on desktop.
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
slint = { workspace = true, features = ["backend-winit"] }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
slint = { workspace = true, features = ["backend-android-activity-06"] }
|
||||
|
||||
[build-dependencies]
|
||||
slint-build.workspace = true
|
||||
# build.rs generates theme.slint from style.yaml (S2).
|
||||
serde_norway.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Temporary CPU readback path; see dr-ui docs and spike S1.
|
||||
readback = ["dr-gpu/readback"]
|
||||
# Debug convenience: re-read style.yaml at startup so a palette can be tuned
|
||||
# without rebuilding. Costs the constant-folding of every token, so it stays
|
||||
# off by default and has no business in a release build.
|
||||
live-style = ["dep:serde_norway"]
|
||||
|
||||
+315
-1
@@ -1,3 +1,317 @@
|
||||
//! Generates `theme.slint` from `style.yaml`, then compiles the UI.
|
||||
//!
|
||||
//! The generated file lands in `OUT_DIR`, not beside the hand-written Slint.
|
||||
//! That is the whole point: a generated file sitting in `ui/` looks exactly
|
||||
//! like the five files around it that *are* meant to be edited, and an edit
|
||||
//! to it survives until the next `touch style.yaml` — a bug that hides for
|
||||
//! weeks. In `OUT_DIR` it cannot be edited by accident and cannot be
|
||||
//! committed by accident, so no `.gitignore` entry is needed either.
|
||||
//!
|
||||
//! Slint resolves `import ... from "theme.slint"` against the importing
|
||||
//! file's directory first and the compiler's include paths after, so adding
|
||||
//! `OUT_DIR` as an include path makes the generated file answer every
|
||||
//! existing `import { Theme } from "theme.slint"` unchanged. This only works
|
||||
//! while no `ui/theme.slint` exists to shadow it — see the guard below.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_norway::Value;
|
||||
|
||||
const STYLE_YAML: &str = "style.yaml";
|
||||
const GENERATED: &str = "theme.slint";
|
||||
|
||||
fn main() {
|
||||
slint_build::compile("ui/app.slint").expect("compiling app.slint");
|
||||
println!("cargo:rerun-if-changed={STYLE_YAML}");
|
||||
println!("cargo:rerun-if-changed=ui");
|
||||
println!("cargo:rustc-check-cfg=cfg(live_style)");
|
||||
|
||||
// Under `live-style` the tokens become `in-out` so Rust can write them at
|
||||
// startup. Call sites are unaffected — `Theme.ground` reads the same
|
||||
// either way — but the properties stop being constant-folded, which is
|
||||
// why this is a feature and not the default.
|
||||
let live = std::env::var_os("CARGO_FEATURE_LIVE_STYLE").is_some();
|
||||
if live {
|
||||
println!("cargo:rustc-cfg=live_style");
|
||||
}
|
||||
|
||||
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR"));
|
||||
let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir"));
|
||||
|
||||
// A hand-written `ui/theme.slint` would shadow the generated one silently
|
||||
// — the importing file's own directory wins over the include path — and
|
||||
// every palette change would then be ignored with no error anywhere.
|
||||
let shadow = manifest_dir.join("ui").join(GENERATED);
|
||||
if shadow.exists() {
|
||||
fail(format!(
|
||||
"{} exists and would shadow the generated theme.\n\
|
||||
Tokens now come from {STYLE_YAML}; delete the stale file.",
|
||||
shadow.display()
|
||||
));
|
||||
}
|
||||
|
||||
let source = manifest_dir.join(STYLE_YAML);
|
||||
let generated = out_dir.join(GENERATED);
|
||||
match generate(&source, live) {
|
||||
Ok(slint) => std::fs::write(&generated, slint)
|
||||
.unwrap_or_else(|e| fail(format!("writing {}: {e}", generated.display()))),
|
||||
Err(e) => fail(format!("{STYLE_YAML}: {e}")),
|
||||
}
|
||||
|
||||
// The live-reload path parses the same YAML at runtime, so the crate has
|
||||
// to be able to find it from an installed binary too.
|
||||
println!("cargo:rustc-env=DR_STYLE_YAML={}", source.display());
|
||||
|
||||
let config = slint_build::CompilerConfiguration::new()
|
||||
.with_include_paths(vec![out_dir.clone(), manifest_dir.join("ui")]);
|
||||
slint_build::compile_with_config(entry(&out_dir, live), config).expect("compiling app.slint");
|
||||
}
|
||||
|
||||
/// The file handed to the Slint compiler.
|
||||
///
|
||||
/// Normally `ui/app.slint` itself. Under `live-style` it is a generated
|
||||
/// shim that re-exports `app.slint` *and* `Theme`, because Slint emits Rust
|
||||
/// accessors only for globals exported from the entry document — and
|
||||
/// `app.slint` has no business carrying a line that exists to serve a debug
|
||||
/// feature. Everything the crate names (`AppWindow`, `ParamRow`, the cell
|
||||
/// and row structs) comes through the wildcard, so `include_modules!` sees
|
||||
/// exactly what it saw before plus the theme.
|
||||
fn entry(out_dir: &Path, live: bool) -> PathBuf {
|
||||
let app = PathBuf::from("ui/app.slint");
|
||||
if !live {
|
||||
return app;
|
||||
}
|
||||
let shim = out_dir.join("live-entry.slint");
|
||||
std::fs::write(
|
||||
&shim,
|
||||
"// GENERATED — see ui/dr-ui/build.rs. Entry point for `live-style` only.\n\
|
||||
export * from \"app.slint\";\n\
|
||||
import { Theme } from \"theme.slint\";\n\
|
||||
export { Theme }\n",
|
||||
)
|
||||
.unwrap_or_else(|e| fail(format!("writing {}: {e}", shim.display())));
|
||||
shim
|
||||
}
|
||||
|
||||
/// Build scripts report failure through stderr and a non-zero exit; a panic
|
||||
/// buries the message under a backtrace and the "process didn't exit
|
||||
/// successfully" boilerplate, which is exactly the wrong thing when the
|
||||
/// message is the name of the key the author got wrong.
|
||||
fn fail(message: String) -> ! {
|
||||
eprintln!("\nerror: {message}\n");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// --- codegen -------------------------------------------------------------
|
||||
|
||||
fn generate(source: &Path, live: bool) -> Result<String, String> {
|
||||
let text = std::fs::read_to_string(source).map_err(|e| format!("cannot read: {e}"))?;
|
||||
let doc: Value = serde_norway::from_str(&text).map_err(|e| format!("not valid YAML: {e}"))?;
|
||||
let doc = doc.as_mapping().ok_or("top level must be a mapping")?;
|
||||
|
||||
let mut out = String::new();
|
||||
let banner = source
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| STYLE_YAML.into());
|
||||
writeln!(
|
||||
out,
|
||||
"// GENERATED FILE — DO NOT EDIT.\n\
|
||||
//\n\
|
||||
// Written by ui/dr-ui/build.rs from ui/dr-ui/{banner}. Edits here are\n\
|
||||
// discarded the next time that file changes. Change a token there.\n"
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if let Some(preamble) = doc.get("preamble") {
|
||||
let preamble = preamble
|
||||
.as_str()
|
||||
.ok_or("`preamble` must be a block string")?;
|
||||
out.push_str(&comment(preamble, "//", 0));
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// `out` normally, `in-out` under live-style so the reloader can write
|
||||
// them. Reading a token is `Theme.<name>` under both, which is the whole
|
||||
// reason live reload is possible without touching a single call site.
|
||||
let direction = if live {
|
||||
out.push_str(
|
||||
"// Built with `live-style`: tokens are `in-out` so style.yaml can be\n\
|
||||
// re-read at startup. Release builds generate `out` and fold these\n\
|
||||
// to constants.\n",
|
||||
);
|
||||
"in-out"
|
||||
} else {
|
||||
"out"
|
||||
};
|
||||
out.push_str("export global Theme {\n");
|
||||
|
||||
let mut names = BTreeSet::new();
|
||||
emit_group(&mut out, doc, "colors", "color", direction, &mut names, parse_color)?;
|
||||
emit_group(&mut out, doc, "lengths", "length", direction, &mut names, parse_length)?;
|
||||
|
||||
if names.is_empty() {
|
||||
return Err("defines no tokens; expected `colors:` and `lengths:` maps".into());
|
||||
}
|
||||
out.push_str("}\n");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Emits one YAML map as a run of `out property` declarations.
|
||||
///
|
||||
/// `parse` turns a scalar into Slint syntax; everything else about a token —
|
||||
/// its prose, its aliasing, the section headings between groups — is shared
|
||||
/// between colours and lengths and lives here.
|
||||
fn emit_group(
|
||||
out: &mut String,
|
||||
doc: &serde_norway::Mapping,
|
||||
key: &str,
|
||||
slint_type: &str,
|
||||
direction: &str,
|
||||
names: &mut BTreeSet<String>,
|
||||
parse: fn(&Value) -> Result<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let Some(group) = doc.get(key) else {
|
||||
return Err(format!("missing `{key}:` map"));
|
||||
};
|
||||
let group = group
|
||||
.as_mapping()
|
||||
.ok_or_else(|| format!("`{key}` must be a mapping of token name to value"))?;
|
||||
|
||||
let mut first = true;
|
||||
for (name, spec) in group {
|
||||
let name = name
|
||||
.as_str()
|
||||
.ok_or_else(|| format!("`{key}` has a non-string token name"))?;
|
||||
|
||||
// A divider is not a token. `section:` opens a named run with the
|
||||
// prose that introduces it; `break: true` is a bare blank line, which
|
||||
// is how the file groups related tokens (the three inks, the four
|
||||
// text sizes) without a heading each time. YAML discards the author's
|
||||
// blank lines, so the grouping has to be said rather than shown.
|
||||
if let Some(map) = spec.as_mapping() {
|
||||
if let Some(title) = map.get("section") {
|
||||
let title = title
|
||||
.as_str()
|
||||
.ok_or_else(|| format!("`{key}.{name}.section` must be a string"))?;
|
||||
let rule = "-".repeat(64usize.saturating_sub(title.len()).max(3));
|
||||
writeln!(out, "\n // --- {title} {rule}").unwrap();
|
||||
if let Some(note) = prose(spec, "note", &format!("{key}.{name}"))? {
|
||||
out.push_str(" //\n");
|
||||
out.push_str(&comment(¬e, "//", 4));
|
||||
}
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
if map.contains_key("break") {
|
||||
out.push('\n');
|
||||
first = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if !names.insert(name.to_string()) {
|
||||
return Err(format!("`{name}` is defined twice"));
|
||||
}
|
||||
|
||||
// Blank line between prose-carrying tokens, so a comment attaches
|
||||
// visibly to the token below it rather than the run above.
|
||||
let doc_comment = prose(spec, "doc", &format!("{key}.{name}"))?;
|
||||
let note = prose(spec, "note", &format!("{key}.{name}"))?;
|
||||
if !first && (doc_comment.is_some() || note.is_some()) {
|
||||
out.push('\n');
|
||||
}
|
||||
if let Some(note) = ¬e {
|
||||
out.push_str(&comment(note, "//", 4));
|
||||
}
|
||||
if let Some(doc_comment) = &doc_comment {
|
||||
out.push_str(&comment(doc_comment, "///", 4));
|
||||
}
|
||||
|
||||
let value = token_value(spec, name, key, parse)?;
|
||||
writeln!(out, " {direction} property <{slint_type}> {name}: {value};").unwrap();
|
||||
first = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A token is either a bare scalar or a mapping carrying `value:`/`alias:`
|
||||
/// alongside its prose.
|
||||
fn token_value(
|
||||
spec: &Value,
|
||||
name: &str,
|
||||
key: &str,
|
||||
parse: fn(&Value) -> Result<String, String>,
|
||||
) -> Result<String, String> {
|
||||
let Some(map) = spec.as_mapping() else {
|
||||
return parse(spec).map_err(|e| format!("`{key}.{name}`: {e}"));
|
||||
};
|
||||
if let Some(alias) = map.get("alias") {
|
||||
let alias = alias
|
||||
.as_str()
|
||||
.ok_or_else(|| format!("`{key}.{name}.alias` must name another token"))?;
|
||||
// `root.` rather than a bare name: inside a global, an unqualified
|
||||
// reference to a sibling property does not resolve.
|
||||
return Ok(format!("root.{alias}"));
|
||||
}
|
||||
let value = map
|
||||
.get("value")
|
||||
.ok_or_else(|| format!("`{key}.{name}` has neither `value:` nor `alias:`"))?;
|
||||
parse(value).map_err(|e| format!("`{key}.{name}`: {e}"))
|
||||
}
|
||||
|
||||
fn prose(spec: &Value, field: &str, path: &str) -> Result<Option<String>, String> {
|
||||
let Some(map) = spec.as_mapping() else {
|
||||
return Ok(None);
|
||||
};
|
||||
match map.get(field) {
|
||||
None => Ok(None),
|
||||
Some(v) => v
|
||||
.as_str()
|
||||
.map(|s| Some(s.trim_end().to_string()))
|
||||
.ok_or_else(|| format!("`{path}.{field}` must be a string")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_color(value: &Value) -> Result<String, String> {
|
||||
let hex = value
|
||||
.as_str()
|
||||
.ok_or("must be a quoted hex colour such as \"#1B1C1E\"")?;
|
||||
let digits = hex.strip_prefix('#').ok_or_else(|| {
|
||||
format!("`{hex}` is not a hex colour; expected a leading `#`")
|
||||
})?;
|
||||
if !matches!(digits.len(), 3 | 4 | 6 | 8) || !digits.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(format!(
|
||||
"`{hex}` is not a hex colour; expected #RGB, #RGBA, #RRGGBB or #RRGGBBAA"
|
||||
));
|
||||
}
|
||||
Ok(hex.to_string())
|
||||
}
|
||||
|
||||
fn parse_length(value: &Value) -> Result<String, String> {
|
||||
let px = value
|
||||
.as_f64()
|
||||
.ok_or("must be a number of pixels, such as 12")?;
|
||||
if px.fract() == 0.0 {
|
||||
Ok(format!("{}px", px as i64))
|
||||
} else {
|
||||
Ok(format!("{px}px"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a block of prose as Slint comments at a given indent, keeping the
|
||||
/// author's own line breaks — the paragraphs in `style.yaml` are already
|
||||
/// wrapped to the width the codebase reads at.
|
||||
fn comment(text: &str, marker: &str, indent: usize) -> String {
|
||||
let pad = " ".repeat(indent);
|
||||
let mut out = String::new();
|
||||
for line in text.trim_end().lines() {
|
||||
if line.trim().is_empty() {
|
||||
writeln!(out, "{pad}{marker}").unwrap();
|
||||
} else {
|
||||
writeln!(out, "{pad}{marker} {line}").unwrap();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,387 @@
|
||||
//! TRACES: FR-CAT-3 | FR-CAT-7 | FR-NC-7
|
||||
//! Pushing derived state to Nextcloud: thumbnail shards and the catalog.
|
||||
//!
|
||||
//! # What travels, and why only this
|
||||
//!
|
||||
//! Sidecars are handled elsewhere ([`crate::library::spawn_sidecar_writes`])
|
||||
//! and are the *authoritative* store — they are the reason a catalog can be
|
||||
//! deleted and rebuilt (ARCH §6.12). What moves here is derived state that is
|
||||
//! merely expensive:
|
||||
//!
|
||||
//! - **Thumbnail shards.** A thumbnail costs a range fetch plus a decode, and
|
||||
//! is byte-identical for every client looking at the same file. A second
|
||||
//! device that downloads the shards gets a full grid without touching a
|
||||
//! single RAW — hours of indexing against a few hundred MB of transfer.
|
||||
//! - **The catalog**, for its collections. Every other thing the catalog holds
|
||||
//! has authoritative backing in a sidecar; a manually assembled collection
|
||||
//! does not, so without this it exists on one machine only.
|
||||
//!
|
||||
//! # Why sealed shards make this cheap
|
||||
//!
|
||||
//! A shard stops being written once it reaches its cap, and is never rewritten
|
||||
//! after — deleting a thumbnail tombstones it in the index rather than editing
|
||||
//! the sealed blob. So a client that has downloaded a sealed shard never needs
|
||||
//! to ask about it again, and an up-to-date client transfers only the index and
|
||||
//! whichever shard is currently open. That is the whole reason for sharding at
|
||||
//! 25 MB rather than keeping one growing file.
|
||||
//!
|
||||
//! # Where it lives
|
||||
//!
|
||||
//! Under the library root, in a dotted folder beside the trash. The root is the
|
||||
//! only place the user granted access to, and writing outside it may cross a
|
||||
//! share boundary the account cannot write to. The scanner excludes it by the
|
||||
//! same mechanism that excludes the trash.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
|
||||
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
||||
use dr_thumbs::ThumbStore;
|
||||
|
||||
/// Folder under the library root holding derived state.
|
||||
///
|
||||
/// Defined by the scanner, which must exclude it: a walk that indexed this
|
||||
/// folder would pay a listing for it on every sync of every device.
|
||||
pub use dr_sync::scan::DERIVED_DIR;
|
||||
|
||||
/// What a sync pass did, for logging and for telling the user.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SyncReport {
|
||||
pub shards_uploaded: usize,
|
||||
pub shards_downloaded: usize,
|
||||
pub thumbnails_adopted: usize,
|
||||
pub catalog_uploaded: bool,
|
||||
pub catalog_merged: bool,
|
||||
pub collections_gained: usize,
|
||||
}
|
||||
|
||||
impl SyncReport {
|
||||
pub fn did_anything(&self) -> bool {
|
||||
self.shards_uploaded > 0
|
||||
|| self.shards_downloaded > 0
|
||||
|| self.catalog_uploaded
|
||||
|| self.catalog_merged
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress from the sync worker.
|
||||
#[derive(Debug)]
|
||||
pub enum SyncMessage {
|
||||
Status(String),
|
||||
Finished(Box<SyncReport>),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Push shards and the catalog, and take anything newer from the server.
|
||||
///
|
||||
/// Runs on its own thread with its own runtime, like every other network path
|
||||
/// here — the Slint loop must never block (NFR-P9).
|
||||
pub fn spawn_sync(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
root: String,
|
||||
thumbs_dir: PathBuf,
|
||||
catalog_path: PathBuf,
|
||||
scratch: PathBuf,
|
||||
) -> std::sync::mpsc::Receiver<SyncMessage> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = tx.send(SyncMessage::Failed(e.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let _ = tx.send(SyncMessage::Failed(e.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match run(&backend, &root, &thumbs_dir, &catalog_path, &scratch, &tx).await {
|
||||
Ok(report) => {
|
||||
let _ = tx.send(SyncMessage::Finished(Box::new(report)));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(SyncMessage::Failed(e));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
rx
|
||||
}
|
||||
|
||||
async fn run(
|
||||
backend: &NextcloudBackend,
|
||||
root: &str,
|
||||
thumbs_dir: &Path,
|
||||
catalog_path: &Path,
|
||||
scratch: &Path,
|
||||
tx: &std::sync::mpsc::Sender<SyncMessage>,
|
||||
) -> Result<SyncReport, String> {
|
||||
let mut report = SyncReport::default();
|
||||
let base = derived_path(root);
|
||||
|
||||
// The folder may not exist on a first sync. Creating it unconditionally is
|
||||
// cheaper than probing, and an existing folder is not an error.
|
||||
let _ = backend.create_dir(&base).await;
|
||||
|
||||
let _ = tx.send(SyncMessage::Status("checking thumbnails…".into()));
|
||||
sync_shards(backend, &base, thumbs_dir, scratch, &mut report).await?;
|
||||
|
||||
let _ = tx.send(SyncMessage::Status("checking collections…".into()));
|
||||
sync_catalog(backend, &base, catalog_path, scratch, &mut report).await?;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// The derived folder for a library root.
|
||||
fn derived_path(root: &str) -> RemotePath {
|
||||
if root.is_empty() {
|
||||
RemotePath::new(DERIVED_DIR)
|
||||
} else {
|
||||
RemotePath::new(format!("{root}/{DERIVED_DIR}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchange thumbnail shards with the server.
|
||||
///
|
||||
/// Upload what the server lacks, download what we lack. Sealed shards are
|
||||
/// immutable, so a name match is a content match and nothing needs comparing
|
||||
/// beyond existence — which is what keeps a steady-state sync to one listing.
|
||||
async fn sync_shards(
|
||||
backend: &NextcloudBackend,
|
||||
base: &RemotePath,
|
||||
thumbs_dir: &Path,
|
||||
scratch: &Path,
|
||||
report: &mut SyncReport,
|
||||
) -> Result<(), String> {
|
||||
let store = match ThumbStore::open(thumbs_dir) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// No local store is not a failure: a fresh device has nothing to
|
||||
// upload and everything to gain from downloading.
|
||||
log::debug!("thumbnail store unavailable: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let remote: std::collections::HashMap<String, u64> = backend
|
||||
.list(base, None)
|
||||
.await
|
||||
.map(|entries| {
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|e| e.kind == dr_sync::EntryKind::File)
|
||||
.map(|e| (e.path.name().to_string(), e.size))
|
||||
.collect()
|
||||
})
|
||||
// A missing folder lists as an error on some servers; treat it as empty
|
||||
// rather than aborting a first sync.
|
||||
.unwrap_or_default();
|
||||
|
||||
let local = store.shards().map_err(|e| e.to_string())?;
|
||||
|
||||
// ---- upload ----------------------------------------------------------
|
||||
for shard in &local {
|
||||
let path = store.shard_path(shard.id);
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
let name = shard_name(shard.id);
|
||||
|
||||
// A sealed shard the server already has is byte-identical by
|
||||
// construction, so its presence is proof enough. The open shard is
|
||||
// re-uploaded whenever its size differs, which is the only way it
|
||||
// changes.
|
||||
let skip = match remote.get(&name) {
|
||||
Some(_) if shard.sealed => true,
|
||||
Some(size) => *size == bytes.len() as u64,
|
||||
None => false,
|
||||
};
|
||||
if skip {
|
||||
continue;
|
||||
}
|
||||
|
||||
let target = RemotePath::new(format!("{}/{name}", base.as_str()));
|
||||
match backend.put(&target, bytes, None).await {
|
||||
Ok(_) => report.shards_uploaded += 1,
|
||||
// One shard failing must not abort the rest: they are independent
|
||||
// and the next pass retries.
|
||||
Err(e) => log::warn!("uploading {name}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- download --------------------------------------------------------
|
||||
let have: std::collections::HashSet<u32> = local.iter().map(|s| s.id).collect();
|
||||
let mut store = store;
|
||||
|
||||
for (name, _) in &remote {
|
||||
let Some(id) = shard_id(name) else { continue };
|
||||
if have.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let source = RemotePath::new(format!("{}/{name}", base.as_str()));
|
||||
let bytes = match backend.get(&RemoteId::Path(source), None).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!("downloading {name}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Written to scratch and merged, rather than dropped into the store
|
||||
// directory: a downloaded shard's *id* is the other device's numbering,
|
||||
// and two devices independently fill shard 0.
|
||||
let tmp = scratch.join(name);
|
||||
if std::fs::write(&tmp, &bytes).is_err() {
|
||||
continue;
|
||||
}
|
||||
match store.merge_shard(&tmp) {
|
||||
Ok(n) => {
|
||||
report.shards_downloaded += 1;
|
||||
report.thumbnails_adopted += n;
|
||||
}
|
||||
Err(e) => log::warn!("merging {name}: {e}"),
|
||||
}
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exchange the catalog, for its collections.
|
||||
///
|
||||
/// Only collections merge — see [`dr_catalog::sync`]. The rest of a catalog
|
||||
/// describes local state (folder ETags, cache paths, job rows) and importing
|
||||
/// another device's version would be actively wrong.
|
||||
async fn sync_catalog(
|
||||
backend: &NextcloudBackend,
|
||||
base: &RemotePath,
|
||||
catalog_path: &Path,
|
||||
scratch: &Path,
|
||||
report: &mut SyncReport,
|
||||
) -> Result<(), String> {
|
||||
let remote_name = "catalog.sqlite";
|
||||
let target = RemotePath::new(format!("{}/{remote_name}", base.as_str()));
|
||||
|
||||
// ---- take theirs first -----------------------------------------------
|
||||
//
|
||||
// Merging before uploading means our upload carries the union rather than
|
||||
// only our own half, so a third device syncing next gets everything in one
|
||||
// fetch.
|
||||
if let Ok(bytes) = backend.get(&RemoteId::Path(target.clone()), None).await {
|
||||
let downloaded = scratch.join("catalog-remote.sqlite");
|
||||
if std::fs::write(&downloaded, &bytes).is_ok() {
|
||||
match dr_catalog::Catalog::open(catalog_path) {
|
||||
Ok(catalog) => match catalog.merge_remote_catalog(&downloaded) {
|
||||
Ok(merge) => {
|
||||
report.catalog_merged = true;
|
||||
report.collections_gained = merge.inserted + merge.updated;
|
||||
}
|
||||
Err(e) => log::warn!("merging remote catalog: {e}"),
|
||||
},
|
||||
Err(e) => log::warn!("opening catalog to merge: {e}"),
|
||||
}
|
||||
let _ = std::fs::remove_file(&downloaded);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- then push ours --------------------------------------------------
|
||||
//
|
||||
// Never the live file: committed transactions can sit in the `-wal` with
|
||||
// the main file lagging, so copying it uploads a torn snapshot. The backup
|
||||
// API serialises against writers instead of racing them.
|
||||
let snapshot = scratch.join("catalog-upload.sqlite");
|
||||
let catalog = dr_catalog::Catalog::open(catalog_path).map_err(|e| e.to_string())?;
|
||||
catalog
|
||||
.snapshot_for_upload(&snapshot)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let bytes = std::fs::read(&snapshot).map_err(|e| e.to_string())?;
|
||||
match backend.put(&target, bytes, None).await {
|
||||
Ok(_) => report.catalog_uploaded = true,
|
||||
Err(e) => log::warn!("uploading catalog: {e}"),
|
||||
}
|
||||
let _ = std::fs::remove_file(&snapshot);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shard_name(id: u32) -> String {
|
||||
format!("shard-{id:04}.sqlite")
|
||||
}
|
||||
|
||||
/// The shard id in a filename, or `None` if it is not a shard.
|
||||
///
|
||||
/// Guards the download loop against adopting the catalog, a stray file, or
|
||||
/// anything else the folder happens to contain.
|
||||
fn shard_id(name: &str) -> Option<u32> {
|
||||
name.strip_prefix("shard-")?
|
||||
.strip_suffix(".sqlite")?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn derived_folder_sits_under_the_library_root() {
|
||||
// Outside the root the account may not have write access — the root is
|
||||
// the only thing the user granted.
|
||||
assert_eq!(
|
||||
derived_path("PhotosRaw").as_str(),
|
||||
"PhotosRaw/.darkroom-derived"
|
||||
);
|
||||
// A library at the account root still gets a relative path.
|
||||
assert_eq!(derived_path("").as_str(), ".darkroom-derived");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shard_names_round_trip() {
|
||||
assert_eq!(shard_name(0), "shard-0000.sqlite");
|
||||
assert_eq!(shard_name(42), "shard-0042.sqlite");
|
||||
assert_eq!(shard_id("shard-0042.sqlite"), Some(42));
|
||||
assert_eq!(shard_id(&shard_name(7)), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_shard_files_are_not_adopted() {
|
||||
// The folder also holds the catalog; downloading it as a shard would
|
||||
// hand a catalog to the thumbnail merger.
|
||||
assert_eq!(shard_id("catalog.sqlite"), None);
|
||||
assert_eq!(shard_id("shard-0000.sqlite-wal"), None);
|
||||
assert_eq!(shard_id("notes.txt"), None);
|
||||
assert_eq!(shard_id("shard-abc.sqlite"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_report_that_did_nothing_says_so() {
|
||||
assert!(!SyncReport::default().did_anything());
|
||||
assert!(SyncReport {
|
||||
shards_uploaded: 1,
|
||||
..Default::default()
|
||||
}
|
||||
.did_anything());
|
||||
// Adopting thumbnails without moving a shard cannot happen, but the
|
||||
// report must not claim work on collections alone either.
|
||||
assert!(SyncReport {
|
||||
catalog_merged: true,
|
||||
..Default::default()
|
||||
}
|
||||
.did_anything());
|
||||
}
|
||||
}
|
||||
+298
-20
@@ -31,12 +31,36 @@ impl DevelopSession {
|
||||
pub fn open(ctx: &GpuContext, raw: &RawImage) -> Result<Self, String> {
|
||||
let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?;
|
||||
let demosaiced = demosaicer.run(raw).map_err(|e| e.to_string())?;
|
||||
Ok(Self::with_source(ctx, demosaiced))
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
/// Prepare an edit graph over an already-processed RGB image.
|
||||
///
|
||||
/// The JPEG path. A JPEG is already demosaiced, so there is no sensor
|
||||
/// stage to run — but everything after it is identical, which is why this
|
||||
/// shares [`Self::with_source`] rather than duplicating the session.
|
||||
///
|
||||
/// Worth being honest about what this cannot recover: an 8-bit JPEG has
|
||||
/// clipped highlights and quantised shadows that no edit brings back, so
|
||||
/// exposure has far less latitude here than on sensor data. The controls
|
||||
/// are the same controls; the file simply carries less to work with.
|
||||
pub fn open_rgb(
|
||||
ctx: &GpuContext,
|
||||
rgba: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<Self, String> {
|
||||
let source =
|
||||
DemosaicedImage::from_rgba8(ctx, rgba, width, height).map_err(|e| e.to_string())?;
|
||||
Ok(Self::with_source(ctx, source))
|
||||
}
|
||||
|
||||
fn with_source(ctx: &GpuContext, demosaiced: DemosaicedImage) -> Self {
|
||||
Self {
|
||||
graph: EditGraph::default_chain(),
|
||||
demosaiced,
|
||||
adjust: AdjustPass::new(ctx),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The controls the interface should show.
|
||||
@@ -46,6 +70,9 @@ impl DevelopSession {
|
||||
pub fn rows(&self) -> Vec<ParamRow> {
|
||||
let mut rows = Vec::new();
|
||||
for (op_index, op) in self.graph.capabilities().iter().enumerate() {
|
||||
// Where this operation's rows begin. The panel groups by walking
|
||||
// back to it, so it has to be taken before any row is pushed.
|
||||
let group_head = rows.len();
|
||||
// An operation may ask for one widget spanning several
|
||||
// parameters. Honouring it is optional — dropping this block
|
||||
// renders the same parameters as ordinary sliders, and the edit
|
||||
@@ -55,7 +82,7 @@ impl DevelopSession {
|
||||
// kind is added, this stops compiling until it is handled,
|
||||
// rather than silently falling through to sliders.
|
||||
let row = match presentation.widget {
|
||||
WidgetKind::Curve => self.curve_row(op_index, op, presentation),
|
||||
WidgetKind::Curve => self.curve_row(op_index, group_head, op, presentation),
|
||||
};
|
||||
if let Some(row) = row {
|
||||
rows.push(row);
|
||||
@@ -63,6 +90,17 @@ impl DevelopSession {
|
||||
}
|
||||
}
|
||||
|
||||
// Whether anything in this operation has been touched, aggregated
|
||||
// before the rows are built so every row of the group can carry
|
||||
// the same answer — the panel's heading is one of them and cannot
|
||||
// see the others.
|
||||
//
|
||||
// Derived here rather than asked of the core: a group is a
|
||||
// composition this side invented, so whether one is modified is
|
||||
// this side's question to answer (ARCH §4.3a).
|
||||
let group_modified = op.params.iter().any(|p| p.value != p.default);
|
||||
let group_len = op.params.len() as i32;
|
||||
|
||||
for (param_index, p) in op.params.iter().enumerate() {
|
||||
let (kind, min, max, precision, unit) = match &p.kind {
|
||||
ParamKind::Scalar {
|
||||
@@ -86,9 +124,9 @@ impl DevelopSession {
|
||||
param_index: param_index as i32,
|
||||
op_label: labels::resolve(op.label.0).into(),
|
||||
param_label: labels::resolve(p.label.0).into(),
|
||||
// The panel draws a heading wherever this is set, without
|
||||
// needing to know what an operation is.
|
||||
starts_group: param_index == 0,
|
||||
group_head: group_head as i32,
|
||||
group_len,
|
||||
group_modified,
|
||||
kind: kind.into(),
|
||||
value: p.value,
|
||||
default_value: p.default,
|
||||
@@ -112,12 +150,13 @@ impl DevelopSession {
|
||||
fn curve_row(
|
||||
&self,
|
||||
op_index: usize,
|
||||
group_head: usize,
|
||||
op: &OpCapability,
|
||||
presentation: &Presentation,
|
||||
) -> Option<ParamRow> {
|
||||
// Points are x/y pairs, so an odd count means the operation and this
|
||||
// code disagree about the layout.
|
||||
if presentation.params.len() < 2 || presentation.params.len() % 2 != 0 {
|
||||
if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) {
|
||||
log::warn!("{}: curve widget needs an even parameter count", op.id);
|
||||
return None;
|
||||
}
|
||||
@@ -148,7 +187,11 @@ impl DevelopSession {
|
||||
param_index: base as i32,
|
||||
op_label: labels::resolve(op.label.0).into(),
|
||||
param_label: String::new().into(),
|
||||
starts_group: true,
|
||||
group_head: group_head as i32,
|
||||
// One widget standing for every parameter of the operation, so
|
||||
// the group it heads is itself and nothing else.
|
||||
group_len: 1,
|
||||
group_modified: op.params.iter().any(|p| p.value != p.default),
|
||||
kind: "curve".into(),
|
||||
value: 0.0,
|
||||
default_value: 0.0,
|
||||
@@ -207,8 +250,14 @@ impl DevelopSession {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return every point of a curve operation to its default.
|
||||
pub fn reset_curve(&mut self, op_index: i32) {
|
||||
/// Return every parameter of one operation to its default.
|
||||
///
|
||||
/// What both a section's reset and a curve's reset do — a curve is one
|
||||
/// widget spanning all of its operation's parameters, so "reset this
|
||||
/// curve" and "reset this operation" were always the same action. Nothing
|
||||
/// here is curve-shaped; it walks whatever parameters the operation
|
||||
/// declares.
|
||||
pub fn reset_op(&mut self, op_index: i32) {
|
||||
let caps = self.graph.capabilities();
|
||||
let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
|
||||
return;
|
||||
@@ -218,6 +267,15 @@ impl DevelopSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset a curve, which is to reset its operation.
|
||||
///
|
||||
/// Kept as its own name because the call site is a curve widget's own
|
||||
/// double-click, and reading `reset_curve` there says why it resets ten
|
||||
/// parameters at once rather than the one that was clicked.
|
||||
pub fn reset_curve(&mut self, op_index: i32) {
|
||||
self.reset_op(op_index);
|
||||
}
|
||||
|
||||
/// Apply a change from the interface.
|
||||
///
|
||||
/// Indices are positions in [`Self::rows`]; the mapping back to ids stays
|
||||
@@ -290,6 +348,46 @@ impl DevelopSession {
|
||||
Ok(slint::Image::from_rgba8(buffer))
|
||||
}
|
||||
|
||||
/// Render the *whole* frame for the crop overlay to be drawn over.
|
||||
///
|
||||
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
|
||||
/// area being cropped away would not be on screen and there would be
|
||||
/// nothing to drag the handles across. This renders as though the crop
|
||||
/// were full, and the interface draws the rect and greys the surround.
|
||||
///
|
||||
/// Zoom is suspended too. Panning a zoomed view while also dragging crop
|
||||
/// handles is two conflicting meanings for one drag, and the handles are
|
||||
/// placed against the whole frame in any case.
|
||||
///
|
||||
/// Returns the image together with the size it was rendered at, since the
|
||||
/// overlay has to place its rect against exactly those pixels.
|
||||
pub fn render_uncropped(
|
||||
&mut self,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<(slint::Image, u32, u32), String> {
|
||||
let saved_crop = self.graph.crop();
|
||||
let saved_view = self.graph.framing().view();
|
||||
|
||||
self.graph.set_crop(CropRect::default());
|
||||
self.graph.framing_mut().set_view(CropRect::default());
|
||||
|
||||
let result = self.render(width, height);
|
||||
|
||||
// Restored whatever happened: leaving the graph cropped-to-full on a
|
||||
// render error would silently discard the user's crop.
|
||||
self.graph.set_crop(saved_crop);
|
||||
self.graph.framing_mut().set_view(saved_view);
|
||||
|
||||
let image = result?;
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
// The uncropped frame still turns with the quarter turns, so the
|
||||
// overlay's box comes from the framing rather than the sensor.
|
||||
let (fw, fh) = self.graph.framing().output_size_uncropped(sw, sh);
|
||||
let (rw, rh) = fit(fw, fh, width.max(1), height.max(1));
|
||||
Ok((image, rw, rh))
|
||||
}
|
||||
|
||||
/// The displayed size, for sizing the viewport.
|
||||
///
|
||||
/// The *framed* size, not the sensor's: cropping and quarter turns change
|
||||
@@ -322,6 +420,87 @@ impl DevelopSession {
|
||||
self.graph.rotate_quarters(turns);
|
||||
}
|
||||
|
||||
/// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×.
|
||||
pub fn zoom(&self) -> f32 {
|
||||
let v = self.graph.framing().view();
|
||||
if v.width <= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
1.0 / v.width
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_zoomed(&self) -> bool {
|
||||
self.graph.framing().is_zoomed()
|
||||
}
|
||||
|
||||
/// Zoom about a point, given in fractions of the *visible* area.
|
||||
///
|
||||
/// Anchoring matters: zooming about the pointer keeps whatever is under
|
||||
/// it stationary, which is what makes a scroll-wheel zoom feel like it is
|
||||
/// magnifying the photograph rather than sliding it around.
|
||||
///
|
||||
/// `factor` multiplies the current zoom — above 1 moves in.
|
||||
pub fn zoom_about(&mut self, factor: f32, at_x: f32, at_y: f32) {
|
||||
const MAX_ZOOM: f32 = 16.0;
|
||||
|
||||
let view = self.graph.framing().view();
|
||||
let current = if view.width > 0.0 {
|
||||
1.0 / view.width
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let target = (current * factor).clamp(1.0, MAX_ZOOM);
|
||||
// Snapped so scrolling back out reliably reaches "fit" rather than
|
||||
// stopping a fraction short and leaving the image imperceptibly
|
||||
// panned.
|
||||
let target = if (target - 1.0).abs() < 0.01 {
|
||||
1.0
|
||||
} else {
|
||||
target
|
||||
};
|
||||
|
||||
let extent = (1.0 / target).clamp(CropRect::MIN_EXTENT, 1.0);
|
||||
|
||||
// The point under the cursor, in framed coordinates, must land back
|
||||
// under the cursor afterwards.
|
||||
let anchor_x = view.x + at_x.clamp(0.0, 1.0) * view.width;
|
||||
let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height;
|
||||
|
||||
self.set_view_clamped(
|
||||
anchor_x - at_x.clamp(0.0, 1.0) * extent,
|
||||
anchor_y - at_y.clamp(0.0, 1.0) * extent,
|
||||
extent,
|
||||
);
|
||||
}
|
||||
|
||||
/// Pan by a fraction of the *visible* area — what a drag reports.
|
||||
pub fn pan_by(&mut self, dx: f32, dy: f32) {
|
||||
let view = self.graph.framing().view();
|
||||
self.set_view_clamped(view.x + dx * view.width, view.y + dy * view.height, view.width);
|
||||
}
|
||||
|
||||
/// Back to fitting the whole frame.
|
||||
pub fn reset_zoom(&mut self) {
|
||||
self.graph.framing_mut().set_view(CropRect::default());
|
||||
}
|
||||
|
||||
/// Place a square view of `extent`, keeping it inside the frame.
|
||||
///
|
||||
/// Clamped rather than allowed to run off the edge: panning past the
|
||||
/// boundary would show undefined area beside the photograph, which reads
|
||||
/// as a rendering fault rather than as the end of the image.
|
||||
fn set_view_clamped(&mut self, x: f32, y: f32, extent: f32) {
|
||||
let extent = extent.clamp(CropRect::MIN_EXTENT, 1.0);
|
||||
let max = 1.0 - extent;
|
||||
self.graph.framing_mut().set_view(CropRect {
|
||||
x: x.clamp(0.0, max.max(0.0)),
|
||||
y: y.clamp(0.0, max.max(0.0)),
|
||||
width: extent,
|
||||
height: extent,
|
||||
});
|
||||
}
|
||||
|
||||
/// The largest centred crop that, at the current straightening angle,
|
||||
/// contains no undefined area. What a "straighten and fill" action
|
||||
/// applies.
|
||||
@@ -421,20 +600,119 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_operation_starts_exactly_one_group() {
|
||||
// The panel draws a heading per group; two groups for one operation
|
||||
// would duplicate the heading, none would merge two operations under
|
||||
// one.
|
||||
fn each_operation_becomes_exactly_one_group() {
|
||||
// The panel draws one section per group, and derives the boundary
|
||||
// from `group_head` rather than from a flag the core supplies. Two
|
||||
// heads for one operation would draw its heading twice; none would
|
||||
// swallow the operation into the section above it.
|
||||
let graph = EditGraph::default_chain();
|
||||
let mut groups = 0;
|
||||
for op in graph.capabilities() {
|
||||
for (i, _) in op.params.iter().enumerate() {
|
||||
if i == 0 {
|
||||
groups += 1;
|
||||
let caps = graph.capabilities();
|
||||
|
||||
// A row heads its group exactly when its own index equals its
|
||||
// `group_head` — the same test `adjust.slint` makes.
|
||||
let mut heads = 0;
|
||||
for (i, row) in rows_of(&caps).iter().enumerate() {
|
||||
if row.0 == i {
|
||||
heads += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(heads, caps.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_spans_exactly_its_operations_rows() {
|
||||
// `group_len` is how many rows the section reaches forward over. Too
|
||||
// few silently drops controls off the bottom of a section; too many
|
||||
// reads past the model and renders a neighbouring operation's
|
||||
// parameters under the wrong heading.
|
||||
let graph = EditGraph::default_chain();
|
||||
let caps = graph.capabilities();
|
||||
let rows = rows_of(&caps);
|
||||
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let (head, len) = *row;
|
||||
assert!(head <= i, "row {i} claims a head after itself");
|
||||
assert!(
|
||||
head + len <= rows.len(),
|
||||
"group at {head} reaches past the model"
|
||||
);
|
||||
// Every row the group spans must agree it belongs to that group.
|
||||
for span in head..head + len {
|
||||
assert_eq!(rows[span].0, head, "row {span} disagrees about its group");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(groups, graph.capabilities().len());
|
||||
|
||||
#[test]
|
||||
fn a_group_is_modified_when_any_of_its_parameters_is() {
|
||||
// The dot on a collapsed section is the only thing saying an edit is
|
||||
// hidden inside it, and it is derived here rather than asked of the
|
||||
// core (ARCH §4.3a).
|
||||
let mut graph = EditGraph::default_chain();
|
||||
let caps = graph.capabilities();
|
||||
// A fresh chain is at its defaults, so nothing is modified.
|
||||
assert!(
|
||||
caps.iter()
|
||||
.all(|c| c.params.iter().all(|p| p.value == p.default)),
|
||||
"a fresh chain must start neutral"
|
||||
);
|
||||
|
||||
// Move one parameter of one operation off its default; only that
|
||||
// operation's group may light up.
|
||||
let (op_id, param_id, default) = caps
|
||||
.iter()
|
||||
.find_map(|c| {
|
||||
c.params
|
||||
.iter()
|
||||
.find(|p| matches!(p.kind, ParamKind::Scalar { .. }))
|
||||
.map(|p| (c.id, p.id, p.default))
|
||||
})
|
||||
.expect("the chain has a scalar parameter");
|
||||
graph.set_param(op_id, param_id, default + 1.0);
|
||||
|
||||
let caps = graph.capabilities();
|
||||
let modified: Vec<bool> = caps
|
||||
.iter()
|
||||
.map(|c| c.params.iter().any(|p| p.value != p.default))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
modified.iter().filter(|m| **m).count(),
|
||||
1,
|
||||
"one edit must mark exactly one group"
|
||||
);
|
||||
|
||||
// And it goes out again when the value returns.
|
||||
graph.set_param(op_id, param_id, default);
|
||||
assert!(
|
||||
graph
|
||||
.capabilities()
|
||||
.iter()
|
||||
.all(|c| c.params.iter().all(|p| p.value == p.default)),
|
||||
"returning a value to its default must clear the group"
|
||||
);
|
||||
}
|
||||
|
||||
/// `(group_head, group_len)` per row, flattened as
|
||||
/// [`DevelopSession::rows`] flattens — without needing a GPU to build a
|
||||
/// session.
|
||||
///
|
||||
/// A widget hint only collapses an operation to one row when it is
|
||||
/// *honoured*; `rows` falls back to sliders otherwise, and mirroring that
|
||||
/// here is what keeps the test honest when a hint stops applying.
|
||||
fn rows_of(caps: &[OpCapability]) -> Vec<(usize, usize)> {
|
||||
let mut rows = Vec::new();
|
||||
for op in caps {
|
||||
let head = rows.len();
|
||||
let collapses = op
|
||||
.presentation
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.params.len() == op.params.len());
|
||||
let len = if collapses { 1 } else { op.params.len() };
|
||||
for _ in 0..len {
|
||||
rows.push((head, len));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -29,6 +29,23 @@ pub enum LaunchState {
|
||||
},
|
||||
}
|
||||
|
||||
/// What the app opens on.
|
||||
///
|
||||
/// Three outcomes, and the middle one is easy to lose: a configured library
|
||||
/// means the launch screen is skipped, and skipping it must not also skip
|
||||
/// opening the library — otherwise the app lands on an empty view with no
|
||||
/// route back to the grid, because the "Open library" button is on the screen
|
||||
/// that was never shown.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Startup {
|
||||
/// Files were named on the command line; show those.
|
||||
ShowLocalFiles,
|
||||
/// An account and a folder are configured; scan and show the grid.
|
||||
OpenLibrary,
|
||||
/// Nothing configured; ask the user to sign in.
|
||||
ShowLaunchScreen,
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-1 | FR-NC-4 | M-1 | M-3 | M-4
|
||||
/// Everything the launch screen renders from.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -197,6 +214,23 @@ impl LaunchModel {
|
||||
self.is_signed_in() && !self.library_root().is_empty()
|
||||
}
|
||||
|
||||
/// What the app should do on startup.
|
||||
///
|
||||
/// Pure so it can be tested without a secret store or a display server —
|
||||
/// and it needs testing, because the interesting case is the one with no
|
||||
/// visible symptom until you are staring at an empty window.
|
||||
pub fn startup_action(&self, have_local_paths: bool) -> Startup {
|
||||
if have_local_paths {
|
||||
// Files named on the command line win: the user asked for those
|
||||
// specifically, not for their library.
|
||||
Startup::ShowLocalFiles
|
||||
} else if self.can_open_library() {
|
||||
Startup::OpenLibrary
|
||||
} else {
|
||||
Startup::ShowLaunchScreen
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_filter(&self) -> FormatFilter {
|
||||
FormatFilter::from_formats(self.formats.iter().filter(|(_, on)| *on).map(|(f, _)| *f))
|
||||
}
|
||||
@@ -393,6 +427,38 @@ mod tests {
|
||||
assert!(m.can_open_library());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_configured_library_opens_rather_than_showing_nothing() {
|
||||
// The regression this guards: skipping the launch screen because a
|
||||
// library is configured used to mean the library was never opened,
|
||||
// since `on_open_library` only fires from a button nobody saw.
|
||||
let mut m = LaunchModel::default();
|
||||
m.signed_in(session_with_root("PhotosRaw"));
|
||||
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_configuration_shows_the_launch_screen() {
|
||||
let m = LaunchModel::default();
|
||||
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signed_in_account_without_a_folder_still_needs_the_screen() {
|
||||
// Opening without a chosen folder would scan the whole account.
|
||||
let mut m = LaunchModel::default();
|
||||
m.signed_in(session_with_root(""));
|
||||
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_line_files_win_over_a_configured_library() {
|
||||
// The user asked for those files specifically.
|
||||
let mut m = LaunchModel::default();
|
||||
m.signed_in(session_with_root("PhotosRaw"));
|
||||
assert_eq!(m.startup_action(true), Startup::ShowLocalFiles);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_never_strands_the_screen_in_busy() {
|
||||
let mut m = LaunchModel::default();
|
||||
|
||||
@@ -37,14 +37,6 @@ impl LaunchController {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the app should open on the launch screen.
|
||||
///
|
||||
/// Only when there is nothing to show: a configured library goes straight
|
||||
/// to the images, since making someone click past a login screen they
|
||||
/// already completed is pure friction.
|
||||
pub fn should_show(&self, have_local_paths: bool) -> bool {
|
||||
!have_local_paths && !self.model.borrow().can_open_library()
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the model into the window's properties.
|
||||
|
||||
+467
-34
@@ -14,8 +14,15 @@
|
||||
//! 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 collections_ui;
|
||||
mod derived_sync;
|
||||
mod develop;
|
||||
mod labels;
|
||||
mod library;
|
||||
mod library_ui;
|
||||
mod trash;
|
||||
#[cfg(live_style)]
|
||||
mod live_style;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -48,10 +55,10 @@ const EXPANDED_MIN_WIDTH: f32 = 820.0;
|
||||
|
||||
/// Everything loaded for the currently displayed image.
|
||||
struct Loaded {
|
||||
/// A develop session where the file could be decoded to sensor data.
|
||||
/// `None` for a JPEG or a body rawler cannot decode, in which case
|
||||
/// `fallback` carries an embedded preview and the adjust panel is
|
||||
/// disabled rather than shown doing nothing.
|
||||
/// A develop session. `None` only where the file could not be opened for
|
||||
/// editing at all — a body rawler cannot decode, or a corrupt JPEG — in
|
||||
/// which case `fallback` carries an embedded preview and the adjust panel
|
||||
/// is disabled rather than shown doing nothing.
|
||||
session: Option<DevelopSession>,
|
||||
fallback: Option<slint::Image>,
|
||||
meta: Metadata,
|
||||
@@ -77,14 +84,55 @@ fn load(ctx: Option<&dr_gpu::GpuContext>, path: &Path) -> Result<Loaded, String>
|
||||
}
|
||||
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let meta = dr_decode::metadata(&bytes).unwrap_or_default();
|
||||
load_bytes(ctx, &bytes)
|
||||
}
|
||||
|
||||
/// Open already-fetched bytes.
|
||||
///
|
||||
/// Split from [`load`] because a library image has no local file: it arrives
|
||||
/// as a WebDAV response body, and writing it to disk purely to read it back
|
||||
/// would be a round-trip for nothing.
|
||||
fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result<Loaded, String> {
|
||||
let meta = dr_decode::metadata(bytes).unwrap_or_default();
|
||||
|
||||
// Route by what the bytes actually are, not by extension (M-9).
|
||||
//
|
||||
// A JPEG has no sensor data and never will, so trying the RAW decoder
|
||||
// first would be a guaranteed failure whose log line reads like a fault.
|
||||
// It goes straight to the RGB path instead, which is what makes develop
|
||||
// mode work on the JPEGs the library already indexes.
|
||||
let is_jpeg = dr_decode::probe(bytes) == Some(dr_types::Format::Jpeg);
|
||||
|
||||
// Try sensor data first. A failure here is expected for JPEGs and for
|
||||
// bodies rawler does not know, and must not stop the image displaying
|
||||
// (FR-RAW-4).
|
||||
if let Some(ctx) = ctx {
|
||||
match dr_decode::decode(&bytes) {
|
||||
Ok(raw) => match DevelopSession::open(ctx, &raw) {
|
||||
let opened = if is_jpeg {
|
||||
dr_decode::decode_jpeg(bytes)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|mut p| {
|
||||
// Fit the device before uploading. A film scan runs to
|
||||
// 13728×8928, well past the 8192 a typical GPU can hold,
|
||||
// and refusing it would drop the image back to a
|
||||
// read-only preview — the very thing this path exists to
|
||||
// avoid. 8192 is still four times a 4K long edge.
|
||||
let limit = dr_gpu::DemosaicedImage::max_dimension(ctx);
|
||||
if p.width.max(p.height) > limit {
|
||||
log::info!(
|
||||
"{}×{} exceeds the {limit} texture limit; fitting to it",
|
||||
p.width,
|
||||
p.height
|
||||
);
|
||||
p.downscale_to(limit);
|
||||
}
|
||||
DevelopSession::open_rgb(ctx, &p.rgba, p.width, p.height)
|
||||
})
|
||||
} else {
|
||||
// A failure here is expected for bodies rawler does not know, and
|
||||
// must not stop the image displaying (FR-RAW-4).
|
||||
dr_decode::decode(bytes)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|raw| DevelopSession::open(ctx, &raw))
|
||||
};
|
||||
|
||||
match opened {
|
||||
Ok(session) => {
|
||||
let (width, height) = session.source_size();
|
||||
return Ok(Loaded {
|
||||
@@ -96,14 +144,13 @@ fn load(ctx: Option<&dr_gpu::GpuContext>, path: &Path) -> Result<Loaded, String>
|
||||
});
|
||||
}
|
||||
Err(e) => log::info!("develop unavailable, showing preview: {e}"),
|
||||
},
|
||||
Err(e) => log::info!("no sensor data ({e}); showing preview"),
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the embedded preview, which is all a JPEG has anyway.
|
||||
// Fall back to the embedded preview: no GPU, or a file neither decoder
|
||||
// could open for editing. Read-only, and the adjust panel is disabled.
|
||||
let mut preview =
|
||||
dr_decode::extract_preview(&bytes, PreviewSize::Screen).map_err(|e| e.to_string())?;
|
||||
dr_decode::extract_preview(bytes, PreviewSize::Screen).map_err(|e| e.to_string())?;
|
||||
preview.downscale_to(MAX_DISPLAY_DIM);
|
||||
|
||||
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(
|
||||
@@ -162,6 +209,21 @@ fn is_supported(p: &Path) -> bool {
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Return the view to its opening state for a newly loaded image.
|
||||
///
|
||||
/// Zoom and crop mode are properties of *looking at one photograph*, so
|
||||
/// carrying them to the next one would leave the second image cropped to a
|
||||
/// rect chosen for the first.
|
||||
fn reset_view_state(window: &AppWindow) {
|
||||
window.set_crop_mode(false);
|
||||
window.set_zoom(1.0);
|
||||
window.set_zoomed(false);
|
||||
window.set_crop_x(0.0);
|
||||
window.set_crop_y(0.0);
|
||||
window.set_crop_w(1.0);
|
||||
window.set_crop_h(1.0);
|
||||
}
|
||||
|
||||
/// Push current parameter values back to the interface.
|
||||
///
|
||||
/// The controls are not self-updating: the core clamps values, so what the
|
||||
@@ -186,10 +248,27 @@ fn sync_rows(
|
||||
};
|
||||
|
||||
if current.len() == rows.row_count() {
|
||||
for (i, row) in current.into_iter().enumerate() {
|
||||
for (i, mut row) in current.into_iter().enumerate() {
|
||||
let existing = rows.row_data(i);
|
||||
|
||||
// A curve row carries a *nested* model of point coordinates, and
|
||||
// `rows()` builds a fresh one each call. Swapping it in would
|
||||
// destroy the point elements — including the `TouchArea` holding
|
||||
// the current drag — so the existing model is kept and its values
|
||||
// written through instead.
|
||||
//
|
||||
// It also makes the equality test below meaningful: `ModelRc`
|
||||
// compares by identity, so a brand-new points model would make
|
||||
// every curve row look changed on every event.
|
||||
if let Some(previous) = existing.as_ref() {
|
||||
if update_points_in_place(&previous.points, &row.points) {
|
||||
row.points = previous.points.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// Only touch rows that actually changed, so unrelated controls
|
||||
// are not needlessly invalidated.
|
||||
if rows.row_data(i).as_ref() != Some(&row) {
|
||||
if existing.as_ref() != Some(&row) {
|
||||
rows.set_row_data(i, row);
|
||||
}
|
||||
}
|
||||
@@ -204,30 +283,168 @@ fn sync_rows(
|
||||
window.set_curve_samples(slint::ModelRc::new(slint::VecModel::from(samples)));
|
||||
}
|
||||
|
||||
/// Copy `fresh`'s values into `existing`, keeping the model identity.
|
||||
///
|
||||
/// Returns `false` where the two differ in length, in which case the caller
|
||||
/// must take the new model wholesale — the control set itself has changed and
|
||||
/// there is no drag worth preserving.
|
||||
fn update_points_in_place(
|
||||
existing: &slint::ModelRc<f32>,
|
||||
fresh: &slint::ModelRc<f32>,
|
||||
) -> bool {
|
||||
use slint::Model as _;
|
||||
|
||||
if existing.row_count() != fresh.row_count() {
|
||||
return false;
|
||||
}
|
||||
for i in 0..fresh.row_count() {
|
||||
let (Some(new), Some(old)) = (fresh.row_data(i), existing.row_data(i)) else {
|
||||
continue;
|
||||
};
|
||||
// Guarded so an unchanged coordinate does not invalidate its element
|
||||
// — the same reasoning as the row-level check above.
|
||||
if new != old {
|
||||
existing.set_row_data(i, new);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// TRACES: M-13 | M-14
|
||||
/// Build and run the viewer.
|
||||
pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let entries = Rc::new(collect(&paths));
|
||||
log::info!("{} image(s) to browse", entries.len());
|
||||
// Mutable because the browsing list has two sources: the command line at
|
||||
// startup, and whatever the library grid is showing when a cell is
|
||||
// clicked. Opening from the grid replaces this so next/previous walk the
|
||||
// library the user is actually looking at rather than the arguments they
|
||||
// launched with.
|
||||
let entries = Rc::new(RefCell::new(collect(&paths)));
|
||||
log::info!("{} image(s) to browse", entries.borrow().len());
|
||||
|
||||
let window = AppWindow::new()?;
|
||||
|
||||
// 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));
|
||||
|
||||
// Before anything binds to a token: the compiled palette is already in
|
||||
// place, so this only overwrites what style.yaml currently says.
|
||||
#[cfg(live_style)]
|
||||
live_style::apply(&window);
|
||||
|
||||
// The library grid: scan the remote tree into the catalog, then show what
|
||||
// was found. Clicking a cell opens it in develop.
|
||||
//
|
||||
// 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();
|
||||
|
||||
// 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
|
||||
// a folder goes straight to their images (FR-NC-1).
|
||||
{
|
||||
let controller = launch_ui::LaunchController::new();
|
||||
let show = controller.should_show(!paths.is_empty());
|
||||
window.set_show_launch(show);
|
||||
launch_ui::wire(&window, controller, |session| {
|
||||
// Opening a remote library needs the scan-and-cache path, which
|
||||
// lands with the catalog. Reporting that plainly beats a button
|
||||
// that silently does nothing.
|
||||
log::info!("open library requested for {}", session.describe());
|
||||
let startup = controller
|
||||
.model
|
||||
.borrow()
|
||||
.startup_action(!paths.is_empty());
|
||||
window.set_show_launch(startup == launch::Startup::ShowLaunchScreen);
|
||||
|
||||
let library = library.clone();
|
||||
let collections = collections_ui::CollectionsController::new();
|
||||
|
||||
// The click handler needs `show`, which is built further down because
|
||||
// it captures the develop session and the GPU context. This cell is
|
||||
// the knot between them: wired empty here, filled once `show` exists.
|
||||
// A click before then is a no-op rather than a panic — the grid cannot
|
||||
// be reached until the window is running, by which point it is set.
|
||||
let open_from_library = open_from_library.clone();
|
||||
library_ui::wire(
|
||||
&window,
|
||||
library.clone(),
|
||||
collections.clone(),
|
||||
move |path| {
|
||||
let Some(f) = open_from_library.borrow().clone() else {
|
||||
log::warn!("open requested before the viewer was ready: {path}");
|
||||
return;
|
||||
};
|
||||
f(path);
|
||||
},
|
||||
);
|
||||
|
||||
// The collections sidebar shares the library's catalog handle rather
|
||||
// than opening its own: one SQLite connection, so an edit here is
|
||||
// visible to the grid's next read without a reopen.
|
||||
//
|
||||
// The reload closure is the seam between the two controllers. The
|
||||
// sidebar decides *what* is scoped; the library owns the window, the
|
||||
// offset and the thumbnail workers, so it is what actually reloads —
|
||||
// and it must be told the scope before it reads, which is why both
|
||||
// happen here in one place rather than each controller reaching for the
|
||||
// other.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let lib = library.clone();
|
||||
let coll = collections.clone();
|
||||
let lib_ids = library.clone();
|
||||
let lib_session = library.clone();
|
||||
collections_ui::wire(
|
||||
&window,
|
||||
collections.clone(),
|
||||
library.catalog(),
|
||||
move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
// Order matters: `set_scope` clears the trash flag, because
|
||||
// picking a collection is how you leave the trash. Setting
|
||||
// the flag second is what lets selecting the trash itself
|
||||
// survive the call.
|
||||
lib.set_scope(coll.scope());
|
||||
lib.set_viewing_trash(coll.viewing_trash());
|
||||
library_ui::reload(&w, &lib);
|
||||
},
|
||||
move || lib_ids.visible_ids(),
|
||||
// The trash's MOVE and DELETE go to the same account the scan
|
||||
// and thumbnail workers use.
|
||||
move || lib_session.session(),
|
||||
);
|
||||
}
|
||||
|
||||
let weak = window.as_weak();
|
||||
let store_ctl = controller.clone();
|
||||
let lib = library.clone();
|
||||
let coll = collections.clone();
|
||||
launch_ui::wire(&window, controller.clone(), move |session| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
log::info!("opening library for {}", session.describe());
|
||||
library_ui::open(&w, lib.clone(), coll.clone(), &store_ctl.store, session);
|
||||
});
|
||||
if show {
|
||||
|
||||
match startup {
|
||||
launch::Startup::ShowLaunchScreen => {
|
||||
log::info!("no library configured — showing the launch screen");
|
||||
}
|
||||
launch::Startup::ShowLocalFiles => {
|
||||
log::info!("{} file(s) named on the command line", paths.len());
|
||||
}
|
||||
// Skipping the launch screen must not mean skipping the library:
|
||||
// the "Open library" button lives on the screen we just bypassed,
|
||||
// so nothing else would ever start the scan.
|
||||
launch::Startup::OpenLibrary => {
|
||||
let session = controller.model.borrow().session().cloned();
|
||||
if let Some(session) = session {
|
||||
log::info!("resuming library for {}", session.describe());
|
||||
library_ui::open(
|
||||
&window,
|
||||
library.clone(),
|
||||
collections.clone(),
|
||||
&controller.store,
|
||||
session,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The device is shared by demosaic and the adjust pass. Without one the
|
||||
@@ -246,7 +463,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
window.set_total(entries.len() as i32);
|
||||
window.set_total(entries.borrow().len() as i32);
|
||||
let index = Rc::new(RefCell::new(0usize));
|
||||
// The current develop session, if the file yielded sensor data.
|
||||
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
|
||||
@@ -269,10 +486,25 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let mut slot = session.borrow_mut();
|
||||
let Some(s) = slot.as_mut() else { return };
|
||||
let (w, h) = *viewport.borrow();
|
||||
match s.render(w, h) {
|
||||
|
||||
// Crop mode shows the whole frame, or the area being cropped away
|
||||
// would not be on screen for the handles to drag across. The
|
||||
// overlay draws the rect on top of it.
|
||||
let rendered = if window.get_crop_mode() {
|
||||
s.render_uncropped(w, h).map(|(image, _, _)| image)
|
||||
} else {
|
||||
s.render(w, h)
|
||||
};
|
||||
|
||||
match rendered {
|
||||
Ok(image) => {
|
||||
window.set_canvas(image);
|
||||
window.set_load_error("".into());
|
||||
// The readout and the "Fit" button follow the session
|
||||
// rather than the gesture, so a clamped zoom shows the
|
||||
// value that was actually applied.
|
||||
window.set_zoom(s.zoom());
|
||||
window.set_zoomed(s.is_zoomed());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("render failed: {e}");
|
||||
@@ -291,13 +523,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let rows = rows.clone();
|
||||
Rc::new(move |window: &AppWindow| {
|
||||
let i = *index.borrow();
|
||||
let Some(path) = entries.get(i) else { return };
|
||||
// Cloned rather than held: `load` below is slow, and keeping the
|
||||
// list borrowed across it would panic the moment anything else
|
||||
// touched `entries`.
|
||||
let Some(path) = entries.borrow().get(i).cloned() else {
|
||||
return;
|
||||
};
|
||||
let path = path.as_path();
|
||||
|
||||
let name = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
reset_view_state(window);
|
||||
window.set_filename(name.clone().into());
|
||||
window.set_index(i as i32);
|
||||
|
||||
@@ -349,6 +588,108 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
})
|
||||
};
|
||||
|
||||
// Now `show` exists, close the knot left open at the library wiring.
|
||||
//
|
||||
// The grid's paths are *remote*: there is no local file to open, so the
|
||||
// click starts a download and the image appears when it lands. That is a
|
||||
// whole RAW file over WebDAV, so the wait is real and has to be visible —
|
||||
// the status line says so rather than leaving a blank frame.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let library = library.clone();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
let rows = rows.clone();
|
||||
let gpu = gpu.clone();
|
||||
*open_from_library.borrow_mut() = Some(Rc::new(move |path: String| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
|
||||
let name = path.rsplit('/').next().unwrap_or(&path).to_string();
|
||||
reset_view_state(&w);
|
||||
w.set_filename(name.clone().into());
|
||||
w.set_load_error("".into());
|
||||
w.set_camera("".into());
|
||||
w.set_exposure("".into());
|
||||
w.set_dimensions("".into());
|
||||
// The grid is one image at a time, so next/previous have nothing
|
||||
// to walk. Shown as 1 of 1 rather than left reading 0.
|
||||
w.set_index(0);
|
||||
w.set_total(1);
|
||||
|
||||
let Some((creds, user_id)) = library.credentials() else {
|
||||
w.set_load_error("no library session".into());
|
||||
return;
|
||||
};
|
||||
|
||||
log::info!("fetching {path} for develop");
|
||||
w.set_load_error("Downloading…".into());
|
||||
|
||||
let rx = library::spawn_full_fetch(creds, user_id, path.clone());
|
||||
|
||||
// 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();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
let rows = rows.clone();
|
||||
let gpu = gpu.clone();
|
||||
let timer = Rc::new(slint::Timer::default());
|
||||
let held = timer.clone();
|
||||
timer.start(
|
||||
slint::TimerMode::Repeated,
|
||||
std::time::Duration::from_millis(50),
|
||||
move || {
|
||||
let Ok(got) = rx.try_recv() else { return };
|
||||
// Landed — this timer has done its job.
|
||||
held.stop();
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
|
||||
let bytes = match got {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!("{name}: {e}");
|
||||
w.set_load_error(e.into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
log::info!("{name}: {} bytes fetched", bytes.len());
|
||||
|
||||
match load_bytes(gpu.as_ref(), &bytes) {
|
||||
Ok(l) => {
|
||||
w.set_load_error("".into());
|
||||
w.set_camera(describe_camera(&l.meta).into());
|
||||
w.set_exposure(describe_exposure(&l.meta).into());
|
||||
w.set_dimensions(format!("{} × {}", l.width, l.height).into());
|
||||
match l.session {
|
||||
Some(s) => {
|
||||
w.set_adjust_enabled(true);
|
||||
*session.borrow_mut() = Some(s);
|
||||
sync_rows(&w, &rows, &session);
|
||||
redraw(&w);
|
||||
}
|
||||
None => {
|
||||
*session.borrow_mut() = None;
|
||||
rows.set_vec(Vec::<ParamRow>::new());
|
||||
w.set_adjust_enabled(false);
|
||||
if let Some(image) = l.fallback {
|
||||
w.set_canvas(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
log::info!("{name}: {}×{}", l.width, l.height);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("{name}: {e}");
|
||||
*session.borrow_mut() = None;
|
||||
w.set_adjust_enabled(false);
|
||||
w.set_load_error(e.into());
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- Adjustment callbacks ------------------------------------------
|
||||
//
|
||||
// Generic by construction: they carry indices into the capability list,
|
||||
@@ -413,6 +754,96 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- zoom, pan and crop ---------------------------------------------
|
||||
//
|
||||
// Zoom and pan are viewing state and touch no parameter, so unlike the
|
||||
// handlers above they do not `sync_rows`.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_zoom_at(move |factor, at_x, at_y| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.zoom_about(factor, at_x, at_y);
|
||||
}
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_pan_by(move |dx, dy| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.pan_by(dx, dy);
|
||||
}
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_zoom_reset(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.reset_zoom();
|
||||
}
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
// Entering crop mode drops the zoom: the handles are placed against
|
||||
// the whole frame, and a zoomed view would put most of that frame off
|
||||
// screen where it cannot be dragged.
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_crop_mode_toggled(move |on| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
if on {
|
||||
s.reset_zoom();
|
||||
let c = s.crop();
|
||||
w.set_crop_x(c.x);
|
||||
w.set_crop_y(c.y);
|
||||
w.set_crop_w(c.width);
|
||||
w.set_crop_h(c.height);
|
||||
}
|
||||
}
|
||||
w.set_crop_mode(on);
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
// The rect arrives raw from the drag; the session normalises it, and
|
||||
// the properties are written back from what it actually stored. That
|
||||
// round trip is what makes an over-drag slide along the edge rather
|
||||
// than letting the overlay and the pipeline disagree.
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_crop_changed(move |x, y, width, height| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.set_crop(dr_pipeline::CropRect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
let c = s.crop();
|
||||
w.set_crop_x(c.x);
|
||||
w.set_crop_y(c.y);
|
||||
w.set_crop_w(c.width);
|
||||
w.set_crop_h(c.height);
|
||||
}
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let index = index.clone();
|
||||
@@ -420,14 +851,15 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let show = show.clone();
|
||||
window.on_next_image(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if entries.is_empty() {
|
||||
let len = entries.borrow().len();
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
// Read, then write — `*x.borrow_mut() = *x.borrow() + 1` holds
|
||||
// both borrows at once and panics.
|
||||
let next = {
|
||||
let cur = *index.borrow();
|
||||
(cur + 1) % entries.len()
|
||||
(cur + 1) % len
|
||||
};
|
||||
*index.borrow_mut() = next;
|
||||
show(&w);
|
||||
@@ -440,13 +872,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let show = show.clone();
|
||||
window.on_prev_image(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if entries.is_empty() {
|
||||
let len = entries.borrow().len();
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
let prev = {
|
||||
let cur = *index.borrow();
|
||||
if cur == 0 {
|
||||
entries.len() - 1
|
||||
len - 1
|
||||
} else {
|
||||
cur - 1
|
||||
}
|
||||
@@ -489,7 +922,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
apply_layout_class(&window, size.width as f32 / scale);
|
||||
}
|
||||
|
||||
if !entries.is_empty() {
|
||||
if !entries.borrow().is_empty() {
|
||||
show(&window);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,181 @@
|
||||
//! Re-reads `style.yaml` at startup, so a palette can be tuned by editing a
|
||||
//! data file and restarting rather than rebuilding the tree.
|
||||
//!
|
||||
//! Compiled only under the `live-style` feature. Release builds have no
|
||||
//! parser, no file read, and tokens the Slint compiler has folded to
|
||||
//! constants — this path exists for the ten minutes somebody spends deciding
|
||||
//! whether `surface` wants two more points of lift.
|
||||
//!
|
||||
//! The trick that makes it cheap: under this feature `build.rs` emits the
|
||||
//! tokens as `in-out` rather than `out`. `Theme.ground` reads identically
|
||||
//! either way, so not one call site knows this module exists; the only
|
||||
//! difference is that Slint now generates Rust setters for the global.
|
||||
//!
|
||||
//! Failure here is never fatal. A photographer running a debug build with a
|
||||
//! half-edited YAML should get their window and a warning, not a crash — the
|
||||
//! build-time path is where a malformed file is an error.
|
||||
//!
|
||||
//! The cost of the feature is the table at the bottom: Slint generates one
|
||||
//! setter per property and nothing indexed, so adding a token to `style.yaml`
|
||||
//! means adding a line here too. Tolerable because it is debug-only, and the
|
||||
//! unmatched-name warning says so out loud rather than reloading a no-op —
|
||||
//! but it is why this is not the primary path.
|
||||
|
||||
use serde_norway::Value;
|
||||
use slint::{Color, ComponentHandle};
|
||||
|
||||
use crate::{AppWindow, Theme};
|
||||
|
||||
/// Absolute path baked in by `build.rs`, so the binary finds the file
|
||||
/// regardless of the directory it is launched from.
|
||||
const STYLE_YAML: &str = env!("DR_STYLE_YAML");
|
||||
|
||||
pub fn apply(window: &AppWindow) {
|
||||
match load() {
|
||||
Ok(style) => {
|
||||
let n = style.apply_to(window);
|
||||
log::info!("live-style: applied {n} token(s) from {STYLE_YAML}");
|
||||
}
|
||||
Err(e) => log::warn!("live-style: keeping compiled tokens; {STYLE_YAML}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
struct Style {
|
||||
colors: Vec<(String, Color)>,
|
||||
lengths: Vec<(String, f32)>,
|
||||
}
|
||||
|
||||
fn load() -> Result<Style, String> {
|
||||
let text = std::fs::read_to_string(STYLE_YAML).map_err(|e| e.to_string())?;
|
||||
let doc: Value = serde_norway::from_str(&text).map_err(|e| e.to_string())?;
|
||||
let doc = doc.as_mapping().ok_or("top level is not a mapping")?;
|
||||
|
||||
let mut colors = Vec::new();
|
||||
for (name, spec) in group(doc, "colors")? {
|
||||
// An alias resolves against what has already been read, matching the
|
||||
// generated Slint where `modified: root.active` refers upward.
|
||||
if let Some(target) = scalar(spec, "alias") {
|
||||
if let Some((_, c)) = colors.iter().find(|(n, _): &&(String, Color)| n == &target) {
|
||||
colors.push((name, *c));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(hex) = value_of(spec).and_then(|v| v.as_str()) {
|
||||
if let Some(c) = parse_hex(hex) {
|
||||
colors.push((name, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut lengths = Vec::new();
|
||||
for (name, spec) in group(doc, "lengths")? {
|
||||
if let Some(px) = value_of(spec).and_then(|v| v.as_f64()) {
|
||||
lengths.push((name, px as f32));
|
||||
}
|
||||
}
|
||||
Ok(Style { colors, lengths })
|
||||
}
|
||||
|
||||
/// Yields the real tokens of a group, skipping the `section:`/`break:`
|
||||
/// dividers that exist only to shape the generated file.
|
||||
fn group<'a>(
|
||||
doc: &'a serde_norway::Mapping,
|
||||
key: &str,
|
||||
) -> Result<Vec<(String, &'a Value)>, String> {
|
||||
let map = doc
|
||||
.get(key)
|
||||
.and_then(Value::as_mapping)
|
||||
.ok_or_else(|| format!("missing `{key}:` map"))?;
|
||||
Ok(map
|
||||
.iter()
|
||||
.filter(|(_, spec)| {
|
||||
!spec
|
||||
.as_mapping()
|
||||
.is_some_and(|m| m.contains_key("section") || m.contains_key("break"))
|
||||
})
|
||||
.filter_map(|(k, v)| Some((k.as_str()?.to_string(), v)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn value_of(spec: &Value) -> Option<&Value> {
|
||||
match spec.as_mapping() {
|
||||
Some(map) => map.get("value"),
|
||||
None => Some(spec),
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar(spec: &Value, field: &str) -> Option<String> {
|
||||
Some(spec.as_mapping()?.get(field)?.as_str()?.to_string())
|
||||
}
|
||||
|
||||
fn parse_hex(hex: &str) -> Option<Color> {
|
||||
let d = hex.strip_prefix('#')?;
|
||||
let byte = |i: usize| u8::from_str_radix(d.get(i..i + 2)?, 16).ok();
|
||||
match d.len() {
|
||||
6 => Some(Color::from_rgb_u8(byte(0)?, byte(2)?, byte(4)?)),
|
||||
8 => Some(Color::from_argb_u8(byte(6)?, byte(0)?, byte(2)?, byte(4)?)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Style {
|
||||
/// Slint generates one setter per property rather than anything indexed,
|
||||
/// so the mapping from token name to setter has to be spelled out. The
|
||||
/// `theme_tokens!` macro keeps that to one line per token, and an
|
||||
/// unmatched name is warned about rather than ignored — a token renamed
|
||||
/// in the YAML and nowhere else would otherwise reload silently as a
|
||||
/// no-op.
|
||||
fn apply_to(&self, window: &AppWindow) -> usize {
|
||||
let theme = window.global::<Theme>();
|
||||
let mut applied = 0;
|
||||
|
||||
macro_rules! theme_tokens {
|
||||
($set:ident, $list:expr, $($name:literal => $setter:ident),* $(,)?) => {
|
||||
for (name, v) in $list {
|
||||
match name.as_str() {
|
||||
$($name => { theme.$setter(v.clone().into()); applied += 1; })*
|
||||
other => log::warn!("live-style: no such token `{other}`"),
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
theme_tokens!(set, &self.colors,
|
||||
"ground" => set_ground,
|
||||
"surface" => set_surface,
|
||||
"surface-raised" => set_surface_raised,
|
||||
"rule" => set_rule,
|
||||
"ink" => set_ink,
|
||||
"ink-dim" => set_ink_dim,
|
||||
"ink-faint" => set_ink_faint,
|
||||
"hover" => set_hover,
|
||||
"pressed" => set_pressed,
|
||||
"active" => set_active,
|
||||
"active-dim" => set_active_dim,
|
||||
"active-pressed" => set_active_pressed,
|
||||
"modified" => set_modified,
|
||||
"selected" => set_selected,
|
||||
"selected-ring" => set_selected_ring,
|
||||
"warn-ink" => set_warn_ink,
|
||||
);
|
||||
|
||||
theme_tokens!(set, &self.lengths,
|
||||
"gap-sm" => set_gap_sm,
|
||||
"gap" => set_gap,
|
||||
"gap-lg" => set_gap_lg,
|
||||
"text-sm" => set_text_sm,
|
||||
"text" => set_text,
|
||||
"text-lg" => set_text_lg,
|
||||
"text-xl" => set_text_xl,
|
||||
"radius-sm" => set_radius_sm,
|
||||
"radius" => set_radius,
|
||||
"touch-target" => set_touch_target,
|
||||
"row-height" => set_row_height,
|
||||
"indent" => set_indent,
|
||||
"control-height" => set_control_height,
|
||||
"control-min-width" => set_control_min_width,
|
||||
);
|
||||
|
||||
applied
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
//! TRACES: FR-CAT-15 | NFR-P9
|
||||
//! Soft delete, restore, and permanent delete against the remote.
|
||||
//!
|
||||
//! `dr_catalog::trash` owns the catalog side and does no I/O. This is the other
|
||||
//! half: the remote `MOVE`/`DELETE`, run on a worker thread, paired with the
|
||||
//! catalog record in the one order that is safe.
|
||||
//!
|
||||
//! # Ordering, which is the whole of the correctness here
|
||||
//!
|
||||
//! **Soft delete** — `MOVE` first, record second. The reverse would leave the
|
||||
//! catalog claiming a file is trashed while it sits in the library; the scan
|
||||
//! excludes the trash folder, so nothing would ever correct the row.
|
||||
//!
|
||||
//! **Restore** — `MOVE` first, record second, for the same reason mirrored.
|
||||
//!
|
||||
//! **Purge** — `DELETE` the file, then forget the row, then forget the
|
||||
//! thumbnail. A crash between steps leaves a trashed row whose file is gone,
|
||||
//! which the next empty resolves as already-deleted. The other order loses the
|
||||
//! file silently: no row, no listing, and a scan that will never look in the
|
||||
//! trash folder — a photograph consuming quota that nothing can find.
|
||||
//!
|
||||
//! # Partial failure is normal, not exceptional
|
||||
//!
|
||||
//! Forty files is forty requests, and one can fail on permissions while the
|
||||
//! rest succeed. Every operation here is therefore per-image and reports what
|
||||
//! actually happened rather than aborting the batch — a trash that gives up
|
||||
//! halfway with no record of where it stopped is worse than one that reports
|
||||
//! "38 of 40".
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
use dr_catalog::{trash, Catalog};
|
||||
use dr_sync::{RemoteBackend, RemoteError, RemoteId, RemotePath};
|
||||
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
||||
use dr_types::ImageId;
|
||||
|
||||
/// What a trash operation reports back to the UI.
|
||||
#[derive(Debug)]
|
||||
pub enum TrashMessage {
|
||||
/// One image finished, successfully or not.
|
||||
///
|
||||
/// Per-image rather than per-batch so the UI can show progress on a large
|
||||
/// selection, and so a failure names the file it happened to.
|
||||
Progress {
|
||||
done: usize,
|
||||
total: usize,
|
||||
failed: usize,
|
||||
},
|
||||
/// The batch finished. `failed` names what did not work, for the status line.
|
||||
Done {
|
||||
moved: usize,
|
||||
failed: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Which way an image is being moved.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Direction {
|
||||
/// Library → trash folder.
|
||||
ToTrash,
|
||||
/// Trash folder → where it came from.
|
||||
Restore,
|
||||
}
|
||||
|
||||
/// One image to move, resolved before the worker starts.
|
||||
///
|
||||
/// Carries the id the `MOVE` addresses and the destination path, so the worker
|
||||
/// needs no catalog access to do its half — the catalog is not `Send`, and the
|
||||
/// worker owns a separate connection only for the write-back.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Move {
|
||||
pub image_id: ImageId,
|
||||
/// `oc:fileid` where known, else the path. A stable id survives the move and
|
||||
/// keeps the thumbnail and sidecar mapping attached.
|
||||
pub file_id: Option<u64>,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
/// Plan a soft delete: where each image goes in the trash.
|
||||
///
|
||||
/// Reads the catalog, so it runs on the UI thread before the worker starts.
|
||||
/// Skips images already trashed — re-trashing is a no-op, not an error, and the
|
||||
/// UI can hand over a selection that overlaps the trash.
|
||||
pub fn plan_trash(
|
||||
catalog: &Catalog,
|
||||
root: &str,
|
||||
images: &[ImageId],
|
||||
) -> Result<Vec<Move>, dr_catalog::CatalogError> {
|
||||
let mut out = Vec::new();
|
||||
for &image in images {
|
||||
let row: Option<(String, Option<i64>, Option<i64>)> = catalog
|
||||
.connection()
|
||||
.query_row(
|
||||
"SELECT i.source_ref, r.file_id, i.trashed_at
|
||||
FROM images i LEFT JOIN remote r ON r.image_id = i.id
|
||||
WHERE i.id = ?1",
|
||||
[image.0 as i64],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.ok();
|
||||
|
||||
let Some((from, file_id, trashed_at)) = row else {
|
||||
continue;
|
||||
};
|
||||
if trashed_at.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(Move {
|
||||
image_id: image,
|
||||
file_id: file_id.map(|v| v as u64),
|
||||
to: trash::trash_path(root, image, &from),
|
||||
from,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Plan a restore: where each trashed image goes back to.
|
||||
///
|
||||
/// An image with no recorded origin is skipped rather than guessed at — putting
|
||||
/// a photograph in the wrong folder is harder to notice, and harder to undo,
|
||||
/// than leaving it in the trash.
|
||||
pub fn plan_restore(
|
||||
catalog: &Catalog,
|
||||
images: &[ImageId],
|
||||
) -> Result<Vec<Move>, dr_catalog::CatalogError> {
|
||||
let mut out = Vec::new();
|
||||
for &image in images {
|
||||
let row: Option<(String, Option<String>, Option<i64>)> = catalog
|
||||
.connection()
|
||||
.query_row(
|
||||
"SELECT i.source_ref, i.trashed_from, r.file_id
|
||||
FROM images i LEFT JOIN remote r ON r.image_id = i.id
|
||||
WHERE i.id = ?1 AND i.trashed_at IS NOT NULL",
|
||||
[image.0 as i64],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.ok();
|
||||
|
||||
let Some((from, Some(to), file_id)) = row else {
|
||||
continue;
|
||||
};
|
||||
out.push(Move {
|
||||
image_id: image,
|
||||
file_id: file_id.map(|v| v as u64),
|
||||
from,
|
||||
to,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Move images to or from the trash on a worker thread.
|
||||
///
|
||||
/// The catalog is written by the worker, after each successful move, so an
|
||||
/// interrupted batch leaves the rows it completed correct rather than losing all
|
||||
/// of them.
|
||||
pub fn spawn_move(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
moves: Vec<Move>,
|
||||
direction: Direction,
|
||||
catalog_path: PathBuf,
|
||||
) -> Receiver<TrashMessage> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let total = moves.len();
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = tx.send(TrashMessage::Done {
|
||||
moved: 0,
|
||||
failed: vec![e.to_string()],
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let _ = tx.send(TrashMessage::Done {
|
||||
moved: 0,
|
||||
failed: vec![e.to_string()],
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut succeeded: Vec<(ImageId, String)> = Vec::new();
|
||||
let mut failed: Vec<String> = Vec::new();
|
||||
|
||||
for (i, mv) in moves.iter().enumerate() {
|
||||
let id = match mv.file_id {
|
||||
Some(f) => RemoteId::Stable(f),
|
||||
None => RemoteId::Path(RemotePath::new(&mv.from)),
|
||||
};
|
||||
|
||||
match backend.move_to(&id, &RemotePath::new(&mv.to)).await {
|
||||
Ok(()) => succeeded.push((mv.image_id, mv.to.clone())),
|
||||
Err(e) => {
|
||||
// Named by file, not by id: the user recognises the
|
||||
// filename and cannot do anything with a row number.
|
||||
let name = mv.from.rsplit('/').next().unwrap_or(&mv.from);
|
||||
log::warn!("moving {name}: {e}");
|
||||
failed.push(format!("{name}: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tx.send(TrashMessage::Progress {
|
||||
done: i + 1,
|
||||
total,
|
||||
failed: failed.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// Record after the moves, in one transaction. A crash before this
|
||||
// leaves the files moved and the catalog stale — recoverable,
|
||||
// because the next scan cannot see them in the trash folder and the
|
||||
// rows still point at paths that 404, which the UI reports.
|
||||
if !succeeded.is_empty() {
|
||||
match Catalog::open(&catalog_path) {
|
||||
Ok(cat) => {
|
||||
let result = match direction {
|
||||
Direction::ToTrash => {
|
||||
trash::record_trashed(cat.connection(), &succeeded, now_secs())
|
||||
}
|
||||
Direction::Restore => {
|
||||
trash::record_restored(cat.connection(), &succeeded)
|
||||
}
|
||||
};
|
||||
if let Err(e) = result {
|
||||
log::warn!("recording trash state: {e}");
|
||||
failed.push(format!("catalog: {e}"));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("opening catalog to record trash state: {e}");
|
||||
failed.push(format!("catalog: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tx.send(TrashMessage::Done {
|
||||
moved: succeeded.len(),
|
||||
failed,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
rx
|
||||
}
|
||||
|
||||
/// Permanently delete trashed images: the file, then the row, then the thumbnail.
|
||||
///
|
||||
/// See the module preamble for why that order. `thumbs_dir` is passed so the
|
||||
/// worker can drop the previews — the shards sync, so a stale entry would keep
|
||||
/// serving a preview of a deleted photograph on every device.
|
||||
pub fn spawn_purge(
|
||||
creds: AppCredentials,
|
||||
user_id: String,
|
||||
images: Vec<ImageId>,
|
||||
paths: Vec<(ImageId, Option<u64>, String)>,
|
||||
catalog_path: PathBuf,
|
||||
thumbs_dir: PathBuf,
|
||||
) -> Receiver<TrashMessage> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let total = paths.len();
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = tx.send(TrashMessage::Done {
|
||||
moved: 0,
|
||||
failed: vec![e.to_string()],
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let _ = tx.send(TrashMessage::Done {
|
||||
moved: 0,
|
||||
failed: vec![e.to_string()],
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut deleted: Vec<ImageId> = Vec::new();
|
||||
let mut dead_thumbs: Vec<u64> = Vec::new();
|
||||
let mut failed: Vec<String> = Vec::new();
|
||||
|
||||
for (i, (image, file_id, path)) in paths.iter().enumerate() {
|
||||
let id = match file_id {
|
||||
Some(f) => RemoteId::Stable(*f),
|
||||
None => RemoteId::Path(RemotePath::new(path)),
|
||||
};
|
||||
|
||||
match backend.delete(&id, None).await {
|
||||
Ok(()) => {
|
||||
deleted.push(*image);
|
||||
if let Some(f) = file_id {
|
||||
dead_thumbs.push(*f);
|
||||
}
|
||||
}
|
||||
// Already gone is the goal state, not a failure. Treating it
|
||||
// as an error would wedge every future empty-trash on a file
|
||||
// the user had removed by hand.
|
||||
Err(e) if is_missing(&e) => {
|
||||
log::debug!("{path} was already gone");
|
||||
deleted.push(*image);
|
||||
if let Some(f) = file_id {
|
||||
dead_thumbs.push(*f);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
log::warn!("deleting {name}: {e}");
|
||||
failed.push(format!("{name}: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tx.send(TrashMessage::Progress {
|
||||
done: i + 1,
|
||||
total,
|
||||
failed: failed.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// Rows after the files — see `trash::purge_order`.
|
||||
if !deleted.is_empty() {
|
||||
match Catalog::open(&catalog_path) {
|
||||
Ok(cat) => {
|
||||
if let Err(e) = trash::forget(cat.connection(), &deleted) {
|
||||
log::warn!("forgetting purged rows: {e}");
|
||||
failed.push(format!("catalog: {e}"));
|
||||
}
|
||||
}
|
||||
Err(e) => failed.push(format!("catalog: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// Thumbnails last. A failure here is logged and dropped: the
|
||||
// photographs are gone, which was the point, and a stale preview is
|
||||
// a cosmetic problem rather than a reason to report the delete
|
||||
// failed.
|
||||
if !dead_thumbs.is_empty() {
|
||||
match dr_thumbs::ThumbStore::open(&thumbs_dir) {
|
||||
Ok(mut store) => match store.forget(&dead_thumbs) {
|
||||
Ok(n) => log::info!("dropped {n} thumbnail(s) for purged images"),
|
||||
Err(e) => log::warn!("dropping thumbnails: {e}"),
|
||||
},
|
||||
Err(e) => log::warn!("opening thumbnail store to drop previews: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tx.send(TrashMessage::Done {
|
||||
moved: deleted.len(),
|
||||
failed,
|
||||
});
|
||||
});
|
||||
|
||||
let _ = images;
|
||||
});
|
||||
|
||||
rx
|
||||
}
|
||||
|
||||
/// Whether a remote error means the object is not there.
|
||||
///
|
||||
/// Kept next to its use rather than in `dr-catalog`: it inspects a
|
||||
/// `dr_sync::RemoteError`, and the catalog crate neither sees nor should see
|
||||
/// that type.
|
||||
fn is_missing(e: &RemoteError) -> bool {
|
||||
matches!(e, RemoteError::NotFound(_))
|
||||
}
|
||||
|
||||
fn now_secs() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn seeded() -> Catalog {
|
||||
let cat = Catalog::in_memory().unwrap();
|
||||
let c = cat.connection();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'PhotosRaw')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
for i in 1..=3i64 {
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, file_size, added_at)
|
||||
VALUES (?1, 1, ?2, 1000, 0)",
|
||||
rusqlite::params![i, format!("PhotosRaw/2019/IMG_{i:04}.CR2")],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
|
||||
rusqlite::params![i, 500 + i],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
cat
|
||||
}
|
||||
|
||||
fn img(i: u64) -> ImageId {
|
||||
ImageId(i)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trash_plan_targets_the_trash_folder_and_keeps_the_stable_id() {
|
||||
let cat = seeded();
|
||||
let plan = plan_trash(&cat, "PhotosRaw", &[img(1)]).unwrap();
|
||||
|
||||
assert_eq!(plan.len(), 1);
|
||||
assert_eq!(plan[0].from, "PhotosRaw/2019/IMG_0001.CR2");
|
||||
assert!(plan[0].to.contains(".darkroom-trash"), "{}", plan[0].to);
|
||||
// The stable id is what makes the move keep the thumbnail attached.
|
||||
assert_eq!(plan[0].file_id, Some(501));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_already_trashed_image_is_not_trashed_again() {
|
||||
// The selection can overlap what is already in the trash; a second move
|
||||
// would relocate the file *within* the trash and lose its origin.
|
||||
let cat = seeded();
|
||||
let plan = plan_trash(&cat, "PhotosRaw", &[img(1)]).unwrap();
|
||||
trash::record_trashed(cat.connection(), &[(img(1), plan[0].to.clone())], 100).unwrap();
|
||||
|
||||
assert!(plan_trash(&cat, "PhotosRaw", &[img(1)]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restore_plan_sends_each_image_back_where_it_came_from() {
|
||||
let cat = seeded();
|
||||
let plan = plan_trash(&cat, "PhotosRaw", &[img(1)]).unwrap();
|
||||
trash::record_trashed(cat.connection(), &[(img(1), plan[0].to.clone())], 100).unwrap();
|
||||
|
||||
let back = plan_restore(&cat, &[img(1)]).unwrap();
|
||||
assert_eq!(back.len(), 1);
|
||||
assert_eq!(back[0].to, "PhotosRaw/2019/IMG_0001.CR2");
|
||||
// And it moves *from* the trash.
|
||||
assert!(back[0].from.contains(".darkroom-trash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_an_untrashed_image_plans_nothing() {
|
||||
let cat = seeded();
|
||||
assert!(plan_restore(&cat, &[img(2)]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_image_is_skipped_rather_than_planned_against_nothing() {
|
||||
// The grid's selection can outlive a rescan that removed a row.
|
||||
let cat = seeded();
|
||||
assert!(plan_trash(&cat, "PhotosRaw", &[img(999)]).unwrap().is_empty());
|
||||
assert!(plan_restore(&cat, &[img(999)]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_not_found_on_delete_counts_as_deleted() {
|
||||
// Otherwise one hand-removed file wedges every future empty-trash.
|
||||
assert!(is_missing(&RemoteError::NotFound("x".into())));
|
||||
assert!(!is_missing(&RemoteError::Unsupported("x")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planning_over_an_empty_selection_is_a_no_op() {
|
||||
let cat = seeded();
|
||||
assert!(plan_trash(&cat, "PhotosRaw", &[]).unwrap().is_empty());
|
||||
assert!(plan_restore(&cat, &[]).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
# The source of truth for the UI's colour and length tokens.
|
||||
#
|
||||
# `build.rs` reads this file and generates `theme.slint` into OUT_DIR at
|
||||
# compile time; nothing here is read at runtime unless the `live-style`
|
||||
# feature is on. Edit this file, never the generated one.
|
||||
#
|
||||
# Leaf values only. A token that binds several values into one concept — a
|
||||
# panel heading's colour *and* size *and* weight — is a Slint component, not
|
||||
# a row in a YAML file (see widgets.slint).
|
||||
#
|
||||
# Structure:
|
||||
# preamble — prose emitted at the top of the generated file
|
||||
# colors: — name -> "#RRGGBB", or `alias:` naming another colour token
|
||||
# lengths: — name -> pixels
|
||||
# Any token may carry `note:` (a `//` comment above it) or `doc:` (a `///`
|
||||
# doc comment, which Slint surfaces to editors).
|
||||
#
|
||||
# Two entries are dividers rather than tokens, because YAML discards the blank
|
||||
# lines between map entries and the generated file's grouping has to survive:
|
||||
# `section: <title>` — a headed run, optionally with its own `note:`
|
||||
# `break: true` — a bare blank line between related tokens
|
||||
|
||||
preamble: |
|
||||
Near-neutral dark palette, achromatic signalling.
|
||||
|
||||
Two commitments, both about not lying to the photographer.
|
||||
|
||||
**Dark ground.** A light UI surrounding an image biases how that image is
|
||||
judged — the eye adapts to the brightest thing in view, and a white panel
|
||||
makes a correctly-exposed photograph look dark.
|
||||
|
||||
**Near-neutral greys.** The earlier palette was a warm "darkroom safelight"
|
||||
brown (ground #14120F, R twelve points above B). That is worse than it
|
||||
sounds: simultaneous contrast pushes perception of the image *away* from the
|
||||
surround, so a warm chrome makes a neutral photograph read cool, and the
|
||||
photographer corrects toward warm to compensate. Every export drifts yellow.
|
||||
It is the same reason a print viewing booth is neutral grey rather than
|
||||
whatever colour the room happens to be.
|
||||
|
||||
These greys carry a 2–3 point blue lift rather than being flatly achromatic.
|
||||
Pure R=G=B reads as dead to most eyes; a trace of cool reads as instrument
|
||||
rather than absence, and biases far less than warmth because the eye is more
|
||||
tolerant of a cool surround. The lift is small enough not to matter
|
||||
perceptually and deliberate enough not to be mistaken for drift.
|
||||
|
||||
**No hue in the chrome.** Active and modified states are signalled by
|
||||
brightness alone. An accent sitting beside the image competes with it for
|
||||
attention and shifts the perception of nearby colours; a photo editor cannot
|
||||
afford either. The one exception is `warn-ink` — a caution is genuinely a
|
||||
different kind of thing from an active state, and hue is the fastest way to
|
||||
say so.
|
||||
|
||||
colors:
|
||||
ground: "#121314"
|
||||
surface: "#1B1C1E"
|
||||
surface-raised: "#252629"
|
||||
rule: "#323438"
|
||||
|
||||
_inks: { break: true }
|
||||
ink: "#EDEEF0"
|
||||
ink-dim: "#9EA1A6"
|
||||
ink-faint: "#71747A"
|
||||
|
||||
hover:
|
||||
value: "#2E3034"
|
||||
note: |
|
||||
Interactive states for surfaces. Named rather than written inline so a
|
||||
button, a section header and a list row cannot drift apart: hover lifts
|
||||
toward the light, press sinks back past the resting surface so the
|
||||
control reads as depressed rather than merely lit.
|
||||
pressed: "#17181A"
|
||||
|
||||
_signalling:
|
||||
section: signalling
|
||||
note: |
|
||||
Achromatic, so the separation has to come from luminance. These are
|
||||
spaced further apart than a coloured palette would need: with hue
|
||||
unavailable, a two-step brightness difference is invisible, and a
|
||||
modified marker that cannot be spotted at a glance is not a marker.
|
||||
|
||||
active:
|
||||
value: "#FFFFFF"
|
||||
doc: |
|
||||
An active or engaged control: a slider fill, a curve line, a checked
|
||||
box. Brighter than `ink` so it reads as lit rather than merely present.
|
||||
active-dim:
|
||||
value: "#C6C9CE"
|
||||
doc: Active, at rest — a filled control that is not under the pointer.
|
||||
active-pressed:
|
||||
value: "#8E9298"
|
||||
doc: Active, pressed. Sinks rather than lifts, matching `pressed`.
|
||||
|
||||
modified:
|
||||
alias: active
|
||||
note: |
|
||||
"This differs from its default." Deliberately the brightest thing in the
|
||||
chrome: it is the one piece of state the photographer scans for, and
|
||||
with no hue to carry it, brightness is all there is.
|
||||
|
||||
selected:
|
||||
value: "#383B40"
|
||||
doc: |
|
||||
A selected grid cell. A lifted neutral rather than a tint — distinct
|
||||
from `hover` because a multi-selection must stay legible after the
|
||||
pointer has moved on, which is the whole point of selecting several
|
||||
before dragging them.
|
||||
selected-ring:
|
||||
value: "#D5D8DD"
|
||||
doc: |
|
||||
The ring around a selected cell. Brighter than the fill so selection
|
||||
survives against a pale thumbnail, where the fill alone would vanish.
|
||||
|
||||
warn-ink:
|
||||
value: "#C9A05A"
|
||||
note: |
|
||||
Semantic, and the only hue in the palette. A caution is not an active
|
||||
state, and it is worth the one exception to say that instantly. Muted
|
||||
rather than saturated so it does not shift perception of a nearby image.
|
||||
|
||||
lengths:
|
||||
_gaps: { break: true }
|
||||
gap-sm: 6
|
||||
gap: 12
|
||||
gap-lg: 20
|
||||
|
||||
_text: { break: true }
|
||||
text-sm: 11
|
||||
text: 13
|
||||
text-lg: 17
|
||||
text-xl: 24
|
||||
|
||||
radius-sm:
|
||||
value: 3
|
||||
note: |
|
||||
Corner radii. Two steps only: `radius-sm` for things that sit inside
|
||||
other things, `radius` for the controls themselves.
|
||||
radius: 4
|
||||
|
||||
touch-target:
|
||||
value: 44
|
||||
note: "FR-UI-3: minimum 44pt hit target under touch."
|
||||
|
||||
row-height:
|
||||
value: 26
|
||||
note: |
|
||||
One row of the collections tree, and one level of nesting. Both are
|
||||
tokens because a tree's indentation has to stay proportional to its row
|
||||
height; hard-coding either makes the hierarchy read wrong when the other
|
||||
changes.
|
||||
indent: 14
|
||||
|
||||
control-height:
|
||||
value: 28
|
||||
note: |
|
||||
The drawn height of a button or section header. Deliberately shorter
|
||||
than `touch-target` — chrome this tall in every row would crowd the
|
||||
photograph — so controls grow their TouchArea past their own bounds to
|
||||
meet FR-UI-3 rather than growing their ink.
|
||||
control-min-width:
|
||||
value: 88
|
||||
doc: |
|
||||
Floor on button width, so a one-word label is still a comfortable
|
||||
target and a row of buttons has an even rhythm.
|
||||
+190
-103
@@ -8,6 +8,7 @@
|
||||
// (FR-DEV-3c).
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { PanelHeading, Label, Value, Caption, Section } from "widgets.slint";
|
||||
|
||||
// One parameter, flattened for Slint's model system.
|
||||
//
|
||||
@@ -24,9 +25,21 @@ export struct ParamRow {
|
||||
op-label: string,
|
||||
param-label: string,
|
||||
|
||||
// True on the first parameter of each operation, so the panel can draw a
|
||||
// section heading without knowing what the sections are.
|
||||
starts-group: bool,
|
||||
// Grouping, derived in Rust from where `op-index` changes.
|
||||
//
|
||||
// The model is flat and Slint cannot slice one, so a group says where it
|
||||
// begins and how long it is and the panel indexes back into `rows` from
|
||||
// there. `group-head` is this row's group's first index — a row heads its
|
||||
// group exactly when its own index equals it, which is what replaced the
|
||||
// core-supplied `starts-group` flag (ARCH §4.3a: the core does not decide
|
||||
// that the panel has sections).
|
||||
group-head: int,
|
||||
group-len: int,
|
||||
|
||||
// Any parameter of this operation differs from its default. Identical on
|
||||
// every row of a group, because the heading is one of those rows and
|
||||
// cannot see the others.
|
||||
group-modified: bool,
|
||||
|
||||
// Which control to build. Mirrors ParamKind, plus the widget kinds an
|
||||
// operation can request through its presentation.
|
||||
@@ -62,26 +75,25 @@ component ParamSlider inherits Rectangle {
|
||||
spacing: 2px;
|
||||
|
||||
HorizontalLayout {
|
||||
Text {
|
||||
Label {
|
||||
text: root.data.param-label;
|
||||
color: root.data.value != root.data.default-value
|
||||
? Theme.ink : Theme.ink-dim;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
emphasised: root.data.value != root.data.default-value;
|
||||
}
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
Text {
|
||||
Value {
|
||||
// Precision comes from the descriptor, so a control in stops
|
||||
// reads 1.25 while one in whole units reads 25.
|
||||
text: root.data.precision == 0
|
||||
? Math.round(root.data.value) + root.data.unit
|
||||
: (Math.round(root.data.value * 100) / 100) + root.data.unit;
|
||||
color: root.data.value != root.data.default-value
|
||||
? Theme.accent : Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
// The one readout in the panel that has moved off its default
|
||||
// is what the eye is hunting for, and `modified` is the only
|
||||
// thing left to say it with once hue is gone.
|
||||
modified: root.data.value != root.data.default-value;
|
||||
placeholder: root.data.value == root.data.default-value;
|
||||
compact: true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +135,10 @@ component ParamSlider inherits Rectangle {
|
||||
width: abs(self.value-x / 1px - self.default-x / 1px) * 1px;
|
||||
y: (parent.height - 3px) / 2;
|
||||
height: 3px;
|
||||
background: Theme.accent;
|
||||
// The fill is the engaged part of the control — the span the
|
||||
// photographer has actually moved — so it takes `active`
|
||||
// rather than the ink the rest of the track is drawn in.
|
||||
background: Theme.active;
|
||||
border-radius: 1.5px;
|
||||
}
|
||||
|
||||
@@ -147,6 +162,16 @@ component ParamSlider inherits Rectangle {
|
||||
|
||||
property <float> span: root.data.maximum - root.data.minimum;
|
||||
|
||||
// Whether this gesture has been claimed as a slider drag.
|
||||
//
|
||||
// The panel scrolls vertically and this control acts
|
||||
// horizontally, so the axis of the movement says which was
|
||||
// meant. Committing on press-down instead — the obvious
|
||||
// approach — makes every attempt to scroll from a slider
|
||||
// jump its value first, which is destructive and happens
|
||||
// constantly given how much of the panel is sliders.
|
||||
property <bool> claimed: false;
|
||||
|
||||
function value-at(px: length) -> float {
|
||||
return clamp(
|
||||
root.data.minimum + (px / self.width) * self.span,
|
||||
@@ -155,15 +180,23 @@ component ParamSlider inherits Rectangle {
|
||||
}
|
||||
|
||||
moved => {
|
||||
// `moved` fires only while pressed, so this is the drag.
|
||||
// `moved` fires only while pressed, so this is a drag.
|
||||
if (!self.claimed) {
|
||||
// Claim once the movement is more horizontal than
|
||||
// vertical. Until then it might still be a scroll.
|
||||
if (abs(self.mouse-x - self.pressed-x)
|
||||
> abs(self.mouse-y - self.pressed-y)) {
|
||||
self.claimed = true;
|
||||
}
|
||||
}
|
||||
if (self.claimed) {
|
||||
root.changed(self.value-at(self.mouse-x));
|
||||
}
|
||||
}
|
||||
pointer-event(ev) => {
|
||||
// Jump to the press position, so a click anywhere on the
|
||||
// track sets the value and a drag continues from there.
|
||||
if (ev.kind == PointerEventKind.down
|
||||
&& ev.button == PointerEventButton.left) {
|
||||
root.changed(self.value-at(self.mouse-x));
|
||||
if (ev.kind == PointerEventKind.up
|
||||
|| ev.kind == PointerEventKind.cancel) {
|
||||
self.claimed = false;
|
||||
}
|
||||
// Right-click resets, alongside double-click.
|
||||
if (ev.kind == PointerEventKind.down
|
||||
@@ -171,6 +204,14 @@ component ParamSlider inherits Rectangle {
|
||||
root.reset();
|
||||
}
|
||||
}
|
||||
clicked => {
|
||||
// A press with no meaningful drag: jump to it. Handled
|
||||
// on release rather than on press so it cannot fire
|
||||
// during a scroll that merely started here.
|
||||
if (!self.claimed) {
|
||||
root.changed(self.value-at(self.mouse-x));
|
||||
}
|
||||
}
|
||||
double-clicked => {
|
||||
root.reset();
|
||||
}
|
||||
@@ -198,6 +239,10 @@ component CurveEditor inherits Rectangle {
|
||||
|
||||
property <int> point-count: root.data.points.length / 2;
|
||||
|
||||
// The point the pointer is over, or -1. Set by the grab targets below,
|
||||
// and used only to highlight the marker.
|
||||
in-out property <int> hovered-point: -1;
|
||||
|
||||
// Square: a tone curve is read as a deviation from the 45° diagonal, and
|
||||
// that reading only works if the axes share a scale.
|
||||
height: self.width;
|
||||
@@ -232,7 +277,7 @@ component CurveEditor inherits Rectangle {
|
||||
// Span the segment vertically, so a steep section stays joined.
|
||||
y: parent.height * (1.0 - max(s, self.next));
|
||||
height: max(parent.height * abs(self.next - s), 1.5px);
|
||||
background: Theme.accent;
|
||||
background: Theme.active;
|
||||
}
|
||||
|
||||
// Control points.
|
||||
@@ -247,61 +292,97 @@ component CurveEditor inherits Rectangle {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: root.active-point == idx ? Theme.accent : Theme.ink;
|
||||
// Grown and tinted when grabbable, so it is obvious where the
|
||||
// curve takes the gesture and where the panel scrolls instead.
|
||||
property <bool> live: root.active-point == idx
|
||||
|| root.hovered-point == idx;
|
||||
background: self.live ? Theme.active : Theme.ink;
|
||||
border-width: 1px;
|
||||
border-color: Theme.ground;
|
||||
}
|
||||
|
||||
// Catches the release even when the pointer has left the grab
|
||||
// target, and resets on double-click.
|
||||
area := TouchArea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// **One grab target per point, and nothing covering the rest.**
|
||||
//
|
||||
// Three constraints meet here, and only this arrangement satisfies
|
||||
// all of them:
|
||||
//
|
||||
// 1. The panel scrolls, and Slint cannot hand back a press once
|
||||
// taken — so an area spanning the plot would swallow every scroll
|
||||
// gesture beginning over the curve. Small targets leave the rest
|
||||
// of the plot free.
|
||||
// 2. `enabled: false` does not work as a gate: a disabled TouchArea
|
||||
// recognises *no* events at all, hover included, so it cannot
|
||||
// report where the pointer is in order to decide.
|
||||
// 3. A target positioned by its own point would slide out from under
|
||||
// the pointer on the first movement, stalling the drag. So while
|
||||
// a point is being dragged its target **freezes** at the press
|
||||
// position and grows to cover the plot, keeping the pointer
|
||||
// inside it however far the point travels.
|
||||
for idx in [0, 1, 2, 3, 4]: TouchArea {
|
||||
property <bool> exists: idx < root.point-count;
|
||||
property <bool> dragging: root.active-point == idx;
|
||||
|
||||
// Frozen and expanded while dragging; tracking the point
|
||||
// otherwise.
|
||||
x: self.dragging ? 0px
|
||||
: parent.width * root.data.points[idx * 2] - 14px;
|
||||
y: self.dragging ? 0px
|
||||
: parent.height * (1.0 - root.data.points[idx * 2 + 1]) - 14px;
|
||||
width: self.dragging ? parent.width : 28px;
|
||||
height: self.dragging ? parent.height : 28px;
|
||||
visible: self.exists;
|
||||
mouse-cursor: pointer;
|
||||
|
||||
// Highlights the marker, so it is visible where the curve takes
|
||||
// the gesture and where the panel scrolls instead.
|
||||
changed has-hover => {
|
||||
if (self.has-hover) {
|
||||
root.hovered-point = idx;
|
||||
} else if (root.hovered-point == idx) {
|
||||
root.hovered-point = -1;
|
||||
}
|
||||
}
|
||||
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.down
|
||||
&& ev.button == PointerEventButton.left) {
|
||||
root.active-point = idx;
|
||||
}
|
||||
if (ev.kind == PointerEventKind.up
|
||||
|| ev.kind == PointerEventKind.cancel) {
|
||||
root.active-point = -1;
|
||||
}
|
||||
}
|
||||
moved => {
|
||||
if (root.active-point >= 0) {
|
||||
if (self.dragging) {
|
||||
// Coordinates are relative to this area, which is the
|
||||
// whole plot while dragging — so no offset is needed.
|
||||
root.point-moved(
|
||||
root.active-point,
|
||||
idx,
|
||||
clamp(self.mouse-x / parent.width, 0.0, 1.0),
|
||||
clamp(1.0 - self.mouse-y / parent.height, 0.0, 1.0));
|
||||
}
|
||||
}
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.up) {
|
||||
root.active-point = -1;
|
||||
}
|
||||
}
|
||||
double-clicked => { root.reset(); }
|
||||
}
|
||||
|
||||
// One grab target per point, above the shared area so a press picks
|
||||
// the point under the pointer rather than the panel guessing.
|
||||
for idx in [0, 1, 2, 3, 4]: TouchArea {
|
||||
property <bool> exists: idx < root.point-count;
|
||||
x: parent.width * root.data.points[idx * 2] - 11px;
|
||||
y: parent.height * (1.0 - root.data.points[idx * 2 + 1]) - 11px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
enabled: self.exists;
|
||||
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.down) {
|
||||
root.active-point = idx;
|
||||
}
|
||||
}
|
||||
moved => {
|
||||
if (root.active-point == idx) {
|
||||
root.point-moved(
|
||||
idx,
|
||||
clamp((self.x + self.mouse-x) / parent.width, 0.0, 1.0),
|
||||
clamp(1.0 - (self.y + self.mouse-y) / parent.height, 0.0, 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The panel: a heading per operation, a control per parameter.
|
||||
// The panel: a collapsible section per operation, a control per parameter.
|
||||
//
|
||||
// **Why the loop is shaped the way it is.** `rows` is flat, and Slint can
|
||||
// neither slice a model nor nest a `for` over a run of it. What it *can* do is
|
||||
// repeat over an integer — `for n in row.group-len` — so each group's heading
|
||||
// row renders its whole group by indexing back into `rows` from `group-head`,
|
||||
// and every other row renders nothing. That puts the group's controls genuinely
|
||||
// *inside* its `Section`, which is what makes collapse a matter of the section
|
||||
// clipping its own body rather than each row hiding itself.
|
||||
//
|
||||
// It also means collapse state is the `Section`'s own — one `expanded` per
|
||||
// repeated element, keyed by position and so by `op-index`, never by label.
|
||||
// Slint keeps that state across row-*data* updates, which is what lets a
|
||||
// collapsed section stay collapsed while a slider elsewhere is dragged: the
|
||||
// panel is re-fed on every drag event.
|
||||
export component AdjustPanel inherits Rectangle {
|
||||
in property <[ParamRow]> rows;
|
||||
in property <bool> enabled: true;
|
||||
@@ -311,6 +392,9 @@ export component AdjustPanel inherits Rectangle {
|
||||
callback param-changed(int, int, float);
|
||||
callback param-reset(int, int);
|
||||
callback curve-reset(int);
|
||||
/// Return every parameter of one operation to its default — the reset on
|
||||
/// a section's own header, beside the panel-wide one.
|
||||
callback op-reset(int);
|
||||
callback reset-all();
|
||||
|
||||
background: Theme.surface;
|
||||
@@ -321,34 +405,21 @@ export component AdjustPanel inherits Rectangle {
|
||||
alignment: start;
|
||||
|
||||
HorizontalLayout {
|
||||
Text {
|
||||
text: "ADJUST";
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
PanelHeading { text: "ADJUST"; }
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
reset := TouchArea {
|
||||
width: 44px;
|
||||
height: 20px;
|
||||
clicked => { root.reset-all(); }
|
||||
Text {
|
||||
Label {
|
||||
text: "reset";
|
||||
color: reset.has-hover ? Theme.ink : Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
emphasised: reset.has-hover;
|
||||
horizontal-alignment: right;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !root.enabled: Text {
|
||||
text: "No image";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
}
|
||||
if !root.enabled: Caption { text: "No image"; }
|
||||
|
||||
if root.enabled: Flickable {
|
||||
viewport-height: content.preferred-height;
|
||||
@@ -357,48 +428,64 @@ export component AdjustPanel inherits Rectangle {
|
||||
spacing: 0px;
|
||||
alignment: start;
|
||||
|
||||
// One iteration per row, but only a group's *first* row draws
|
||||
// anything — and it draws the whole group. Every other row
|
||||
// renders nothing at all.
|
||||
for row[i] in root.rows: VerticalLayout {
|
||||
spacing: 0px;
|
||||
|
||||
// Section heading, driven by the flag the core set — this
|
||||
// file never asks "which operation is this".
|
||||
if row.starts-group: VerticalLayout {
|
||||
Rectangle { height: Theme.gap; }
|
||||
Text {
|
||||
text: row.op-label;
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
Rectangle { height: 2px; }
|
||||
}
|
||||
// `if` rather than a zero height: a hidden-but-present
|
||||
// section would still *build* its whole group, so every
|
||||
// control would exist once per row of its own group —
|
||||
// thirty-six live TouchAreas behind the colour mixer's
|
||||
// twelve visible ones. The conditional builds nothing.
|
||||
if row.group-head == i: Section {
|
||||
title: row.op-label;
|
||||
modified: row.group-modified;
|
||||
|
||||
if row.kind == "scalar": ParamSlider {
|
||||
data: row;
|
||||
// The section's reset. Placed here rather than in
|
||||
// `Section` itself because resetting is what *this*
|
||||
// panel's sections do; a section in another panel may
|
||||
// have nothing to reset.
|
||||
op-reset => { root.op-reset(row.op-index); }
|
||||
|
||||
// The group's own rows, addressed by offset from its
|
||||
// head. `root.rows[...]` rather than the loop's `row`:
|
||||
// this repeats over a count, so `n` is a number and
|
||||
// the row has to be fetched.
|
||||
for n in row.group-len: VerticalLayout {
|
||||
property <ParamRow> entry: root.rows[row.group-head + n];
|
||||
|
||||
spacing: 0px;
|
||||
|
||||
if entry.kind == "scalar": ParamSlider {
|
||||
data: entry;
|
||||
changed(v) => {
|
||||
root.param-changed(row.op-index, row.param-index, v);
|
||||
root.param-changed(
|
||||
entry.op-index, entry.param-index, v);
|
||||
}
|
||||
reset => {
|
||||
root.param-reset(row.op-index, row.param-index);
|
||||
root.param-reset(entry.op-index, entry.param-index);
|
||||
}
|
||||
}
|
||||
|
||||
if row.kind == "curve": CurveEditor {
|
||||
data: row;
|
||||
if entry.kind == "curve": CurveEditor {
|
||||
data: entry;
|
||||
samples: root.curve-samples;
|
||||
// A point carries two parameters, so the parameter
|
||||
// index is the row's base plus the point's offset.
|
||||
// This component still knows nothing about which
|
||||
// operation it belongs to.
|
||||
// A point carries two parameters, so the
|
||||
// parameter index is the row's base plus the
|
||||
// point's offset. This component still knows
|
||||
// nothing about which operation it belongs to.
|
||||
point-moved(point, x, y) => {
|
||||
root.param-changed(
|
||||
row.op-index, row.param-index + point * 2, x);
|
||||
entry.op-index, entry.param-index + point * 2, x);
|
||||
root.param-changed(
|
||||
row.op-index, row.param-index + point * 2 + 1, y);
|
||||
entry.op-index, entry.param-index + point * 2 + 1, y);
|
||||
}
|
||||
reset => {
|
||||
root.curve-reset(row.op-index);
|
||||
root.curve-reset(entry.op-index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+650
-74
@@ -1,6 +1,11 @@
|
||||
import { Theme } from "theme.slint";
|
||||
import { AdjustPanel, 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 { CollectionsPanel, CollectionRow } from "collections.slint";
|
||||
|
||||
export { LibraryCell, TimelineBar, CollectionRow }
|
||||
|
||||
// Status strip — surfaces the GPU backend and adapter, which matters during
|
||||
// v0.1 because assumption A1 is exactly "does this compositing path work on
|
||||
@@ -13,8 +18,15 @@ component StatusBar inherits Rectangle {
|
||||
in property <int> fps;
|
||||
in property <string> filename;
|
||||
in property <string> position;
|
||||
/// Only offered where there is a library to go back to — with files named
|
||||
/// on the command line there is no grid behind this view.
|
||||
in property <bool> can-return-to-library: false;
|
||||
|
||||
height: 28px;
|
||||
callback back-to-library();
|
||||
|
||||
// Tall enough for a Button to sit in without the strip growing: the
|
||||
// control height and this strip are both 28px by design.
|
||||
height: Theme.control-height;
|
||||
background: Theme.surface;
|
||||
|
||||
HorizontalLayout {
|
||||
@@ -23,44 +35,37 @@ component StatusBar inherits Rectangle {
|
||||
spacing: Theme.gap;
|
||||
alignment: start;
|
||||
|
||||
Text {
|
||||
text: root.backend;
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
vertical-alignment: center;
|
||||
// Kept instantiated with `visible` rather than wrapped in an `if`:
|
||||
// the strip is inside the layout that `expanded` feeds, and adding a
|
||||
// conditional child here is the shape that has already caused binding
|
||||
// loops in this file (see the panel below).
|
||||
Button {
|
||||
text: "‹ Library";
|
||||
visible: root.can-return-to-library;
|
||||
clicked => { root.back-to-library(); }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.filename;
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 600;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
// The backend is an identifying label, not a state — it says which
|
||||
// path is in use, and it says the same thing whether that path is
|
||||
// fast or slow. The accent it used to carry made every frame look
|
||||
// like an alert; `fps` below is the thing here that can go wrong.
|
||||
Label { text: root.backend; }
|
||||
|
||||
Text {
|
||||
text: root.position;
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Value { text: root.filename; compact: true; overflow: elide; }
|
||||
|
||||
Caption { text: root.position; }
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
Text {
|
||||
text: root.layout-class;
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Caption { text: root.layout-class; }
|
||||
|
||||
Text {
|
||||
// Degraded performance, so this is a caution rather than an active
|
||||
// state: the frame rate has fallen below what the compositing path is
|
||||
// supposed to sustain, which is exactly the assumption A1 exists to
|
||||
// test. `warn` is the one hue left in the palette and this earns it.
|
||||
Caption {
|
||||
text: root.fps + " fps";
|
||||
color: root.fps >= 55 ? Theme.ink-dim : Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
warn: root.fps < 55;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,40 +83,26 @@ component InfoPanel inherits Rectangle {
|
||||
in property <string> exposure;
|
||||
in property <string> dimensions;
|
||||
|
||||
background: Theme.surface;
|
||||
height: self.preferred-height;
|
||||
background: transparent;
|
||||
height: panel.preferred-height;
|
||||
|
||||
VerticalLayout {
|
||||
padding: Theme.gap;
|
||||
spacing: Theme.gap-sm;
|
||||
alignment: start;
|
||||
panel := Panel {
|
||||
// Flat: this abuts the adjust panel below it and the rule between
|
||||
// them is drawn by the column that stacks the two.
|
||||
flat: true;
|
||||
width: 100%;
|
||||
|
||||
Text {
|
||||
text: "IMAGE";
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
PanelHeading { text: "IMAGE"; }
|
||||
|
||||
Text {
|
||||
Value {
|
||||
text: root.camera == "" ? "—" : root.camera;
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text;
|
||||
placeholder: root.camera == "";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.exposure;
|
||||
color: Theme.ink-dim;
|
||||
font-size: Theme.text-sm;
|
||||
}
|
||||
Label { text: root.exposure; }
|
||||
|
||||
Text {
|
||||
text: root.dimensions;
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
}
|
||||
Caption { text: root.dimensions; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +129,34 @@ export component AppWindow inherits Window {
|
||||
in property <int> total: 0;
|
||||
in property <string> load-error: "";
|
||||
|
||||
// --- zoom, pan and crop (FR-DEV-4) ---
|
||||
//
|
||||
// Zoom is a *viewing* state, not an edit: it changes the resolution the
|
||||
// pipeline renders at, never what the file becomes. Rust owns the actual
|
||||
// rect; these carry only what the interface has to draw.
|
||||
in property <float> zoom: 1.0;
|
||||
in property <bool> zoomed: false;
|
||||
|
||||
/// Whether the crop overlay is active. While it is, the canvas shows the
|
||||
/// *whole* frame — otherwise the area being cropped away would not be on
|
||||
/// screen to drag across — and the surround is greyed.
|
||||
in-out property <bool> crop-mode: false;
|
||||
|
||||
/// The crop rect in fractions of the frame, mirrored from Rust so the
|
||||
/// overlay draws exactly what the pipeline holds.
|
||||
in-out property <float> crop-x: 0.0;
|
||||
in-out property <float> crop-y: 0.0;
|
||||
in-out property <float> crop-w: 1.0;
|
||||
in-out property <float> crop-h: 1.0;
|
||||
|
||||
callback crop-mode-toggled(bool);
|
||||
/// A dragged crop rect, in fractions of the frame.
|
||||
callback crop-changed(float, float, float, float);
|
||||
/// Scroll-to-zoom: factor, and the anchor in fractions of the visible area.
|
||||
callback zoom-at(float, float, float);
|
||||
callback pan-by(float, float);
|
||||
callback zoom-reset();
|
||||
|
||||
// --- launch screen (FR-NC-1, FR-NC-4) ---
|
||||
//
|
||||
// The app opens here when no library is configured, and returns here to
|
||||
@@ -172,6 +191,145 @@ export component AppWindow inherits Window {
|
||||
callback launch-browse-confirm();
|
||||
callback launch-browse-cancel();
|
||||
|
||||
// --- library grid (FR-CAT-4) ---
|
||||
//
|
||||
// Shown after a library is opened, before an image is chosen. Cells are a
|
||||
// window over the catalog, not the whole of it.
|
||||
in property <bool> show-library: false;
|
||||
in-out property <[LibraryCell]> library-cells;
|
||||
in property <int> library-total: 0;
|
||||
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;
|
||||
|
||||
in property <string> library-root-label: "";
|
||||
in-out property <[TimelineBar]> library-timeline;
|
||||
in property <string> library-timeline-label: "";
|
||||
in property <string> library-window-label: "";
|
||||
in property <int> library-offset: 0;
|
||||
|
||||
in property <int> library-sweep-done: 0;
|
||||
in property <int> library-sweep-total: 0;
|
||||
|
||||
in property <int> library-current-bucket: 0;
|
||||
in property <int> library-current-index: -1;
|
||||
in property <bool> library-timeline-anchored: false;
|
||||
|
||||
callback library-scrub(int);
|
||||
callback library-timeline-pan(int);
|
||||
callback library-timeline-zoom(int);
|
||||
in-out property <int> library-columns: 1;
|
||||
in property <bool> library-syncing: false;
|
||||
in property <int> library-scroll-to: 0;
|
||||
in property <int> library-scroll-token: 0;
|
||||
callback library-sync-now();
|
||||
callback library-columns-changed();
|
||||
callback library-scrolled(int);
|
||||
callback library-capacity(int);
|
||||
callback library-cell-clicked(int);
|
||||
callback library-rescan();
|
||||
/// Grid → launch screen, to pick a different folder or account.
|
||||
callback library-change();
|
||||
/// Develop → grid.
|
||||
callback back-to-library();
|
||||
|
||||
// --- collections (FR-CAT-7) ---
|
||||
//
|
||||
// The sidebar's tree, flattened in Rust: Slint cannot instantiate a
|
||||
// component recursively, so depth arrives as an integer per row.
|
||||
in-out property <[CollectionRow]> collection-rows;
|
||||
/// Which collection scopes the grid. 0 is the whole library.
|
||||
in property <int> collection-selected: 0;
|
||||
in property <string> collection-scope-label: "";
|
||||
in property <string> collection-error: "";
|
||||
/// Whether the sidebar is shown at all. Collapsed in the compact layout,
|
||||
/// where 232px of the window is most of the photograph (FR-UI-1).
|
||||
in property <bool> collections-visible: true;
|
||||
|
||||
/// Which collection is being renamed in the sidebar, by id. 0 is none.
|
||||
in property <int> collection-renaming: 0;
|
||||
|
||||
callback collection-select(int);
|
||||
callback collection-toggle(int);
|
||||
callback collection-new();
|
||||
callback collection-menu(int);
|
||||
/// Renaming started on a row, by id — a double-click, or `F2`.
|
||||
callback collection-rename-start(int);
|
||||
/// A rename was committed: the collection's id and the new name.
|
||||
callback collection-rename-commit(int, string);
|
||||
/// Renaming was abandoned with Escape.
|
||||
callback collection-rename-cancel();
|
||||
/// Row index and collection id under the pointer. The id is what the
|
||||
/// drop highlight follows: a spring expansion rebuilds the row model
|
||||
/// mid-drag, and an index would then point at a different collection.
|
||||
/// A drag is dwelling over a collection, or has left it: id, and whether
|
||||
/// it is over. Drives the spring-loaded expansion, timed in Rust.
|
||||
callback collection-drag-over(int, bool);
|
||||
/// Images were dropped on a collection, by id. Slint hit-tests the release,
|
||||
/// so this is the collection actually under the pointer.
|
||||
callback collection-dropped(int);
|
||||
|
||||
// --- trash (FR-CAT-15) ---
|
||||
//
|
||||
// Not a collection: a drop here moves the file into a trash folder on the
|
||||
// server, where a collection drop only adds a reference.
|
||||
in property <int> trash-count: 0;
|
||||
in property <string> trash-label: "";
|
||||
/// A soft delete: move the dragged images to the trash folder.
|
||||
callback trash-dropped();
|
||||
/// A hard delete: permanently remove everything in the trash.
|
||||
callback trash-empty();
|
||||
/// Put the selected trashed images back where they came from.
|
||||
callback trash-restore();
|
||||
|
||||
// --- drag and drop ---
|
||||
//
|
||||
// Slint's own `DragArea`/`DropArea` carry this: pointer capture, the
|
||||
// click-versus-drag threshold, arbitration against the grid's Flickable,
|
||||
// and the image drawn under the cursor. So there is no badge to position
|
||||
// and no hover state to mirror here — only the payload, which Rust builds
|
||||
// from the selection, and the count the header shows.
|
||||
/// The drag payload, built by Rust from the current selection.
|
||||
pure callback library-drag-payload() -> data-transfer;
|
||||
/// The bitmap that travels under the cursor: one thumbnail, or a fanned
|
||||
/// stack where several images are being dragged. Composited in Rust.
|
||||
in property <image> library-drag-image;
|
||||
/// A drag began on a cell, so an unselected one can be promoted before the
|
||||
/// payload is read.
|
||||
callback library-drag-started(int);
|
||||
callback library-drag-finished();
|
||||
in property <int> library-selected-count: 0;
|
||||
|
||||
callback library-cell-pressed(int, bool, bool);
|
||||
callback library-remove-from-collection();
|
||||
|
||||
// --- ratings and flags (FR-CAT-5, FR-CULL-4) ---
|
||||
//
|
||||
// Stars and pick/reject, set from the grid and persisted to the catalog
|
||||
// and the sidecar. Every image enters unrated, which is a state of its
|
||||
// own rather than a zero score.
|
||||
/// A star was clicked on a cell: row, then the rating 0..5.
|
||||
callback library-cell-rated(int, int);
|
||||
/// The trash target was clicked on one cell, by row.
|
||||
callback library-cell-trashed(int);
|
||||
/// Move the grid selection to the trash — the `Delete` key.
|
||||
callback library-trash-selection();
|
||||
/// A judgement key was pressed, applying to the whole selection. One of
|
||||
/// the two arguments is -1, saying which axis was *not* meant.
|
||||
callback library-judged(int, int);
|
||||
|
||||
in property <int> library-filter-min-rating: 0;
|
||||
in property <bool> library-filter-unjudged: false;
|
||||
in property <int> library-filter-flag: 0;
|
||||
/// Images per star count, index 0 unrated, for the filter chips.
|
||||
in-out property <[int]> library-rating-counts;
|
||||
|
||||
callback library-filter-min-rating-changed(int);
|
||||
callback library-filter-unjudged-changed(bool);
|
||||
callback library-filter-flag-changed(int);
|
||||
|
||||
callback next-image();
|
||||
callback prev-image();
|
||||
|
||||
@@ -238,7 +396,143 @@ export component AppWindow inherits Window {
|
||||
browse-cancel() => { root.launch-browse-cancel(); }
|
||||
}
|
||||
|
||||
if !root.show-launch: VerticalLayout {
|
||||
// The library view sits between the launch screen and develop: a library
|
||||
// has been opened but no image chosen yet. The collections sidebar and the
|
||||
// grid are siblings here rather than the sidebar living inside the grid,
|
||||
// because the drag that connects them has to be owned above both.
|
||||
if !root.show-launch && root.show-library: Rectangle {
|
||||
background: Theme.ground;
|
||||
|
||||
HorizontalLayout {
|
||||
// Always instantiated, width collapsed to zero when hidden — the
|
||||
// same reasoning as the adjust panel below: an `if` here depends on
|
||||
// the layout class, which derives from the window width, which the
|
||||
// layout then influences. Slint flags that loop and it can panic.
|
||||
Rectangle {
|
||||
width: root.collections-visible ? 232px : 0px;
|
||||
visible: root.collections-visible;
|
||||
horizontal-stretch: 0;
|
||||
|
||||
CollectionsPanel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
rows: root.collection-rows;
|
||||
selected-id: root.collection-selected;
|
||||
total-images: root.library-total;
|
||||
error: root.collection-error;
|
||||
|
||||
select(id) => { root.collection-select(id); }
|
||||
toggle(id) => { root.collection-toggle(id); }
|
||||
new-collection() => { root.collection-new(); }
|
||||
row-menu(id) => { root.collection-menu(id); }
|
||||
|
||||
renaming-id: root.collection-renaming;
|
||||
rename-start(id) => { root.collection-rename-start(id); }
|
||||
rename-commit(id, name) => {
|
||||
root.collection-rename-commit(id, name);
|
||||
}
|
||||
rename-cancel() => { root.collection-rename-cancel(); }
|
||||
trash-count: root.trash-count;
|
||||
trash-label: root.trash-label;
|
||||
selected-count: root.library-selected-count;
|
||||
|
||||
dropped-on(id) => { root.collection-dropped(id); }
|
||||
dropped-on-trash() => { root.trash-dropped(); }
|
||||
empty-trash() => { root.trash-empty(); }
|
||||
restore-selected() => { root.trash-restore(); }
|
||||
drag-over(id, over) => { root.collection-drag-over(id, over); }
|
||||
}
|
||||
}
|
||||
|
||||
LibraryGrid {
|
||||
horizontal-stretch: 1;
|
||||
cells: root.library-cells;
|
||||
total: root.library-total;
|
||||
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;
|
||||
root-label: root.library-root-label;
|
||||
timeline: root.library-timeline;
|
||||
timeline-label: root.library-timeline-label;
|
||||
window-label: root.library-window-label;
|
||||
offset: root.library-offset;
|
||||
selected-count: root.library-selected-count;
|
||||
scope-label: root.collection-scope-label;
|
||||
|
||||
sweep-done: root.library-sweep-done;
|
||||
sweep-total: root.library-sweep-total;
|
||||
|
||||
current-bucket: root.library-current-bucket;
|
||||
current-bucket-index: root.library-current-index;
|
||||
timeline-anchored: root.library-timeline-anchored;
|
||||
|
||||
scrub(t) => { root.library-scrub(t); }
|
||||
timeline-pan(d) => { root.library-timeline-pan(d); }
|
||||
timeline-zoom(d) => { root.library-timeline-zoom(d); }
|
||||
syncing: root.library-syncing;
|
||||
scroll-to: root.library-scroll-to;
|
||||
scroll-token: root.library-scroll-token;
|
||||
sync-now() => { root.library-sync-now(); }
|
||||
columns-changed(n) => {
|
||||
root.library-columns = n;
|
||||
// Which cell begins a row just changed, and month headings sit on
|
||||
// row-leading cells — so they must be recomputed, not just moved.
|
||||
root.library-columns-changed();
|
||||
}
|
||||
scrolled(i) => { root.library-scrolled(i); }
|
||||
capacity-changed(n) => { root.library-capacity(n); }
|
||||
cell-clicked(i) => { root.library-cell-clicked(i); }
|
||||
rescan() => { root.library-rescan(); }
|
||||
change-library() => { root.library-change(); }
|
||||
|
||||
cell-pressed(i, ctrl, shift) => {
|
||||
root.library-cell-pressed(i, ctrl, shift);
|
||||
}
|
||||
drag-image: root.library-drag-image;
|
||||
drag-payload() => { return root.library-drag-payload(); }
|
||||
drag-started(i) => { root.library-drag-started(i); }
|
||||
drag-finished() => { root.library-drag-finished(); }
|
||||
remove-from-collection() => {
|
||||
root.library-remove-from-collection();
|
||||
}
|
||||
|
||||
filter-min-rating: root.library-filter-min-rating;
|
||||
filter-unjudged: root.library-filter-unjudged;
|
||||
filter-flag: root.library-filter-flag;
|
||||
rating-counts: root.library-rating-counts;
|
||||
|
||||
cell-rated(i, n) => { root.library-cell-rated(i, n); }
|
||||
cell-trashed(i) => { root.library-cell-trashed(i); }
|
||||
trash-selection() => { root.library-trash-selection(); }
|
||||
// Derived from the sidebar's own selection rather than
|
||||
// mirrored in a second property: `-1` is already the sentinel
|
||||
// for the trash row (see collections.slint), and two sources
|
||||
// for one fact is how they come to disagree.
|
||||
viewing-trash: root.collection-selected == -1;
|
||||
judged(rating, flag) => { root.library-judged(rating, flag); }
|
||||
// `F2` renames whatever the grid is scoped to. Answered from
|
||||
// the sidebar's selection rather than a second property, for
|
||||
// the same reason `viewing-trash` above is: one fact, one
|
||||
// source. Ids at or below 0 are "all photographs" and the
|
||||
// trash, neither of which has a name to change — Rust drops
|
||||
// those rather than the binding trying to reason about it.
|
||||
rename-scope() => {
|
||||
root.collection-rename-start(root.collection-selected);
|
||||
}
|
||||
filter-min-rating-changed(n) => {
|
||||
root.library-filter-min-rating-changed(n);
|
||||
}
|
||||
filter-unjudged-toggled(on) => {
|
||||
root.library-filter-unjudged-changed(on);
|
||||
}
|
||||
filter-flag-changed(f) => { root.library-filter-flag-changed(f); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !root.show-launch && !root.show-library: VerticalLayout {
|
||||
StatusBar {
|
||||
adapter: root.adapter;
|
||||
backend: root.backend;
|
||||
@@ -246,6 +540,10 @@ export component AppWindow inherits Window {
|
||||
fps: root.fps;
|
||||
filename: root.filename;
|
||||
position: root.total > 0 ? root.index + 1 + " / " + root.total : "";
|
||||
// Only where a library was opened; command-line files have no
|
||||
// grid to return to.
|
||||
can-return-to-library: root.library-total > 0;
|
||||
back-to-library() => { root.back-to-library(); }
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
@@ -256,7 +554,7 @@ export component AppWindow inherits Window {
|
||||
background: Theme.ground;
|
||||
clip: true;
|
||||
|
||||
Image {
|
||||
canvas-image := Image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
source: root.canvas;
|
||||
@@ -264,24 +562,271 @@ export component AppWindow inherits Window {
|
||||
visible: root.total > 0 && root.load-error == "";
|
||||
}
|
||||
|
||||
// Where the photograph actually sits inside this box.
|
||||
//
|
||||
// `image-fit: contain` letterboxes, and Slint does not report
|
||||
// the fitted rect, so it is derived from the source's aspect.
|
||||
// The overlay is placed against *this*, not against the whole
|
||||
// area — otherwise the crop rect drifts off the picture on any
|
||||
// window whose shape differs from the image's.
|
||||
property <float> src-w: root.canvas.width > 0 ? root.canvas.width : 1;
|
||||
property <float> src-h: root.canvas.height > 0 ? root.canvas.height : 1;
|
||||
property <float> box-w: self.width / 1px;
|
||||
property <float> box-h: self.height / 1px;
|
||||
property <float> fit-scale: min(self.box-w / self.src-w, self.box-h / self.src-h);
|
||||
property <length> shown-w: self.src-w * self.fit-scale * 1px;
|
||||
property <length> shown-h: self.src-h * self.fit-scale * 1px;
|
||||
property <length> shown-x: (self.width - self.shown-w) / 2;
|
||||
property <length> shown-y: (self.height - self.shown-h) / 2;
|
||||
|
||||
// Empty and error states say what happened rather than
|
||||
// showing a blank canvas.
|
||||
if root.total == 0 || root.load-error != "": VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: Theme.gap;
|
||||
Text {
|
||||
text: root.load-error != "" ? "Could not load image" : "No images";
|
||||
color: Theme.ink-dim;
|
||||
font-size: Theme.text-lg;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
Text {
|
||||
text: root.load-error != "" ? root.load-error
|
||||
if root.total == 0 || root.load-error != "": EmptyState {
|
||||
headline: root.load-error != "" ? "Could not load image" : "No images";
|
||||
detail: root.load-error != "" ? root.load-error
|
||||
: "Pass a folder or file on the command line.";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
horizontal-alignment: center;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
// --- zoom and pan ------------------------------------------
|
||||
//
|
||||
// Below the crop overlay in z-order so that, in crop mode, the
|
||||
// handles take the drag instead. Scroll still zooms either way.
|
||||
if root.total > 0 && root.load-error == "": TouchArea {
|
||||
x: 0; y: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// A pan is only meaningful once there is something outside
|
||||
// the viewport to reach.
|
||||
mouse-cursor: root.zoomed ? MouseCursor.grab : MouseCursor.default;
|
||||
enabled: !root.crop-mode;
|
||||
|
||||
property <length> last-x;
|
||||
property <length> last-y;
|
||||
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.down) {
|
||||
self.last-x = self.mouse-x;
|
||||
self.last-y = self.mouse-y;
|
||||
}
|
||||
}
|
||||
|
||||
moved => {
|
||||
if (self.pressed && root.zoomed) {
|
||||
// Fractions of the *visible* area, which is what
|
||||
// the session's pan expects. Negated: dragging
|
||||
// right moves the image right, so the window onto
|
||||
// it moves left.
|
||||
root.pan-by(
|
||||
-(self.mouse-x - self.last-x) / max(parent.shown-w, 1px),
|
||||
-(self.mouse-y - self.last-y) / max(parent.shown-h, 1px),
|
||||
);
|
||||
self.last-x = self.mouse-x;
|
||||
self.last-y = self.mouse-y;
|
||||
}
|
||||
}
|
||||
|
||||
scroll-event(ev) => {
|
||||
if (ev.delta-y == 0) {
|
||||
return reject;
|
||||
}
|
||||
// Anchored on the pointer, in fractions of the shown
|
||||
// image, so whatever is under the cursor stays there.
|
||||
root.zoom-at(
|
||||
ev.delta-y > 0 ? 1.15 : 1.0 / 1.15,
|
||||
(self.mouse-x - parent.shown-x) / max(parent.shown-w, 1px),
|
||||
(self.mouse-y - parent.shown-y) / max(parent.shown-h, 1px),
|
||||
);
|
||||
return accept;
|
||||
}
|
||||
|
||||
double-clicked => { root.zoom-reset(); }
|
||||
}
|
||||
|
||||
// --- crop overlay ------------------------------------------
|
||||
//
|
||||
// Four dimmed, desaturated panels around the crop, then the
|
||||
// rect itself with handles. Panels rather than one shape with
|
||||
// a hole: Slint has no cut-out, and four rectangles are exact.
|
||||
if root.crop-mode && root.total > 0 && root.load-error == "": crop-overlay := Rectangle {
|
||||
x: parent.shown-x;
|
||||
y: parent.shown-y;
|
||||
width: parent.shown-w;
|
||||
height: parent.shown-h;
|
||||
|
||||
property <length> rx: root.crop-x * self.width;
|
||||
property <length> ry: root.crop-y * self.height;
|
||||
property <length> rw: root.crop-w * self.width;
|
||||
property <length> rh: root.crop-h * self.height;
|
||||
|
||||
// The surround: dimmed and drained of colour, so the crop
|
||||
// reads as the photograph and everything else as context
|
||||
// being discarded. Slint cannot desaturate a live image,
|
||||
// so this is a heavy neutral wash over it — the dimming
|
||||
// carries the separation and the neutrality kills the
|
||||
// colour cue.
|
||||
property <brush> veil: #20242aE0;
|
||||
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: 0;
|
||||
width: parent.width;
|
||||
height: crop-overlay.ry;
|
||||
background: crop-overlay.veil;
|
||||
}
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: crop-overlay.ry + crop-overlay.rh;
|
||||
width: parent.width;
|
||||
height: parent.height - crop-overlay.ry - crop-overlay.rh;
|
||||
background: crop-overlay.veil;
|
||||
}
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: crop-overlay.ry;
|
||||
width: crop-overlay.rx;
|
||||
height: crop-overlay.rh;
|
||||
background: crop-overlay.veil;
|
||||
}
|
||||
Rectangle {
|
||||
x: crop-overlay.rx + crop-overlay.rw;
|
||||
y: crop-overlay.ry;
|
||||
width: parent.width - crop-overlay.rx - crop-overlay.rw;
|
||||
height: crop-overlay.rh;
|
||||
background: crop-overlay.veil;
|
||||
}
|
||||
|
||||
// The rect, its thirds, and the handles.
|
||||
Rectangle {
|
||||
x: parent.rx;
|
||||
y: parent.ry;
|
||||
width: parent.rw;
|
||||
height: parent.rh;
|
||||
border-width: 1px;
|
||||
border-color: #ffffffCC;
|
||||
|
||||
// Rule of thirds, the reason a crop overlay is worth
|
||||
// drawing at all rather than typing numbers.
|
||||
Rectangle {
|
||||
x: parent.width / 3;
|
||||
y: 0; width: 1px; height: parent.height;
|
||||
background: #ffffff44;
|
||||
}
|
||||
Rectangle {
|
||||
x: parent.width * 2 / 3;
|
||||
y: 0; width: 1px; height: parent.height;
|
||||
background: #ffffff44;
|
||||
}
|
||||
Rectangle {
|
||||
x: 0; y: parent.height / 3;
|
||||
width: parent.width; height: 1px;
|
||||
background: #ffffff44;
|
||||
}
|
||||
Rectangle {
|
||||
x: 0; y: parent.height * 2 / 3;
|
||||
width: parent.width; height: 1px;
|
||||
background: #ffffff44;
|
||||
}
|
||||
|
||||
// Drag the whole rect.
|
||||
move-area := TouchArea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
mouse-cursor: MouseCursor.move;
|
||||
|
||||
property <float> start-x;
|
||||
property <float> start-y;
|
||||
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.down) {
|
||||
self.start-x = root.crop-x;
|
||||
self.start-y = root.crop-y;
|
||||
}
|
||||
}
|
||||
|
||||
moved => {
|
||||
if (self.pressed) {
|
||||
root.crop-changed(
|
||||
self.start-x
|
||||
+ (self.mouse-x - self.pressed-x)
|
||||
/ max(crop-overlay.width, 1px),
|
||||
self.start-y
|
||||
+ (self.mouse-y - self.pressed-y)
|
||||
/ max(crop-overlay.height, 1px),
|
||||
root.crop-w,
|
||||
root.crop-h,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Corner handles. Each drags one corner while the opposite
|
||||
// stays put, which is the only behaviour that lets a crop
|
||||
// be shaped rather than merely moved.
|
||||
for corner in [
|
||||
{ hx: 0.0, hy: 0.0 },
|
||||
{ hx: 1.0, hy: 0.0 },
|
||||
{ hx: 0.0, hy: 1.0 },
|
||||
{ hx: 1.0, hy: 1.0 },
|
||||
]: Rectangle {
|
||||
property <length> size: 18px;
|
||||
x: parent.rx + corner.hx * parent.rw - self.size / 2;
|
||||
y: parent.ry + corner.hy * parent.rh - self.size / 2;
|
||||
width: self.size;
|
||||
height: self.size;
|
||||
|
||||
Rectangle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #ffffff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
TouchArea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
mouse-cursor: (corner.hx == corner.hy)
|
||||
? MouseCursor.nwse-resize
|
||||
: MouseCursor.nesw-resize;
|
||||
|
||||
property <float> ox;
|
||||
property <float> oy;
|
||||
property <float> ow;
|
||||
property <float> oh;
|
||||
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.down) {
|
||||
self.ox = root.crop-x;
|
||||
self.oy = root.crop-y;
|
||||
self.ow = root.crop-w;
|
||||
self.oh = root.crop-h;
|
||||
}
|
||||
}
|
||||
|
||||
// Movement as a fraction of the frame, live while
|
||||
// the handle is held.
|
||||
property <float> dx:
|
||||
(self.mouse-x - self.pressed-x) / max(crop-overlay.width, 1px);
|
||||
property <float> dy:
|
||||
(self.mouse-y - self.pressed-y) / max(crop-overlay.height, 1px);
|
||||
|
||||
moved => {
|
||||
if (!self.pressed) {
|
||||
return;
|
||||
}
|
||||
// Dragging a left/top handle moves the origin
|
||||
// and shrinks the extent by the same amount;
|
||||
// a right/bottom handle moves only the extent.
|
||||
// Rust clamps the result, so an over-drag
|
||||
// slides rather than inverting.
|
||||
root.crop-changed(
|
||||
corner.hx == 0 ? self.ox + self.dx : self.ox,
|
||||
corner.hy == 0 ? self.oy + self.dy : self.oy,
|
||||
corner.hx == 0 ? self.ow - self.dx : self.ow + self.dx,
|
||||
corner.hy == 0 ? self.oh - self.dy : self.oh + self.dy,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +845,31 @@ export component AppWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
// Crop and zoom controls, floating over the canvas so they are
|
||||
// reachable whether or not the side panel is showing.
|
||||
if root.total > 0 && root.load-error == "": HorizontalLayout {
|
||||
x: 12px;
|
||||
y: parent.height - self.preferred-height - 12px;
|
||||
spacing: 6px;
|
||||
|
||||
Button {
|
||||
text: root.crop-mode ? "Done" : "Crop";
|
||||
active: root.crop-mode;
|
||||
clicked => { root.crop-mode-toggled(!root.crop-mode); }
|
||||
}
|
||||
|
||||
// Zoom is a view state, so its readout doubles as the
|
||||
// control that clears it.
|
||||
Button {
|
||||
text: root.zoomed
|
||||
? Math.round(root.zoom * 100) + "%"
|
||||
: "Fit";
|
||||
active: root.zoomed;
|
||||
enabled: root.zoomed;
|
||||
clicked => { root.zoom-reset(); }
|
||||
}
|
||||
}
|
||||
|
||||
// Report size changes so the render target can be resized to
|
||||
// match. Width and height are tracked separately because
|
||||
// Slint has no single "geometry changed" hook.
|
||||
@@ -343,6 +913,12 @@ export component AppWindow inherits Window {
|
||||
root.param-reset(op, param);
|
||||
}
|
||||
curve-reset(op) => { root.curve-reset(op); }
|
||||
// A section's reset and a curve's reset are the same
|
||||
// action — every parameter of one operation back to
|
||||
// its default — so they share the one callback rather
|
||||
// than duplicating a handler that would have to be
|
||||
// kept in step with it.
|
||||
op-reset(op) => { root.curve-reset(op); }
|
||||
reset-all => { root.reset-all(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
// The collections sidebar: a hierarchy of collections, and a drop target for
|
||||
// images dragged out of the grid.
|
||||
//
|
||||
// TRACES: FR-CAT-7 | FR-UI-3 | FR-UI-5
|
||||
//
|
||||
// # Why the tree is flat here
|
||||
//
|
||||
// Slint has no recursive component instantiation, so a `for` cannot nest itself
|
||||
// to arbitrary depth. The tree arrives from Rust already flattened, each row
|
||||
// carrying its own `depth` — indentation is drawn from that integer. The
|
||||
// ordering rules (siblings by name, children after their parent) live in
|
||||
// `dr_catalog::collections::tree`, which is where the data model already is.
|
||||
//
|
||||
// # Drag and drop
|
||||
//
|
||||
// Each row is a `DropArea`, and the grid's cells are `DragArea`s. Slint owns the
|
||||
// gesture: pointer capture, the threshold that separates a click from a drag,
|
||||
// arbitration against the grid's `Flickable`, the image under the cursor, and
|
||||
// delivery of the payload to whichever row the pointer is actually over.
|
||||
//
|
||||
// This replaced a hand-rolled version that tracked presses through `TouchArea`
|
||||
// and is worth recording, because the failure was not obvious: an interactive
|
||||
// `Flickable` claims any drag beginning inside it for scrolling and *cancels*
|
||||
// the child TouchArea's press, so the gesture could never leave the grid. The
|
||||
// hand-rolled version also had to decide the drop target from the last row
|
||||
// hovered, since a captured pointer is invisible to everything else — which
|
||||
// meant a tree rebuilt mid-drag could redirect the drop. `DropArea` hit-tests
|
||||
// the release itself, so neither problem exists.
|
||||
//
|
||||
// `can-drop` runs while the cursor moves and decides whether this row will
|
||||
// accept — a saved filter refuses there, so the cursor says no *before* the
|
||||
// release rather than the drop being silently discarded after it.
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { Button } from "widgets.slint";
|
||||
|
||||
// One row of the collection tree.
|
||||
export struct CollectionRow {
|
||||
id: int,
|
||||
name: string,
|
||||
// 0 for a top-level collection. Indentation is drawn from this.
|
||||
depth: int,
|
||||
// Direct members. A parent shows this and its descendants' total
|
||||
// separately: an empty set of full children must not read as full.
|
||||
direct-count: int,
|
||||
// Distinct images including descendants'.
|
||||
deep-count: int,
|
||||
has-children: bool,
|
||||
expanded: bool,
|
||||
// A saved filter. Cannot be dropped onto — its membership is its selector.
|
||||
smart: bool,
|
||||
}
|
||||
|
||||
// A collection row: disclosure arrow, name, count, and a drop highlight.
|
||||
component TreeRow inherits Rectangle {
|
||||
in property <CollectionRow> entry;
|
||||
in property <bool> selected;
|
||||
/// Whether a drag hovering here could land. A saved filter's membership is
|
||||
/// computed from its selector, so it refuses — and says so on hover.
|
||||
in property <bool> drop-allowed: true;
|
||||
/// Whether this row is being renamed, in which case its name is drawn as an
|
||||
/// editable field rather than as text.
|
||||
in property <bool> renaming: false;
|
||||
|
||||
callback clicked();
|
||||
callback toggle();
|
||||
/// The pointer is dwelling here mid-drag. What springs a collapsed parent
|
||||
/// open, so a child can be reached without ending the drag.
|
||||
callback drag-over(bool);
|
||||
/// Images were dropped on this row.
|
||||
callback dropped();
|
||||
callback context-menu();
|
||||
/// Start renaming this row — a double-click on the name.
|
||||
callback rename-requested();
|
||||
/// The new name, committed with Enter or by clicking away.
|
||||
callback rename-committed(string);
|
||||
/// Renaming abandoned with Escape; the old name stands.
|
||||
callback rename-cancelled();
|
||||
|
||||
height: Theme.row-height;
|
||||
|
||||
// `drop.has-drag` is only true once `can-drop` has accepted, so a refused
|
||||
// row never lights up as though it would take the images.
|
||||
background: drop.has-drag
|
||||
? Theme.selected
|
||||
: (root.selected ? Theme.surface-raised
|
||||
: (touch.has-hover ? Theme.hover : transparent));
|
||||
border-radius: Theme.radius-sm;
|
||||
|
||||
// A drop target is outlined as well as filled: on a dark ground a fill
|
||||
// change alone is easy to miss mid-drag, when the user is watching the
|
||||
// thumbnail under the cursor rather than the row.
|
||||
border-width: drop.has-drag ? 1px : 0px;
|
||||
border-color: Theme.selected-ring;
|
||||
|
||||
// Behind the content, so the row's own TouchArea still gets ordinary
|
||||
// clicks. A DropArea only takes part in a drag; it does not block presses.
|
||||
drop := DropArea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
can-drop(ev) => {
|
||||
// Refused here rather than after the release: the cursor shows
|
||||
// "no" while the user can still aim somewhere else.
|
||||
if (!root.drop-allowed) {
|
||||
return DragAction.none;
|
||||
}
|
||||
return DragAction.copy;
|
||||
}
|
||||
|
||||
dropped(ev) => {
|
||||
root.dropped();
|
||||
return DragAction.copy;
|
||||
}
|
||||
|
||||
// Dwelling over a collapsed parent springs it open. Reported rather
|
||||
// than acted on here, because the dwell timer and the collapse state
|
||||
// live in Rust with the rest of the tree.
|
||||
changed has-drag => { root.drag-over(self.has-drag); }
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
// Indentation from depth. The guide rail below sits in this space.
|
||||
padding-left: Theme.gap-sm + root.entry.depth * Theme.indent;
|
||||
padding-right: Theme.gap-sm;
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
// Disclosure arrow, or an equal blank so names stay aligned between
|
||||
// rows that have children and rows that do not.
|
||||
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;
|
||||
}
|
||||
|
||||
// Its own hit area: toggling open must not also select, or every
|
||||
// expand reloads the grid.
|
||||
if root.entry.has-children: TouchArea {
|
||||
clicked => { root.toggle(); }
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// The name, or the field that is replacing it while this row is being
|
||||
// renamed. Two branches of one `if` rather than a TextInput styled to
|
||||
// look like text at rest: a live TextInput would swallow the clicks
|
||||
// that select the collection and the presses that begin a drag.
|
||||
if !root.renaming: Text {
|
||||
text: root.entry.name;
|
||||
color: root.selected ? Theme.ink : Theme.ink-dim;
|
||||
font-size: Theme.text;
|
||||
font-weight: root.selected ? 600 : 400;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
|
||||
if root.renaming: Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
background: Theme.ground;
|
||||
border-radius: Theme.radius-sm;
|
||||
border-width: 1px;
|
||||
border-color: Theme.active;
|
||||
|
||||
edit := TextInput {
|
||||
// Seeded once, when the field appears. Bound two-way to the
|
||||
// row's name it would rewrite the model on every keystroke,
|
||||
// and an edit abandoned with Escape could not be undone.
|
||||
text: root.entry.name;
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
single-line: true;
|
||||
// Inset by hand for the same reason `Field` does it: inside a
|
||||
// layout the input stops scrolling its own content once the
|
||||
// text outgrows the box.
|
||||
x: Theme.gap-sm;
|
||||
width: parent.width - 2 * Theme.gap-sm;
|
||||
height: 100%;
|
||||
|
||||
// Focus and a full selection on appearance, so the placeholder
|
||||
// name a new collection arrives with is replaced by typing
|
||||
// rather than having to be cleared first.
|
||||
init => {
|
||||
self.focus();
|
||||
self.select-all();
|
||||
}
|
||||
|
||||
accepted => { root.rename-committed(self.text); }
|
||||
|
||||
key-pressed(event) => {
|
||||
// Escape abandons. Handled here rather than on a
|
||||
// surrounding FocusScope, which would never see the key —
|
||||
// the input has focus and consumes it.
|
||||
if (event.text == Key.Escape) {
|
||||
root.rename-cancelled();
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
|
||||
// Clicking away commits rather than discarding: the text is
|
||||
// visible on screen and the user typed it, so throwing it out
|
||||
// for want of an Enter is the surprising choice.
|
||||
changed has-focus => {
|
||||
if (!self.has-focus) {
|
||||
root.rename-committed(self.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The count. A parent shows its deep total, since its own direct
|
||||
// membership is usually zero and "0" beside a full subtree reads as
|
||||
// broken. The distinction is spelled out in the tooltip-less way
|
||||
// available here: parentheses mean "including children".
|
||||
Text {
|
||||
text: root.entry.has-children && root.entry.deep-count != root.entry.direct-count
|
||||
? "(" + root.entry.deep-count + ")"
|
||||
: (root.entry.direct-count > 0 ? root.entry.direct-count : "")
|
||||
+ "";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
touch := TouchArea {
|
||||
// Disabled while the field is up, so a click landing on the row rather
|
||||
// than inside the input does not re-select the collection out from
|
||||
// under the edit in progress.
|
||||
enabled: !root.renaming;
|
||||
|
||||
clicked => { root.clicked(); }
|
||||
// Double-click renames — the gesture a file manager or a Lightroom
|
||||
// panel uses for the same thing, so it needs no discovering.
|
||||
double-clicked => { root.rename-requested(); }
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.down
|
||||
&& ev.button == PointerEventButton.right) {
|
||||
root.context-menu();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component CollectionsPanel inherits Rectangle {
|
||||
in property <[CollectionRow]> rows;
|
||||
// Which collection scopes the grid. 0 means the whole library.
|
||||
in property <int> selected-id: 0;
|
||||
in property <int> total-images: 0;
|
||||
in property <string> error: "";
|
||||
|
||||
callback select(int);
|
||||
callback toggle(int);
|
||||
/// Images were dropped on a collection, by its id. Slint hit-tests the
|
||||
/// release itself, so this is the collection actually under the pointer —
|
||||
/// not the last one hovered.
|
||||
callback dropped-on(int);
|
||||
/// A drag is dwelling over a collection, or has left it. Drives the
|
||||
/// spring-loaded expansion, which is timed in Rust.
|
||||
callback drag-over(int, bool);
|
||||
callback new-collection();
|
||||
// Right-click on a row: rename, delete, new child.
|
||||
callback row-menu(int);
|
||||
|
||||
/// Which collection is being renamed, by id. 0 is none.
|
||||
///
|
||||
/// Driven from Rust rather than held here, because a rename that fails —
|
||||
/// or one begun by *creating* a collection, which happens before this
|
||||
/// panel has the new row — has to be started and ended from that side.
|
||||
in property <int> renaming-id: 0;
|
||||
/// Renaming began on a row: its id.
|
||||
callback rename-start(int);
|
||||
/// A rename was committed: the id, and the new name.
|
||||
callback rename-commit(int, string);
|
||||
/// Renaming was abandoned.
|
||||
callback rename-cancel();
|
||||
|
||||
// --- trash (FR-CAT-15) ---
|
||||
//
|
||||
// `selected-id == -1` is the trash being viewed. A sentinel rather than a
|
||||
// separate bool because the sidebar has exactly one selection, and two
|
||||
// flags could disagree about what the grid is showing.
|
||||
/// How many images are in the trash.
|
||||
in property <int> trash-count: 0;
|
||||
/// The count and the bytes it holds, already formatted — "12 · 340 MB".
|
||||
/// Formatted in Rust because Slint has no byte-size formatting and the
|
||||
/// arithmetic would be unreadable inline.
|
||||
in property <string> trash-label: "";
|
||||
|
||||
/// How many grid cells are selected, so the trash view can offer to restore
|
||||
/// them. Read here as well as in the library panel because restoring is a
|
||||
/// selection action and the button has to say what it will act on.
|
||||
in property <int> selected-count: 0;
|
||||
|
||||
/// Images were dropped on the trash: a soft delete.
|
||||
callback dropped-on-trash();
|
||||
/// Permanently delete everything in the trash.
|
||||
callback empty-trash();
|
||||
/// Put the selected images back where they came from.
|
||||
callback restore-selected();
|
||||
|
||||
width: 232px;
|
||||
background: Theme.surface;
|
||||
|
||||
VerticalLayout {
|
||||
padding: Theme.gap-sm;
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
// --- header -------------------------------------------------------
|
||||
HorizontalLayout {
|
||||
height: 26px;
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
Text {
|
||||
text: "COLLECTIONS";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
|
||||
// New collection. A glyph rather than a word: the header is 232px
|
||||
// wide and the label would crowd out the title.
|
||||
Rectangle {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
background: add-touch.pressed ? Theme.pressed
|
||||
: (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;
|
||||
}
|
||||
add-touch := TouchArea {
|
||||
clicked => { root.new-collection(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- the whole library --------------------------------------------
|
||||
//
|
||||
// Always first and never nested: it is how the user gets back to an
|
||||
// unscoped grid, and burying it inside the tree would make "show me
|
||||
// everything" a thing you have to find.
|
||||
Rectangle {
|
||||
height: Theme.row-height;
|
||||
background: root.selected-id == 0 ? Theme.surface-raised
|
||||
: (all-touch.has-hover ? Theme.hover : transparent);
|
||||
border-radius: Theme.radius-sm;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap-sm;
|
||||
padding-right: Theme.gap-sm;
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
Text {
|
||||
text: "All photographs";
|
||||
color: root.selected-id == 0 ? Theme.ink : Theme.ink-dim;
|
||||
font-size: Theme.text;
|
||||
font-weight: root.selected-id == 0 ? 600 : 400;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
Text {
|
||||
text: root.total-images > 0 ? root.total-images : "";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
all-touch := TouchArea {
|
||||
clicked => { root.select(0); }
|
||||
// Dropping onto "All photographs" would mean nothing — an
|
||||
// image is already in the library — so this reports no hover
|
||||
// during a drag and stays inert.
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
|
||||
// --- the tree -----------------------------------------------------
|
||||
Flickable {
|
||||
vertical-stretch: 1;
|
||||
viewport-height: root.rows.length * (Theme.row-height + 2px);
|
||||
|
||||
for row[i] in root.rows: TreeRow {
|
||||
y: i * (Theme.row-height + 2px);
|
||||
width: parent.width;
|
||||
entry: row;
|
||||
selected: row.id == root.selected-id;
|
||||
// A saved filter's membership is computed, so a drop cannot
|
||||
// land there. Refused in `can-drop`, so the cursor says no
|
||||
// before the release rather than after.
|
||||
drop-allowed: !row.smart;
|
||||
renaming: row.id == root.renaming-id;
|
||||
|
||||
clicked => { root.select(row.id); }
|
||||
toggle => { root.toggle(row.id); }
|
||||
context-menu => { root.row-menu(row.id); }
|
||||
dropped => { root.dropped-on(row.id); }
|
||||
drag-over(over) => { root.drag-over(row.id, over); }
|
||||
rename-requested => { root.rename-start(row.id); }
|
||||
rename-committed(name) => { root.rename-commit(row.id, name); }
|
||||
rename-cancelled => { root.rename-cancel(); }
|
||||
}
|
||||
|
||||
// Empty state. A blank panel gives no hint that collections exist
|
||||
// at all, let alone that images can be dragged into them.
|
||||
if root.rows.length == 0: VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: Theme.gap-sm;
|
||||
padding: Theme.gap;
|
||||
|
||||
Text {
|
||||
text: "No collections yet";
|
||||
color: Theme.ink-dim;
|
||||
font-size: Theme.text-sm;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
Text {
|
||||
text: "Press + to make one, then drag photographs onto it.";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
horizontal-alignment: center;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- trash --------------------------------------------------------
|
||||
//
|
||||
// TRACES: FR-CAT-15
|
||||
// Below the tree and separated from it, because it is not a collection:
|
||||
// dropping here *moves the file* into a trash folder on the server,
|
||||
// where every collection above merely references. A destination that
|
||||
// changes the library has no business sitting in the same list as ones
|
||||
// that do not.
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: Theme.row-height;
|
||||
background: trash-drop.has-drag
|
||||
? Theme.selected
|
||||
: (root.selected-id == -1 ? Theme.surface-raised
|
||||
: (trash-touch.has-hover ? Theme.hover : transparent));
|
||||
border-radius: Theme.radius-sm;
|
||||
border-width: trash-drop.has-drag ? 1px : 0px;
|
||||
border-color: Theme.warn-ink;
|
||||
|
||||
trash-drop := DropArea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// Deliberately `move`, where a collection drop is `copy`: this
|
||||
// one really does take the photograph out of the library, and
|
||||
// the cursor should say so.
|
||||
can-drop(ev) => { return DragAction.move; }
|
||||
dropped(ev) => {
|
||||
root.dropped-on-trash();
|
||||
return DragAction.move;
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap-sm;
|
||||
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;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Trash";
|
||||
color: root.selected-id == -1 ? Theme.ink : Theme.ink-dim;
|
||||
font-size: Theme.text;
|
||||
font-weight: root.selected-id == -1 ? 600 : 400;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
|
||||
// The size, not just the count: "empty trash" is destructive and
|
||||
// what it frees is what tells the user whether they meant it.
|
||||
Text {
|
||||
text: root.trash-count > 0 ? root.trash-label : "";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
trash-touch := TouchArea {
|
||||
clicked => { root.select(-1); }
|
||||
}
|
||||
}
|
||||
|
||||
// Restore sits *above* Empty, and is the only one of the two that names
|
||||
// a number. The recoverable action should be the one the hand reaches
|
||||
// first, and the destructive one should not be what a user finds when
|
||||
// they open the trash looking for a way back.
|
||||
if root.selected-id == -1 && root.selected-count > 0: Button {
|
||||
text: root.selected-count == 1
|
||||
? "Restore 1 image"
|
||||
: "Restore " + root.selected-count + " images";
|
||||
clicked => { root.restore-selected(); }
|
||||
}
|
||||
|
||||
// Emptying is offered only while the trash is being *looked at*, so it
|
||||
// cannot be hit in passing. It is the one irreversible action in this
|
||||
// panel and it should take a deliberate visit to reach.
|
||||
if root.selected-id == -1 && root.trash-count > 0: Button {
|
||||
text: "Empty trash";
|
||||
clicked => { root.empty-trash(); }
|
||||
}
|
||||
|
||||
// --- error --------------------------------------------------------
|
||||
//
|
||||
// A refused drop or a failed rename says so here rather than only in
|
||||
// the log: the gesture succeeded from the user's point of view, so
|
||||
// silence would read as data loss.
|
||||
if root.error != "": Text {
|
||||
text: root.error;
|
||||
color: Theme.warn-ink;
|
||||
font-size: Theme.text-sm;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
// Right edge, separating the panel from the grid.
|
||||
Rectangle {
|
||||
x: parent.width - 1px;
|
||||
width: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
}
|
||||
+73
-187
@@ -1,4 +1,5 @@
|
||||
import { Theme } from "theme.slint";
|
||||
import { Button, PanelHeading, Label, Value, Caption, Panel, Field, Disclosure } from "widgets.slint";
|
||||
|
||||
// Launch screen: connect an account, or resume a saved one.
|
||||
//
|
||||
@@ -35,12 +36,17 @@ component FormatCheck inherits Rectangle {
|
||||
y: (parent.height - self.height) / 2;
|
||||
border-radius: 3px;
|
||||
border-width: 1px;
|
||||
border-color: root.checked ? Theme.accent : Theme.rule;
|
||||
background: root.checked ? Theme.accent : transparent;
|
||||
// A ticked box is an engaged control, so it fills with `active`
|
||||
// — the same token the slider fill and the focus border take.
|
||||
border-color: root.checked ? Theme.active : Theme.rule;
|
||||
background: root.checked ? Theme.active : transparent;
|
||||
|
||||
Text {
|
||||
text: "✓";
|
||||
color: #fff;
|
||||
// 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;
|
||||
visible: root.checked;
|
||||
horizontal-alignment: center;
|
||||
@@ -50,45 +56,20 @@ component FormatCheck inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Label {
|
||||
text: root.label;
|
||||
color: touch.has-hover ? Theme.ink : Theme.ink-dim;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
emphasised: touch.has-hover;
|
||||
body: true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component Button inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <bool> primary: false;
|
||||
in property <bool> enabled: true;
|
||||
callback clicked();
|
||||
|
||||
// This screen's buttons are form actions in a single stacked column, not
|
||||
// chrome beside a photograph — they are given the full `touch-target` height
|
||||
// rather than the denser `control-height` the toolbars use. `Button` draws
|
||||
// its own hit area at least this tall either way; here the ink matches it.
|
||||
component FormButton inherits Button {
|
||||
height: Theme.touch-target;
|
||||
border-radius: 4px;
|
||||
background: root.primary
|
||||
? (touch.has-hover && root.enabled ? Theme.accent-dim : Theme.accent)
|
||||
: (touch.has-hover && root.enabled ? Theme.surface-raised : transparent);
|
||||
border-width: root.primary ? 0px : 1px;
|
||||
border-color: Theme.rule;
|
||||
opacity: root.enabled ? 1.0 : 0.45;
|
||||
|
||||
touch := TouchArea {
|
||||
enabled: root.enabled;
|
||||
clicked => { root.clicked(); }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.label;
|
||||
color: root.primary ? #fff : Theme.ink;
|
||||
font-size: Theme.text;
|
||||
font-weight: 600;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// One folder in the picker. The whole row is the target, not just the text.
|
||||
@@ -108,20 +89,8 @@ component FolderRow inherits Rectangle {
|
||||
padding-right: Theme.gap;
|
||||
spacing: Theme.gap;
|
||||
|
||||
Text {
|
||||
text: root.is-parent ? "↑" : "▸";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
width: 14px;
|
||||
}
|
||||
Text {
|
||||
text: root.label;
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
Disclosure { text: root.is-parent ? "↑" : "▸"; }
|
||||
Value { text: root.label; overflow: elide; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +147,9 @@ export component LaunchScreen inherits Rectangle {
|
||||
// --- masthead ---
|
||||
VerticalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
// The one piece of type in the app that is a title rather
|
||||
// than a label, so it is written here rather than given a
|
||||
// component with a single call site.
|
||||
Text {
|
||||
text: "DarkRoom";
|
||||
color: Theme.ink;
|
||||
@@ -185,12 +157,11 @@ export component LaunchScreen inherits Rectangle {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
Text {
|
||||
Label {
|
||||
text: root.signed-in
|
||||
? "Connected"
|
||||
: "Connect a Nextcloud account to begin";
|
||||
color: Theme.ink-dim;
|
||||
font-size: Theme.text;
|
||||
body: true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,63 +171,29 @@ export component LaunchScreen inherits Rectangle {
|
||||
if !root.signed-in && root.login-url == "": VerticalLayout {
|
||||
spacing: Theme.gap;
|
||||
|
||||
Text {
|
||||
text: "SERVER";
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
PanelHeading { text: "SERVER"; }
|
||||
|
||||
Rectangle {
|
||||
height: Theme.touch-target;
|
||||
border-radius: 4px;
|
||||
border-width: 1px;
|
||||
border-color: server-input.has-focus ? Theme.accent : Theme.rule;
|
||||
background: Theme.surface;
|
||||
|
||||
server-input := TextInput {
|
||||
server-input := Field {
|
||||
text: root.server-url;
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
width: parent.width - 2 * Theme.gap;
|
||||
x: Theme.gap;
|
||||
height: 100%;
|
||||
single-line: true;
|
||||
accepted => { root.sign-in(self.text); }
|
||||
placeholder: "https://cloud.example.com";
|
||||
accepted(url) => { root.sign-in(url); }
|
||||
}
|
||||
|
||||
// Placeholder, since TextInput has none of its own.
|
||||
Text {
|
||||
text: "https://cloud.example.com";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
x: Theme.gap;
|
||||
height: 100%;
|
||||
visible: server-input.text == "";
|
||||
}
|
||||
}
|
||||
|
||||
if !root.can-remember: Text {
|
||||
if !root.can-remember: Caption {
|
||||
text: "No system keyring found — you will need to sign in each time.";
|
||||
color: Theme.warn-ink;
|
||||
font-size: Theme.text-sm;
|
||||
warn: true;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
Button {
|
||||
label: root.busy ? "Connecting…" : "Sign in";
|
||||
FormButton {
|
||||
text: root.busy ? "Connecting…" : "Sign in";
|
||||
primary: true;
|
||||
enabled: !root.busy && server-input.text != "";
|
||||
clicked => { root.sign-in(server-input.text); }
|
||||
}
|
||||
|
||||
Text {
|
||||
Caption {
|
||||
text: "Sign-in happens in your browser. DarkRoom never sees your password.";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
@@ -265,77 +202,52 @@ export component LaunchScreen inherits Rectangle {
|
||||
if root.login-url != "": VerticalLayout {
|
||||
spacing: Theme.gap;
|
||||
|
||||
Text {
|
||||
text: "APPROVE IN YOUR BROWSER";
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
PanelHeading { text: "APPROVE IN YOUR BROWSER"; }
|
||||
|
||||
Rectangle {
|
||||
background: Theme.surface;
|
||||
border-radius: 4px;
|
||||
border-width: 1px;
|
||||
border-color: Theme.rule;
|
||||
|
||||
VerticalLayout {
|
||||
padding: Theme.gap;
|
||||
Text {
|
||||
Panel {
|
||||
Label {
|
||||
text: root.login-url;
|
||||
color: Theme.ink-dim;
|
||||
font-size: Theme.text-sm;
|
||||
// char-wrap, not word-wrap: a URL has no spaces to
|
||||
// break at, and word-wrap would run it off the box.
|
||||
wrap: char-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
label: "Copy link";
|
||||
FormButton {
|
||||
text: "Copy link";
|
||||
clicked => { root.copy-login-url(); }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Waiting for approval…";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
}
|
||||
Caption { text: "Waiting for approval…"; }
|
||||
}
|
||||
|
||||
// --- folder picker ---
|
||||
if root.signed-in && root.browsing: VerticalLayout {
|
||||
spacing: Theme.gap;
|
||||
|
||||
Text {
|
||||
text: "CHOOSE LIBRARY FOLDER";
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
PanelHeading { text: "CHOOSE LIBRARY FOLDER"; }
|
||||
|
||||
// Current location, so it is always clear what
|
||||
// "Use this folder" would select.
|
||||
Text {
|
||||
Value {
|
||||
text: root.browse-path == "" ? "/" : "/" + root.browse-path;
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
// Not a `Panel`: this box overlays three mutually
|
||||
// exclusive states — loading, the list, "nothing here" —
|
||||
// each filling it, and Panel stacks its children in a
|
||||
// column. Same surface and rule, drawn directly.
|
||||
Rectangle {
|
||||
height: 220px;
|
||||
background: Theme.surface;
|
||||
border-radius: 4px;
|
||||
border-radius: Theme.radius;
|
||||
border-width: 1px;
|
||||
border-color: Theme.rule;
|
||||
|
||||
if root.browse-loading: Text {
|
||||
if root.browse-loading: Caption {
|
||||
text: "Loading…";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -364,12 +276,9 @@ export component LaunchScreen inherits Rectangle {
|
||||
}
|
||||
|
||||
if !root.browse-loading && root.browse-entries.length == 0
|
||||
&& root.browse-path != "": Text {
|
||||
&& root.browse-path != "": Caption {
|
||||
text: "No subfolders here";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -377,13 +286,13 @@ export component LaunchScreen inherits Rectangle {
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap;
|
||||
Button {
|
||||
label: "Cancel";
|
||||
FormButton {
|
||||
text: "Cancel";
|
||||
horizontal-stretch: 1;
|
||||
clicked => { root.browse-cancel(); }
|
||||
}
|
||||
Button {
|
||||
label: "Use this folder";
|
||||
FormButton {
|
||||
text: "Use this folder";
|
||||
primary: true;
|
||||
horizontal-stretch: 1;
|
||||
clicked => { root.browse-confirm(); }
|
||||
@@ -395,40 +304,22 @@ export component LaunchScreen inherits Rectangle {
|
||||
if root.signed-in && !root.browsing: VerticalLayout {
|
||||
spacing: Theme.gap;
|
||||
|
||||
Text {
|
||||
text: "ACCOUNT";
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
Text {
|
||||
text: root.account;
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text;
|
||||
}
|
||||
PanelHeading { text: "ACCOUNT"; }
|
||||
Value { text: root.account; }
|
||||
|
||||
Rectangle { height: Theme.gap-sm; }
|
||||
|
||||
Text {
|
||||
text: "LIBRARY FOLDER";
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
PanelHeading { text: "LIBRARY FOLDER"; }
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap;
|
||||
Text {
|
||||
Value {
|
||||
text: root.library-root == "" ? "(not chosen)" : root.library-root;
|
||||
color: root.library-root == "" ? Theme.ink-faint : Theme.ink;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
placeholder: root.library-root == "";
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
Button {
|
||||
label: "Choose…";
|
||||
FormButton {
|
||||
text: "Choose…";
|
||||
width: 110px;
|
||||
clicked => { root.choose-folder(); }
|
||||
}
|
||||
@@ -436,13 +327,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
|
||||
Rectangle { height: Theme.gap-sm; }
|
||||
|
||||
Text {
|
||||
text: "SCAN FOR";
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
PanelHeading { text: "SCAN FOR"; }
|
||||
|
||||
for label[i] in root.format-labels: FormatCheck {
|
||||
label: label;
|
||||
@@ -452,29 +337,30 @@ export component LaunchScreen inherits Rectangle {
|
||||
|
||||
Rectangle { height: Theme.gap; }
|
||||
|
||||
Button {
|
||||
label: root.busy ? "Scanning…" : "Open library";
|
||||
FormButton {
|
||||
text: root.busy ? "Scanning…" : "Open library";
|
||||
primary: true;
|
||||
enabled: !root.busy && root.library-root != "";
|
||||
clicked => { root.open-library(); }
|
||||
}
|
||||
Button {
|
||||
label: "Sign out";
|
||||
FormButton {
|
||||
text: "Sign out";
|
||||
clicked => { root.sign-out(); }
|
||||
}
|
||||
}
|
||||
|
||||
// --- feedback ---
|
||||
if root.status != "": Text {
|
||||
if root.status != "": Label {
|
||||
text: root.status;
|
||||
color: Theme.ink-dim;
|
||||
font-size: Theme.text-sm;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
if root.error != "": Text {
|
||||
// A failed sign-in or scan is a caution, not an active state:
|
||||
// something the user has to act on rather than something the
|
||||
// app is doing. It was the same accent as the headings above
|
||||
// it, which said nothing had gone wrong.
|
||||
if root.error != "": Caption {
|
||||
text: root.error;
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
warn: true;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
||||
// Darkroom safelight palette. Warm neutrals, single red accent.
|
||||
// Committed to a dark ground — this is a photo editor, and a light UI
|
||||
// surrounding an image biases how that image is judged.
|
||||
|
||||
export global Theme {
|
||||
out property <color> ground: #14120F;
|
||||
out property <color> surface: #1D1A16;
|
||||
out property <color> surface-raised: #262119;
|
||||
out property <color> rule: #332C24;
|
||||
|
||||
out property <color> ink: #F0EAE0;
|
||||
out property <color> ink-dim: #A79E91;
|
||||
out property <color> ink-faint: #7C7367;
|
||||
|
||||
out property <color> accent: #D9543C;
|
||||
out property <color> accent-dim: #8F2E1E;
|
||||
|
||||
// Semantic, distinct from the accent: a caution is not an action.
|
||||
out property <color> warn-ink: #C99A4A;
|
||||
|
||||
out property <length> gap-sm: 6px;
|
||||
out property <length> gap: 12px;
|
||||
out property <length> gap-lg: 20px;
|
||||
|
||||
out property <length> text-sm: 11px;
|
||||
out property <length> text: 13px;
|
||||
out property <length> text-lg: 17px;
|
||||
out property <length> text-xl: 24px;
|
||||
|
||||
// FR-UI-3: minimum 44pt hit target under touch.
|
||||
out property <length> touch-target: 44px;
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
// Shared chrome primitives and the style layer.
|
||||
//
|
||||
// Before this file every button was a Rectangle + TouchArea written out where
|
||||
// it was needed, at 64×20, 110×28 and 88×28 with three near-identical
|
||||
// hover/press treatments. Any consistency was coincidental. These are the
|
||||
// pieces that make it deliberate; nothing here draws a colour literal.
|
||||
//
|
||||
// **The rule this file establishes.** Screen files consume components; raw
|
||||
// `Theme.*` is for *composing* a component, not for styling a call site. A
|
||||
// bare `Theme.ink-faint` or a font-size in `app.slint` means a component is
|
||||
// missing, not that a screen needs an exception. `theme.slint` says what
|
||||
// `surface` is; only this file says what a *panel heading* is — and until it
|
||||
// did, four screens each re-derived one, which is exactly how a single accent
|
||||
// colour reached forty call sites with no single place to change it.
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
|
||||
// A text button.
|
||||
//
|
||||
// **The drawn box and the hit target are separate.** FR-UI-3 asks for a 44pt
|
||||
// minimum target under touch, but a 44px-tall button in a 44px-tall grid
|
||||
// header leaves no room for the header, and the chrome would grow to meet a
|
||||
// requirement that is about the *finger*, not the ink. So the rectangle is
|
||||
// `Theme.control-height` and the TouchArea is grown to `Theme.touch-target`
|
||||
// and centred over it, exactly as `FormatCheck` in launch.slint does. Callers
|
||||
// laying these out horizontally get the compact size they expect; a thumb
|
||||
// still gets 44px.
|
||||
//
|
||||
// The overhang is deliberately allowed to spill outside the parent's bounds.
|
||||
// It only matters when two buttons sit within 8px vertically of each other,
|
||||
// which no current layout does — buttons live in single rows.
|
||||
export component Button inherits Rectangle {
|
||||
in property <string> text;
|
||||
in property <bool> enabled: true;
|
||||
/// The one affirmative action in a group. At most one per group, or the
|
||||
/// 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
|
||||
/// toggle read alike.
|
||||
in property <bool> active: false;
|
||||
|
||||
callback clicked();
|
||||
|
||||
height: Theme.control-height;
|
||||
// A minimum rather than a fixed width: callers that set `width` or hand
|
||||
// this to a stretching layout still win, and a long label is not clipped.
|
||||
min-width: Theme.control-min-width;
|
||||
horizontal-stretch: 0;
|
||||
|
||||
border-radius: Theme.radius;
|
||||
border-width: root.primary ? 0px : 1px;
|
||||
border-color: root.active ? Theme.active : Theme.rule;
|
||||
|
||||
// A primary button is filled rather than outlined, and with no hue left to
|
||||
// fill it with the fill is near-white — so it moves the opposite way to a
|
||||
// secondary button: hover *brightens* toward `active` and press sinks to
|
||||
// `active-pressed`, where the neutral variant lifts from `surface-raised`.
|
||||
background: root.primary
|
||||
? (touch.pressed ? Theme.active-pressed
|
||||
: (touch.has-hover ? Theme.active : Theme.active-dim))
|
||||
: (touch.pressed ? Theme.pressed
|
||||
: (touch.has-hover ? Theme.hover : Theme.surface-raised));
|
||||
|
||||
// Disabled reads as "not now", not as a second kind of button — the shape
|
||||
// stays and only the contrast drops.
|
||||
opacity: root.enabled ? 1.0 : 0.45;
|
||||
|
||||
touch := TouchArea {
|
||||
enabled: root.enabled;
|
||||
// Explicit geometry: a TouchArea with none collapses to zero and only
|
||||
// catches the events that happen to land on it.
|
||||
width: 100%;
|
||||
height: max(parent.height, Theme.touch-target);
|
||||
y: (parent.height - self.height) / 2;
|
||||
mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default;
|
||||
clicked => { root.clicked(); }
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap;
|
||||
padding-right: Theme.gap;
|
||||
|
||||
Text {
|
||||
text: root.text;
|
||||
// Dark on the primary fill: that fill is now near-white, and the
|
||||
// white label this carried when the fill was a saturated red is
|
||||
// unreadable against it.
|
||||
color: root.primary ? Theme.ground
|
||||
: (root.active ? Theme.active : Theme.ink);
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 600;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A square button carrying a single glyph.
|
||||
//
|
||||
// Square because it has no label to size against: a toolbar affordance whose
|
||||
// width tracked its glyph would jitter as the glyph changed.
|
||||
export component IconButton inherits Rectangle {
|
||||
in property <string> glyph;
|
||||
in property <bool> enabled: true;
|
||||
/// Sustained state — a toggle that is currently on, not a press.
|
||||
in property <bool> active: false;
|
||||
|
||||
callback clicked();
|
||||
|
||||
width: Theme.control-height;
|
||||
height: Theme.control-height;
|
||||
horizontal-stretch: 0;
|
||||
|
||||
border-radius: Theme.radius;
|
||||
border-width: 1px;
|
||||
border-color: root.active ? Theme.active : Theme.rule;
|
||||
|
||||
background: touch.pressed ? Theme.pressed
|
||||
: (touch.has-hover ? Theme.hover : Theme.surface-raised);
|
||||
opacity: root.enabled ? 1.0 : 0.45;
|
||||
|
||||
touch := TouchArea {
|
||||
enabled: root.enabled;
|
||||
width: max(parent.width, Theme.touch-target);
|
||||
height: max(parent.height, Theme.touch-target);
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default;
|
||||
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%;
|
||||
}
|
||||
}
|
||||
|
||||
// A toggle in a row of toggles: one term of a filter.
|
||||
//
|
||||
// Distinct from `Button` because it is *state*, not an action — it stays on
|
||||
// after the click, and a row of them says what the grid is currently showing.
|
||||
// A `Button` that happened to be styled differently would drift the moment
|
||||
// either changed.
|
||||
//
|
||||
// **Active reads as filled, not merely outlined.** These sit in a row where
|
||||
// several look alike, so an inactive-versus-active difference carried by a
|
||||
// border alone is invisible at a glance across six chips. The active one
|
||||
// inverts — near-white fill, dark text — which is the same treatment
|
||||
// `Button.primary` uses for "this is the one".
|
||||
//
|
||||
// **The count is optional and never fabricated.** `-1` means "not known yet",
|
||||
// which is different from zero: a filter with no images behind it should say
|
||||
// `0` so the user knows narrowing to it will empty the grid, but one whose
|
||||
// count has not been computed must not claim zero.
|
||||
export component FilterChip inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <bool> active: false;
|
||||
/// Images behind this term, or -1 where the count is not known.
|
||||
in property <int> count: -1;
|
||||
|
||||
callback clicked();
|
||||
|
||||
height: Theme.control-height - 4px;
|
||||
// A floor on a content-sized chip, expressed as one property: Slint rejects
|
||||
// `width` and `min-width` together, and the floor is what keeps a chip
|
||||
// labelled "3" from being a sliver too small to hit.
|
||||
width: max(34px, row.preferred-width + 2 * Theme.gap-sm);
|
||||
horizontal-stretch: 0;
|
||||
|
||||
border-radius: Theme.radius;
|
||||
border-width: 1px;
|
||||
border-color: root.active ? Theme.active : Theme.rule;
|
||||
background: root.active
|
||||
? (touch.pressed ? Theme.active-pressed : Theme.active-dim)
|
||||
: (touch.pressed ? Theme.pressed
|
||||
: (touch.has-hover ? Theme.hover : Theme.surface-raised));
|
||||
|
||||
touch := TouchArea {
|
||||
width: 100%;
|
||||
height: max(parent.height, Theme.touch-target);
|
||||
y: (parent.height - self.height) / 2;
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.clicked(); }
|
||||
}
|
||||
|
||||
row := HorizontalLayout {
|
||||
padding-left: Theme.gap-sm;
|
||||
padding-right: Theme.gap-sm;
|
||||
spacing: 4px;
|
||||
|
||||
Text {
|
||||
text: root.label;
|
||||
// Dark on the active fill, which is near-white — the same
|
||||
// inversion `Button.primary` makes for the same reason.
|
||||
color: root.active ? Theme.ground : Theme.ink;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: root.active ? 700 : 500;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.count >= 0 ? root.count : "";
|
||||
// Dimmer than the label on both grounds: the count is supporting
|
||||
// detail, and a chip whose number shouted louder than its name
|
||||
// would read as a number with a caption.
|
||||
color: root.active ? Theme.ground : Theme.ink-faint;
|
||||
opacity: root.active ? 0.7 : 1.0;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
// A collapsible group with a header that reports whether anything inside has
|
||||
// been touched.
|
||||
//
|
||||
// `expanded` is in-out so a caller can key collapse state by something stable
|
||||
// (an operation index, never a label) and drive it from outside; left alone it
|
||||
// works standalone as a self-toggling disclosure.
|
||||
//
|
||||
// **Collapsing is fiddlier than it looks.** `@children` cannot appear inside
|
||||
// a conditional element — Slint rejects it outright — so the body cannot be
|
||||
// dropped from the tree with `if root.expanded`. And `visible: false` alone
|
||||
// only hides the ink: the element keeps its layout slot, so a stack of
|
||||
// collapsed sections would be a column of gaps.
|
||||
//
|
||||
// So the body is a plain Rectangle that is both hidden *and* clamped to zero
|
||||
// height when collapsed, with `clip: true` so children taller than the clamp
|
||||
// cannot paint outside it. The clamp reads `body-inner.preferred-height`,
|
||||
// which is a *preferred* size — an input to layout, never a result of it —
|
||||
// so `expanded` feeding the height does not loop back.
|
||||
export component Section inherits Rectangle {
|
||||
in property <string> title;
|
||||
/// Anything inside differs from its default. The caller computes this —
|
||||
/// the section cannot see into `@children`.
|
||||
in property <bool> modified: false;
|
||||
in-out property <bool> expanded: true;
|
||||
|
||||
/// Fired after `expanded` has already been flipped, for callers that
|
||||
/// persist the state rather than letting this component own it.
|
||||
callback toggled(bool);
|
||||
|
||||
/// Undo everything inside. The affordance only appears once `modified` is
|
||||
/// true — a reset on an untouched group is a control that cannot do
|
||||
/// anything, and a header carrying one permanently is a header that reads
|
||||
/// as busy rather than as a name.
|
||||
///
|
||||
/// Sections whose contents have nothing to undo simply leave this
|
||||
/// unconnected, and `has-reset` off.
|
||||
callback op-reset();
|
||||
/// Whether this section's contents can be reset at all.
|
||||
in property <bool> has-reset: true;
|
||||
|
||||
background: transparent;
|
||||
// Own height comes from the layout below, so a collapsed section shrinks
|
||||
// to its header.
|
||||
height: body.preferred-height;
|
||||
|
||||
body := VerticalLayout {
|
||||
spacing: 0px;
|
||||
alignment: start;
|
||||
|
||||
header := Rectangle {
|
||||
height: Theme.control-height;
|
||||
background: header-touch.pressed ? Theme.pressed
|
||||
: (header-touch.has-hover ? Theme.hover : transparent);
|
||||
border-radius: Theme.radius-sm;
|
||||
|
||||
header-touch := TouchArea {
|
||||
width: 100%;
|
||||
height: max(parent.height, Theme.touch-target);
|
||||
y: (parent.height - self.height) / 2;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.expanded = !root.expanded;
|
||||
root.toggled(root.expanded);
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap-sm;
|
||||
padding-right: Theme.gap-sm;
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
Disclosure { text: root.expanded ? "▾" : "▸"; }
|
||||
|
||||
Text {
|
||||
text: root.title;
|
||||
color: header-touch.has-hover ? Theme.ink : Theme.ink-dim;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
// The modified dot: the one thing that survives collapsing,
|
||||
// so a closed section still says whether it holds an edit.
|
||||
Rectangle {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
border-radius: 3px;
|
||||
background: Theme.modified;
|
||||
visible: root.modified;
|
||||
}
|
||||
|
||||
// The group's reset. Shown only when there is something to
|
||||
// undo *and* the pointer is on the header, so a panel at rest
|
||||
// is a column of names rather than a column of buttons.
|
||||
//
|
||||
// It declares its width whether or not it is visible: a
|
||||
// control that appeared on hover and *also* widened the row
|
||||
// would shift the title sideways under the pointer, which
|
||||
// reads as the panel flinching away from the cursor.
|
||||
Rectangle {
|
||||
width: 28px;
|
||||
|
||||
reset-touch := TouchArea {
|
||||
// Sits after `header-touch` in the tree, so it takes
|
||||
// the press first and the section does not toggle out
|
||||
// from under a reset.
|
||||
width: 100%;
|
||||
height: max(parent.height, Theme.touch-target);
|
||||
y: (parent.height - self.height) / 2;
|
||||
enabled: root.has-reset && root.modified;
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.op-reset(); }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "reset";
|
||||
color: reset-touch.has-hover ? Theme.ink : Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
horizontal-alignment: right;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
visible: root.has-reset && root.modified
|
||||
&& (header-touch.has-hover || reset-touch.has-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
// Collapsed by height plus clip, deliberately *not* by `visible`:
|
||||
// Slint treats a visibility-guarded element as conditional, and
|
||||
// `@children` cannot appear inside one. Zero height with clipping
|
||||
// hides the body just as completely.
|
||||
height: root.expanded ? body-inner.preferred-height : 0px;
|
||||
clip: true;
|
||||
|
||||
body-inner := VerticalLayout {
|
||||
spacing: 0px;
|
||||
alignment: start;
|
||||
@children
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- the style layer ---------------------------------------------------
|
||||
//
|
||||
// Text roles. Four components rather than one with a `role` enum, because a
|
||||
// role is chosen once at the call site and never switched at runtime — an
|
||||
// enum would buy nothing and cost a qualified name at every use.
|
||||
|
||||
// The name of a panel or a form section: `IMAGE`, `ADJUST`, `SERVER`.
|
||||
//
|
||||
// Caps-with-tracking rather than a larger size: these sit directly above the
|
||||
// content they name, in a column only 280px wide, and a heading that grew the
|
||||
// row would push the photograph over for the sake of a label. Tracking does
|
||||
// the same separating work in the same height.
|
||||
//
|
||||
// `ink-faint` rather than the accent these all carried. A heading is a label,
|
||||
// not a state — it is true whatever the panel is doing, so it has no business
|
||||
// competing with the slider that *is* doing something. It is also read once
|
||||
// and then skipped, which is what the faintest ink is for.
|
||||
export component PanelHeading inherits Text {
|
||||
/// A heading *inside* a panel that already has one — an operation group
|
||||
/// under `ADJUST`. Tighter tracking, so the two levels are distinguishable
|
||||
/// where they stack without either needing a second colour or size.
|
||||
in property <bool> sub: false;
|
||||
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: root.sub ? 0.8px : 1.2px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
// The name of a thing whose value sits beside it: a parameter name, a form
|
||||
// field's caption. Dimmer than its value on purpose — the label is constant
|
||||
// and the value is what changed.
|
||||
export component Label inherits Text {
|
||||
/// Lit, for a label under the pointer or one whose value has moved off its
|
||||
/// default. The caller supplies the condition; this only decides what
|
||||
/// "lit" looks like.
|
||||
in property <bool> emphasised: false;
|
||||
/// Body size rather than the chrome's `text-sm`. For a label the user is
|
||||
/// reading rather than scanning past — a row in a picker, a tick-box in a
|
||||
/// form — where the panel is a page rather than an instrument.
|
||||
in property <bool> body: false;
|
||||
|
||||
color: root.emphasised ? Theme.ink : Theme.ink-dim;
|
||||
font-size: root.body ? Theme.text : Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
// A datum: a camera name, a file path, a slider's readout.
|
||||
//
|
||||
// **`modified` is the reason this is a component.** A value differing from its
|
||||
// default is the single thing a photographer scans a panel for, and with hue
|
||||
// gone from the palette the only signal left is luminance — so the gap has to
|
||||
// be large and it has to be identical everywhere, or it stops reading as a
|
||||
// signal at all and becomes texture. One definition, one gap.
|
||||
export component Value inherits Text {
|
||||
/// Differs from its default.
|
||||
in property <bool> modified: false;
|
||||
/// No value yet — a placeholder standing in for one, not a value that
|
||||
/// happens to be empty.
|
||||
in property <bool> placeholder: false;
|
||||
/// The compact readout that sits on a control's own row, rather than a
|
||||
/// datum on a line of its own.
|
||||
in property <bool> compact: false;
|
||||
|
||||
color: root.modified ? Theme.modified
|
||||
: (root.placeholder ? Theme.ink-faint : Theme.ink);
|
||||
font-size: root.compact ? Theme.text-sm : Theme.text;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
// Supporting text: a hint under a field, a count beside a title, an empty
|
||||
// state's second line. The faintest ink, because it is there for the reader
|
||||
// who stopped to look and should not catch the eye of the one who did not.
|
||||
export component Caption inherits Text {
|
||||
/// A caution. The one place hue survives in the chrome — a warning is a
|
||||
/// different kind of thing from an active state, and saying so instantly
|
||||
/// is worth the exception (see the theme preamble).
|
||||
in property <bool> warn: false;
|
||||
/// Lit, for supporting text the pointer is currently over. Mirrors
|
||||
/// `Label.emphasised` from one step further down, so the two roles brighten
|
||||
/// to the same ink and a hover reads identically wherever it lands.
|
||||
in property <bool> emphasised: false;
|
||||
|
||||
color: root.warn ? Theme.warn-ink
|
||||
: (root.emphasised ? Theme.ink : Theme.ink-faint);
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
// A region of surface holding a **column** of controls.
|
||||
//
|
||||
// Two shapes, because the call sites are two shapes. An inset box on the
|
||||
// launch screen is bordered and rounded — it sits on the ground with air
|
||||
// around it and needs its own edge. A panel in the develop column is `flat`:
|
||||
// it abuts its neighbours, so the divider between them belongs to the column
|
||||
// that stacks them, and a border here would double up with it.
|
||||
//
|
||||
// **Not every bordered box is a Panel.** The folder picker's list is the same
|
||||
// surface and rule but overlays three mutually exclusive states — loading, the
|
||||
// list, "nothing here" — each filling the box. This stacks its children, so it
|
||||
// would lay those three out in a row; that site draws its own Rectangle and
|
||||
// says why. A component that covered both would need a bool selecting between
|
||||
// a layout and an overlay, which is two components wearing one name.
|
||||
//
|
||||
// `@children` goes in a plain VerticalLayout for the same reason `Section`'s
|
||||
// body does: Slint rejects `@children` inside anything conditional, so the
|
||||
// two shapes differ only in properties, never in structure.
|
||||
export component Panel inherits Rectangle {
|
||||
/// Abuts its neighbours: no border, no radius. The stacking parent draws
|
||||
/// the dividing rule.
|
||||
in property <bool> flat: false;
|
||||
in property <length> spacing: Theme.gap-sm;
|
||||
/// Named `inset` rather than `padding`: a Rectangle already reserves
|
||||
/// `padding` for the layout it may contain, and redeclaring it is a
|
||||
/// compile error rather than an override.
|
||||
in property <length> inset: Theme.gap;
|
||||
|
||||
background: Theme.surface;
|
||||
border-radius: root.flat ? 0px : Theme.radius;
|
||||
border-width: root.flat ? 0px : 1px;
|
||||
border-color: Theme.rule;
|
||||
|
||||
VerticalLayout {
|
||||
padding: root.inset;
|
||||
spacing: root.spacing;
|
||||
alignment: start;
|
||||
@children
|
||||
}
|
||||
}
|
||||
|
||||
// A single-line text entry.
|
||||
//
|
||||
// **The placeholder is a sibling Text, not a property.** Slint's `TextInput`
|
||||
// has none of its own, and the alternative — seeding `text` and clearing it on
|
||||
// focus — loses whatever the user typed if focus arrives before a keystroke.
|
||||
// A Text underneath, hidden the moment anything is entered, cannot.
|
||||
//
|
||||
// The focus border is `active`: focus is a live state of the control, the one
|
||||
// place in a form where something is *engaged*, which is precisely what that
|
||||
// token is for.
|
||||
export component Field inherits Rectangle {
|
||||
in-out property <string> text;
|
||||
in property <string> placeholder;
|
||||
/// Whether the entry currently holds focus, so a caller can enable its
|
||||
/// submit button from the same fact the border is drawn from.
|
||||
out property <bool> has-focus: input.has-focus;
|
||||
|
||||
callback accepted(string);
|
||||
|
||||
height: Theme.touch-target;
|
||||
border-radius: Theme.radius;
|
||||
border-width: 1px;
|
||||
border-color: input.has-focus ? Theme.active : Theme.rule;
|
||||
background: Theme.surface;
|
||||
|
||||
input := TextInput {
|
||||
text <=> root.text;
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
// Inset by hand rather than by a layout: a TextInput inside a
|
||||
// HorizontalLayout is sized by the layout and stops scrolling its own
|
||||
// content once the text is longer than the box.
|
||||
x: Theme.gap;
|
||||
width: parent.width - 2 * Theme.gap;
|
||||
height: 100%;
|
||||
single-line: true;
|
||||
accepted => { root.accepted(self.text); }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.placeholder;
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
x: Theme.gap;
|
||||
height: 100%;
|
||||
visible: input.text == "";
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// centred lines — a `text-lg` headline over a `text-sm` explanation — and the
|
||||
// distinction they draw is the load-bearing one: "still working" and "finished
|
||||
// and found nothing" are different answers, and a view that conflates them
|
||||
// makes a working scan look broken. One component, so neither view can drift
|
||||
// into answering only half of it.
|
||||
//
|
||||
// The headline is the only place `text-lg` appears outside a masthead, which
|
||||
// is why it is here rather than as a `Label` variant: it is a size this file
|
||||
// otherwise does not hand out.
|
||||
export component EmptyState inherits VerticalLayout {
|
||||
in property <string> headline;
|
||||
in property <string> detail;
|
||||
|
||||
alignment: center;
|
||||
spacing: Theme.gap;
|
||||
|
||||
Text {
|
||||
text: root.headline;
|
||||
color: Theme.ink-dim;
|
||||
font-size: Theme.text-lg;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: root.detail;
|
||||
horizontal-alignment: center;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user