Merge branch 'master' into worktree-faces-scrfd-mbf
Build and test / Desktop (Linux) (push) Failing after 25s
Build and test / Layer separation (push) Successful in 22s
Traceability / Requirement traces (push) Successful in 58s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 33m26s

# Conflicts:
#	docs/traceability.md
#	ui/dr-ui/src/develop.rs
#	ui/dr-ui/src/segmentation.rs
This commit is contained in:
2026-08-27 11:57:38 +02:00
51 changed files with 7732 additions and 586 deletions
+13 -6
View File
@@ -65,11 +65,14 @@ rusqlite.workspace = true
# Consequence worth stating plainly: the desktop app now needs a working wgpu
# adapter to open a window at all. Slint refuses a CPU adapter for this
# renderer unless `SLINT_WGPU_CPU` is set in the environment.
slint = { workspace = true, features = [
"compat-1-2",
"renderer-femtovg-wgpu",
"unstable-wgpu-29",
] }
# TEST BUILD: the wgpu renderer features have moved to the desktop-only
# dependency below. On Android they made `AndroidWindowAdapter` choose
# `SkiaRenderer::default_wgpu_29`, and so put the app on wgpu's Vulkan
# swapchain — which hardcodes `preTransform = IDENTITY` (gfx-rs/wgpu#3345).
# Without them the Android backend uses `SkiaRenderer::default`, which on
# Android resolves to Skia over OpenGL, where the driver owns the display
# rotation and there is no transform to get wrong.
slint = { workspace = true, features = ["compat-1-2"] }
wgpu.workspace = true
anyhow.workspace = true
# `SettingsError` distinguishes an io failure from a malformed file, which the
@@ -85,7 +88,11 @@ serde_norway = { workspace = true, optional = true }
# 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"] }
slint = { workspace = true, features = [
"backend-winit",
"renderer-femtovg-wgpu",
"unstable-wgpu-29",
] }
[target.'cfg(target_os = "android")'.dependencies]
slint = { workspace = true, features = ["backend-android-activity-06"] }
+648 -73
View File
File diff suppressed because it is too large Load Diff
+243 -56
View File
@@ -9,47 +9,153 @@
//! a derived label, before anyone writes a translation for it. That is the
//! behaviour FR-DEV-3c promises: adding an operation needs no UI change.
/// TRACES: FR-DEV-5
/// The history steps this interface records that no descriptor can name.
///
/// Constants rather than string literals at the call sites, and [`ALL`] rather
/// than a list written out again in a test. A key spelled one way where the
/// step is recorded and another way where it is catalogued resolves through
/// `derive` to something *plausible* — "Mask Toggled" — so the mistake does
/// not look like one. Naming each key once removes the opportunity.
///
/// A step that moved a parameter or an operation is deliberately not here: it
/// is named out of the descriptor, through the same `op.` and `param.` entries
/// the develop panel resolves. That is what lets an operation added as a YAML
/// declaration appear in the history correctly named with nothing written for
/// it here (FR-DEV-3c).
pub mod step {
use dr_pipeline::LocalizedKey;
pub const PASTE: LocalizedKey = LocalizedKey("history.paste");
pub const FILM: LocalizedKey = LocalizedKey("history.film");
pub const RESET_ALL: LocalizedKey = LocalizedKey("history.reset_all");
pub const RESET_OP: LocalizedKey = LocalizedKey("history.reset_op");
pub const RESET_PARAM: LocalizedKey = LocalizedKey("history.reset_param");
pub const RESET_FRAMING: LocalizedKey = LocalizedKey("history.reset_framing");
pub const ROTATE: LocalizedKey = LocalizedKey("history.rotate");
pub const FLIP_H: LocalizedKey = LocalizedKey("history.flip_h");
pub const FLIP_V: LocalizedKey = LocalizedKey("history.flip_v");
pub const MASK_ADDED: LocalizedKey = LocalizedKey("history.mask_added");
pub const MASK_REMOVED: LocalizedKey = LocalizedKey("history.mask_removed");
pub const MASK_TOGGLED: LocalizedKey = LocalizedKey("history.mask_toggled");
pub const MASK_INVERTED: LocalizedKey = LocalizedKey("history.mask_inverted");
pub const MASK_MOVED: LocalizedKey = LocalizedKey("history.mask_moved");
pub const MASK_FEATHER: LocalizedKey = LocalizedKey("history.mask_feather");
pub const MASK_MORPH: LocalizedKey = LocalizedKey("history.mask_morph");
pub const MASK_OPACITY: LocalizedKey = LocalizedKey("history.mask_opacity");
pub const MASK_FALLOFF: LocalizedKey = LocalizedKey("history.mask_falloff");
pub const MASK_MORPHOLOGY: LocalizedKey = LocalizedKey("history.mask_morphology");
/// TRACES: FR-DEV-8
/// A repair's own settings changed — its radius, its source, its opacity.
pub const SPOT: LocalizedKey = LocalizedKey("history.spot");
pub const SPOT_PLACED: LocalizedKey = LocalizedKey("history.spot_placed");
pub const SPOT_MOVED: LocalizedKey = LocalizedKey("history.spot_moved");
pub const SPOT_REMOVED: LocalizedKey = LocalizedKey("history.spot_removed");
/// Every step above, plus the two the core names for itself.
///
/// Exists so the catalogue can be checked against the keys that are
/// actually recorded rather than against a second copy of them. A new step
/// left out of this list is a step with no test — which is the failure
/// this whole arrangement is guarding, so it is worth saying plainly:
/// **add the constant to this slice.**
///
/// Test-only, because checking is the whole of what it is for. Resolving a
/// key at runtime goes through [`super::resolve`] one key at a time and
/// never needs the roll.
#[cfg(test)]
pub const ALL: &[LocalizedKey] = &[
dr_pipeline::history::OPENED,
dr_pipeline::history::UNNAMED,
PASTE,
FILM,
RESET_ALL,
RESET_OP,
RESET_PARAM,
RESET_FRAMING,
ROTATE,
FLIP_H,
FLIP_V,
MASK_ADDED,
MASK_REMOVED,
MASK_TOGGLED,
MASK_INVERTED,
MASK_MOVED,
MASK_FEATHER,
MASK_MORPH,
MASK_OPACITY,
MASK_FALLOFF,
MASK_MORPHOLOGY,
SPOT,
SPOT_PLACED,
SPOT_MOVED,
SPOT_REMOVED,
];
}
/// Resolve a key, deriving a fallback where none is catalogued.
pub fn resolve(key: &str) -> String {
match key {
catalogued(key).map_or_else(|| derive(key), str::to_string)
}
/// The label this build has actually chosen for `key`, if it has chosen one.
///
/// Split out from [`resolve`] so that "is this catalogued?" can be asked, and
/// it is asked by the test that guards the history rows. Those are read as a
/// column of short phrases naming decisions, and `derive` is not up to that
/// job even when it produces real words: it turns `history.mask_toggled` into
/// "Mask Toggled", which is a description of a field rather than of something
/// the photographer did — and, being perfectly readable, is a mistake nobody
/// would look at twice.
///
/// Deriving stays right for `op.` and `param.`, where an operation added as a
/// YAML declaration must show up usable before anyone writes its translation
/// (FR-DEV-3c). The difference is that those keys name a thing and these name
/// an act.
fn catalogued(key: &str) -> Option<&'static str> {
Some(match key {
// Operations
// The attribute names. Short on purpose: these are read as a strip
// of tabs, where a long word crowds out the next one.
"attr.tone" => "Light".into(),
"attr.colour" => "Colour".into(),
"attr.detail" => "Detail".into(),
"attr.optics" => "Optics".into(),
"attr.geometry" => "Geometry".into(),
"attr.effect" => "Effects".into(),
"attr.tone" => "Light",
"attr.colour" => "Colour",
"attr.detail" => "Detail",
"attr.optics" => "Optics",
"attr.geometry" => "Geometry",
"attr.effect" => "Effects",
"op.white_balance" => "White Balance".into(),
"op.exposure" => "Exposure".into(),
"op.highlights_shadows" => "Highlights & Shadows".into(),
"op.blacks_whites" => "Blacks & Whites".into(),
"op.brilliance" => "Brilliance".into(),
"op.vibrance" => "Vibrance".into(),
"op.saturation" => "Saturation".into(),
"op.colour_mixer" => "Colour Mixer".into(),
"op.white_balance" => "White Balance",
"op.exposure" => "Exposure",
"op.highlights_shadows" => "Highlights & Shadows",
"op.blacks_whites" => "Blacks & Whites",
"op.brilliance" => "Brilliance",
"op.vibrance" => "Vibrance",
"op.saturation" => "Saturation",
"op.colour_mixer" => "Colour Mixer",
// "Sharpening" rather than what `derive` would make of the id. The id
// says *capture* sharpening to separate it from the output sharpening
// an export applies (FR-EXP-4), which is a distinction about where in
// the pipeline it sits; in the develop panel there is only one, and
// "Capture Sharpen" would name a distinction the photographer cannot
// see from there.
"op.capture_sharpen" => "Sharpening".into(),
"op.framing" => "Crop & Rotate".into(),
"op.capture_sharpen" => "Sharpening",
"op.framing" => "Crop & Rotate",
// Parameters
"param.temperature" => "Temperature".into(),
"param.tint" => "Tint".into(),
"param.exposure" => "Exposure".into(),
"param.highlights" => "Highlights".into(),
"param.shadows" => "Shadows".into(),
"param.blacks" => "Blacks".into(),
"param.whites" => "Whites".into(),
"param.brilliance" => "Brilliance".into(),
"param.vibrance" => "Vibrance".into(),
"param.saturation" => "Saturation".into(),
"param.temperature" => "Temperature",
"param.tint" => "Tint",
"param.exposure" => "Exposure",
"param.highlights" => "Highlights",
"param.shadows" => "Shadows",
"param.blacks" => "Blacks",
"param.whites" => "Whites",
"param.brilliance" => "Brilliance",
"param.vibrance" => "Vibrance",
"param.saturation" => "Saturation",
// What a faceted parameter adjusts — the mixer's three channels.
//
@@ -57,9 +163,9 @@ pub fn resolve(key: &str) -> String {
// "Lum" from the keys. These name a run of twelve rows apiece, and an
// abbreviation at the head of a section is a word the reader has to
// expand every time they scan past it.
"param.channel.hue" => "Hue".into(),
"param.channel.sat" => "Saturation".into(),
"param.channel.lum" => "Luminance".into(),
"param.channel.hue" => "Hue",
"param.channel.sat" => "Saturation",
"param.channel.lum" => "Luminance",
// The tone curve's four curves, which its points are *subject* to.
//
@@ -69,10 +175,10 @@ pub fn resolve(key: &str) -> String {
// colours would derive correctly and are written out beside it anyway,
// since a list where one entry is translated and three are guessed is
// the shape a half-finished translation takes.
"channel.rgb" => "RGB".into(),
"channel.red" => "Red".into(),
"channel.green" => "Green".into(),
"channel.blue" => "Blue".into(),
"channel.rgb" => "RGB",
"channel.red" => "Red",
"channel.green" => "Green",
"channel.blue" => "Blue",
// The hue bands, which a faceted row is *subject* to.
//
@@ -82,32 +188,76 @@ pub fn resolve(key: &str) -> String {
// nothing. These are also the only place a swatch's meaning is written
// down in words, which is what a photographer who cannot separate the
// squares by eye has to go on.
"band.red" => "Red".into(),
"band.orange" => "Orange".into(),
"band.yellow" => "Yellow".into(),
"band.chartreuse" => "Yellow-Green".into(),
"band.green" => "Green".into(),
"band.spring" => "Blue-Green".into(),
"band.cyan" => "Cyan".into(),
"band.azure" => "Azure".into(),
"band.blue" => "Blue".into(),
"band.violet" => "Violet".into(),
"band.magenta" => "Magenta".into(),
"band.rose" => "Rose".into(),
"band.red" => "Red",
"band.orange" => "Orange",
"band.yellow" => "Yellow",
"band.chartreuse" => "Yellow-Green",
"band.green" => "Green",
"band.spring" => "Blue-Green",
"band.cyan" => "Cyan",
"band.azure" => "Azure",
"band.blue" => "Blue",
"band.violet" => "Violet",
"band.magenta" => "Magenta",
"band.rose" => "Rose",
// Framing. "Straighten" rather than "Angle" because that is the task
// the control performs; the number it reports is still degrees.
"param.angle" => "Straighten".into(),
"param.rotation" => "Rotate".into(),
"param.flip_h" => "Flip Horizontal".into(),
"param.flip_v" => "Flip Vertical".into(),
"param.crop_x" => "Crop Left".into(),
"param.crop_y" => "Crop Top".into(),
"param.crop_w" => "Crop Width".into(),
"param.crop_h" => "Crop Height".into(),
"param.angle" => "Straighten",
"param.rotation" => "Rotate",
"param.flip_h" => "Flip Horizontal",
"param.flip_v" => "Flip Vertical",
"param.crop_x" => "Crop Left",
"param.crop_y" => "Crop Top",
"param.crop_w" => "Crop Width",
"param.crop_h" => "Crop Height",
other => derive(other),
}
// TRACES: FR-DEV-5
// The steps that are not a parameter moving.
//
// Catalogued rather than derived because these are read as a *column*
// of short phrases, where `derive` would give "Mask Added" and "Reset
// Op" — the second of which names a function rather than an action,
// and the first of which reads as a label for a thing rather than for
// something the photographer did. A history is a list of decisions, so
// the rows are written as decisions.
//
// A step naming an operation or a parameter is *not* here: it resolves
// through the same `op.` and `param.` entries above that the panel
// uses, which is what lets an operation added as a YAML declaration
// appear in the history correctly named with no entry written for it
// (FR-DEV-3c).
"history.opened" => "Opened",
"history.edit" => "Edit",
"history.paste" => "Paste Settings",
"history.film" => "Film Stock",
"history.reset_all" => "Reset Everything",
"history.reset_op" => "Reset Group",
"history.reset_param" => "Reset Control",
"history.reset_framing" => "Reset Crop & Rotate",
"history.rotate" => "Rotate",
"history.flip_h" => "Flip Horizontal",
"history.flip_v" => "Flip Vertical",
"history.mask_added" => "Add Mask",
"history.mask_removed" => "Delete Mask",
"history.mask_toggled" => "Mask On/Off",
"history.mask_inverted" => "Invert Mask",
"history.mask_moved" => "Move Mask",
"history.mask_feather" => "Mask Feather",
"history.mask_morph" => "Mask Edge",
"history.mask_opacity" => "Mask Opacity",
"history.mask_falloff" => "Mask Falloff",
"history.mask_morphology" => "Mask Grow/Shrink",
"history.spot" => "Adjust Repair",
"history.spot_placed" => "Add Repair",
"history.spot_moved" => "Move Repair",
"history.spot_removed" => "Delete Repair",
_ => return None,
})
}
/// Turn `op.some_new_thing` into `Some New Thing`.
@@ -137,6 +287,43 @@ fn derive(key: &str) -> String {
mod tests {
use super::*;
#[test]
fn every_step_the_interface_records_has_a_name() {
// A history is only as useful as its rows are distinguishable, and
// these are the ones no descriptor can name — the resets, the flips,
// the mask actions. A key missing from the catalogue derives something
// readable rather than nothing, so the failure this guards is subtler:
// two different steps deriving the *same* word, or a word that names
// the function that ran rather than the decision the user made.
let mut seen: Vec<String> = Vec::new();
for key in step::ALL.iter().map(|k| k.0) {
let label = catalogued(key).unwrap_or_else(|| {
panic!(
"{key} is not catalogued; it would fall through to `derive` \
and read as {:?}, which names a field rather than an act",
derive(key)
)
});
assert!(!label.is_empty(), "{key} resolved to nothing");
assert!(
!seen.contains(&label.to_string()),
"{key} shares the label {label:?} with an earlier step"
);
seen.push(label.to_string());
}
}
#[test]
fn a_step_naming_an_operation_borrows_the_panels_label() {
// What keeps the history honest as the pipeline grows: a step that
// moved a parameter is named by the same entry the slider beside it
// is, so the two can never disagree, and an operation nobody has
// catalogued yet still reads correctly in both places.
assert_eq!(resolve("op.white_balance"), "White Balance");
assert_eq!(resolve("op.tone_curve"), "Tone Curve");
assert_eq!(resolve("param.exposure"), "Exposure");
}
#[test]
fn catalogued_keys_resolve_to_their_label() {
assert_eq!(resolve("op.white_balance"), "White Balance");
+122 -3
View File
@@ -43,9 +43,10 @@ mod segmentation;
mod settings_store;
mod settings_ui;
mod sidecar_cache;
mod spots_ui;
mod trash;
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
use std::path::{Path, PathBuf};
use std::rc::Rc;
@@ -295,6 +296,11 @@ fn reset_view_state(window: &AppWindow) {
// overlay or the crosshair to the next one would offer a selection of
// regions that are not in the picture on screen.
masks_ui::reset(window);
// TRACES: FR-DEV-8
// The repairs belong to one photograph too. The next one's arrive with its
// sidecar a moment later, and until they do the canvas must not be showing
// the last one's.
spots_ui::reset(window);
}
/// Push the framing back to the geometry panel.
@@ -738,6 +744,7 @@ enum PointsUpdate {
///
/// `None` means develop is unavailable and the viewer falls back to embedded
/// previews — the same degradation as a machine with no adapter at all.
#[cfg(not(target_os = "android"))]
fn shared_gpu() -> Option<dr_gpu::GpuContext> {
let shared = match pollster::block_on(dr_gpu::GpuContext::new_shared()) {
Ok(shared) => shared,
@@ -778,6 +785,45 @@ fn shared_gpu() -> Option<dr_gpu::GpuContext> {
Some(ctx)
}
/// TRACES: FR-DSP-1 | AC-8
/// Open the compute device on Android — and do **not** give it to Slint.
///
/// # Why this platform is different
///
/// The desktop version above exists so one `wgpu::Texture` can be written by
/// the adjust pass and sampled by the compositor without a round-trip. That
/// requires Slint to be drawing with wgpu, and on Android drawing with wgpu
/// means drawing on wgpu's Vulkan swapchain — which hardcodes
/// `preTransform = IDENTITY` (gfx-rs/wgpu#3345).
///
/// On a tablet whose panel is mounted landscape, portrait then presents an
/// unrotated buffer, every present returns `VK_SUBOPTIMAL_KHR`, and the frames
/// arrive torn. Measured on the device: portrait sits on `composition=CLIENT`
/// with `bufferTransform=ROT_270`, landscape on `composition=DEVICE` with
/// `ROT_180`, and only portrait tore. Taking Slint off wgpu — it then uses
/// Skia over OpenGL, where the driver owns the rotation — fixed it.
///
/// # What it costs, and why that is the right trade here
///
/// Without a shared device the display path needs a readback:
/// `AdjustPass::export_pixels` instead of `Image::try_from(texture)`. That is
/// the transfer ARCH §6.1 and AC-8 exist to avoid, and it is still the better
/// bargain on this platform — the alternative is not a faster develop view, it
/// is a torn one, in the orientation a tablet is mostly held in.
///
/// The compute passes are untouched: demosaic and adjust still run on the GPU,
/// on this device. Only the last hop to the screen goes through memory.
#[cfg(target_os = "android")]
fn shared_gpu() -> Option<dr_gpu::GpuContext> {
match pollster::block_on(dr_gpu::GpuContext::new_headless()) {
Ok(ctx) => Some(ctx),
Err(e) => {
log::warn!("no GPU for the develop passes: {e}");
None
}
}
}
/// TRACES: M-13 | M-14
/// Build and run the viewer.
pub fn run(paths: Vec<PathBuf>) -> Result<()> {
@@ -1325,9 +1371,16 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
//
// Called on every slider change, so it must do no more than run the
// adjust pass — the demosaic is not repeated.
// TRACES: FR-DEV-5
// The history revision the panel was last built from. See the use below:
// the list is rebuilt when it would read differently, not when the picture
// is redrawn, and those are very different rates.
let drawn_history: Rc<Cell<Option<u64>>> = Rc::new(Cell::new(None));
let render_now: Render = {
let session = session.clone();
let viewport = viewport.clone();
let drawn_history = drawn_history.clone();
Rc::new(move |window: &AppWindow, draft: bool| {
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
@@ -1340,6 +1393,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
window.set_can_undo(s.can_undo());
window.set_can_redo(s.can_redo());
// TRACES: FR-DEV-5 | FR-DEV-7
// The list, on the same path and for the same reason — but only
// when it would read differently. A drag ends in a redraw per
// frame while folding into one step, so an unconditional rebuild
// here would tear down and recreate every row of the panel sixty
// times a second to arrive back at the list already on screen.
let revision = s.history_revision();
if drawn_history.get() != Some(revision) {
drawn_history.set(Some(revision));
window
.set_history_rows(slint::ModelRc::new(slint::VecModel::from(s.history_rows())));
window.set_undo_label(s.undo_label().into());
}
// TRACES: FR-DEV-3
// Which part of the region overlay the view is showing. Here
// rather than in the panel's own sync because a pan or a zoom
@@ -1347,6 +1414,18 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// those ends in a redraw.
masks_ui::sync_overlay_view(window, s);
// TRACES: FR-DEV-8
// And where the repairs are drawn, for the same reason: a pan or a
// zoom moves every circle while touching no repair, and a circle
// left where the mark used to be is worse than no circle at all.
spots_ui::sync_handles(window, s);
// The column's own numbers, pushed from here as well so that a
// photograph opened with repairs already on it arrives with the
// panel describing them — the sidecar lands after the callbacks
// have all been installed, and a redraw is the one path every
// arrival takes.
spots_ui::sync_panel(window, s);
let (mut w, mut h) = *viewport.borrow();
// **Half resolution while the gesture is still moving.**
@@ -2050,6 +2129,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
}
masks_ui::wire(&window, &session, &rows, &redraw);
spots_ui::wire(&window, &session, redraw.clone());
// A way to land on the Identity Manager at startup, for looking at it
// without a mouse. Off unless the variable is set, so it costs a getenv
@@ -2139,7 +2219,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
.and_then(dr_film::find)
.and_then(dr_film::default_print)
.is_some();
s.choose_film(*stock, print);
s.pick_film(*stock, print);
}
sync_rows(&w, &rows, &session);
redraw(&w);
@@ -2153,7 +2233,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
if let Some((stock, _)) = s.film().map(|(a, b)| (a.to_string(), b)) {
s.choose_film(Some(&stock), print);
s.pick_film(Some(&stock), print);
}
}
sync_film(&w, &session);
@@ -2199,6 +2279,27 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
});
}
// TRACES: FR-DEV-5 | FR-DEV-7
// Clicking a row. Arriving six steps away costs what arriving from one
// does, because a step is a whole state — see `History::go_to`.
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_history_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
let stepped = session
.borrow_mut()
.as_mut()
.is_some_and(|s| s.go_to_history(index));
if stepped {
sync_rows(&w, &rows, &session);
redraw(&w);
}
});
}
// ---- zoom, pan and crop ---------------------------------------------
//
// Zoom and pan are viewing state and touch no parameter, so unlike the
@@ -2275,14 +2376,32 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
w.set_crop_h(c.height);
}
ViewMode::Local => s.set_overlay(true),
// TRACES: FR-DEV-8
// Nothing to arm: the circles are drawn whenever there are
// repairs, and what the mode changes is whether a click on
// the photograph makes another one. Leaving the mask
// selection behind would re-point the column at a layer's
// chain while the canvas is showing repairs, which is the
// fault this whole strip exists to prevent.
ViewMode::Spots => {
s.set_overlay(false);
s.set_active_mask(None);
}
ViewMode::Photo => {
s.set_overlay(false);
s.set_active_mask(None);
// A repair stays on the photograph; only the *selection*
// goes, so the source circle does not hang about over a
// frame nobody is repairing any more.
s.select_spot(None);
}
}
}
w.set_view_mode(mode);
masks_ui::sync(&w, &session);
if let Some(s) = session.borrow().as_ref() {
spots_ui::sync_handles(&w, s);
}
// The scope may have just changed, so the panel below is now
// describing a different chain.
sync_rows(&w, &rows, &session);
+3 -2
View File
@@ -606,7 +606,7 @@ mod tests {
let version = sidecar.default_version().expect("a default version");
let mut reopened = EditGraph::default_chain();
version.apply(&mut reopened);
version.apply(&mut reopened).expect_no_film();
assert_eq!(
reopened.masks().len(),
@@ -646,7 +646,8 @@ mod tests {
sidecar
.default_version()
.expect("a version")
.apply(&mut restored);
.apply(&mut restored)
.expect_no_film();
assert_eq!(restored.param(exposure::ID, exposure::EXPOSURE), Some(1.5));
}
+237 -11
View File
@@ -34,6 +34,7 @@ use std::sync::Arc;
use dr_gpu::GpuContext;
use dr_pipeline::mask::segmentation_signature;
use dr_types::Orientation;
/// One recognised object.
#[derive(Debug, Clone)]
@@ -243,27 +244,41 @@ impl Default for Options {
/// Find what can be selected in this photograph.
///
/// `rgb` is the proxy the model reads — tightly packed RGB floats at
/// `(width, height)`. Passed in rather than derived here because the caller
/// already has the rendered proxy, and re-deriving it would mean a second
/// readback of something the CPU is holding.
/// `rgb` is the rendered proxy — tightly packed RGB floats at
/// `(width, height)`, in the **sensor's** own orientation. Passed in rather
/// than derived here because the caller already has it, and re-deriving it
/// would mean a second readback of something the CPU is holding.
///
/// `orientation` is the file's EXIF tag composed with whatever turns the
/// photographer has since applied — `Framing::effective_orientation`, one
/// permutation covering both. The model is shown the picture through it and
/// its answers come back without it, so everything this returns is in sensor
/// space exactly as it was before the detector was taught to read.
pub fn compute(
_ctx: &GpuContext,
rgb: &[f32],
width: usize,
height: usize,
orientation: Orientation,
options: &Options,
) -> Result<Segmentation, String> {
let found = detect(rgb, width, height, options.fine)?;
// The model reads the photograph; everything else here speaks sensor.
let (stood_up, uw, uh) = upright(rgb, width, height, orientation);
let found = detect(&stood_up, uw, uh, options.fine)?;
let instances: Vec<InstanceSummary> = found
.iter()
.filter(|i| i.score >= options.confidence)
.map(|i| InstanceSummary {
class_name: i.class_name.clone(),
score: i.score,
mask: quantise(&i.mask),
bbox: i.bbox,
.map(|i| {
let (mask, bbox) = lay_down(&i.mask, i.bbox, uw, uh, orientation);
InstanceSummary {
class_name: i.class_name.clone(),
score: i.score,
// Quantised after the permutation, so the byte stored is a
// rounding of the model's own coverage and not of a copy.
mask: quantise(&mask),
bbox,
}
})
.collect();
@@ -278,21 +293,122 @@ pub fn compute(
// a signature, a layer built against the coarse pass would be silently
// reinterpreted against the fine one. That is a *wrong* mask, which is
// far worse than a stale one, because nothing announces it.
//
// The orientation is in for the same reason and it is not hypothetical:
// turning the photograph changes what the model recognises, so a run
// before a quarter turn and a run after it are different instance
// lists. Two lists that happened to come out the same length would
// otherwise share a signature, and a layer built against the first
// would be silently re-indexed into the second.
options.confidence.to_bits() as u64
^ if options.fine {
0x9E37_79B9_7F4A_7C15
} else {
0
},
}
^ orientation_key(orientation),
);
Ok(Segmentation {
instances,
signature,
// **Sensor space, not the model's.** `lay_down` put every mask back,
// so the grid a stored layer indexes into is the one it always was —
// see `upright` for why the model saw a different one.
proxy: (width, height),
})
}
/// TRACES: FR-DEV-3 | FR-DEV-3h
/// Turn the proxy the way the photographer is looking at it.
///
/// **Why this exists at all.** A camera held sideways writes its sensor rows
/// the way it always does, and the render puts them right by way of
/// `Framing`. The proxy the model reads is deliberately rendered through a
/// *neutral* graph — the detection has to survive an exposure change, or
/// every slider would invalidate the masks built on it — and neutral took the
/// orientation with it. So the detector was handed a portrait frame lying on
/// its side, and a model trained on upright photographs is very bad at those.
/// Measured end to end on one 22 MP frame of two people and a dog: `person
/// 0.36` and nothing else, against `dog 0.82, person 0.61, person 0.49` for
/// the same pixels stood up.
///
/// One line, because the permutation belongs to
/// [`dr_types::Orientation`] and every other consumer goes through the same
/// one — the grid's thumbnails included, which is what makes "upright" mean
/// one thing across the application rather than one thing per caller.
pub(crate) fn upright(
rgb: &[f32],
width: usize,
height: usize,
orientation: Orientation,
) -> (Vec<f32>, usize, usize) {
let (out, w, h) = orientation.into_shown(rgb, width as u32, height as u32, 3);
(out, w as usize, h as usize)
}
/// TRACES: FR-DEV-3
/// Put what the model answered back onto the sensor's grid.
///
/// The counterpart of [`upright`], and the two are always used as a pair: a
/// mask is only ever in the model's frame between those two calls. Returned
/// together rather than as two functions a caller composes, because calling
/// one and forgetting the other is silent — the mask lands a quarter turn off
/// the subject, which reads as a bad detection rather than as a bug.
///
/// `dw`/`dh` are the *shown* dimensions, as [`upright`] returned them.
pub(crate) fn lay_down(
mask: &[f32],
bbox: (f32, f32, f32, f32),
dw: usize,
dh: usize,
orientation: Orientation,
) -> (Vec<f32>, (f32, f32, f32, f32)) {
let (out, sw, sh) = orientation.into_stored(mask, dw as u32, dh as u32, 1);
(out, lay_down_bbox(bbox, dw, dh, sw, sh, orientation))
}
/// [`lay_down`] for a box.
///
/// Normalised on the way in and scaled on the way out, so the turn itself is
/// `Orientation::into_stored_rect` rather than a fourth copy of the corner
/// arithmetic. A box is the one place a permutation can be *nearly* right —
/// the corners land correctly and `x0 > x1` — so the shared map takes the
/// extremes and this only has to say what space it is in.
fn lay_down_bbox(
bbox: (f32, f32, f32, f32),
dw: usize,
dh: usize,
sw: u32,
sh: u32,
orientation: Orientation,
) -> (f32, f32, f32, f32) {
if dw == 0 || dh == 0 {
return bbox;
}
let (fw, fh) = (dw as f32, dh as f32);
let shown = dr_types::ShownRect {
x: bbox.0 / fw,
y: bbox.1 / fh,
width: (bbox.2 - bbox.0) / fw,
height: (bbox.3 - bbox.1) / fh,
};
let stored = orientation.into_stored_rect(shown);
(
stored.x * sw as f32,
stored.y * sh as f32,
(stored.x + stored.width) * sw as f32,
(stored.y + stored.height) * sh as f32,
)
}
/// One of eight transforms, as bits a signature can carry.
fn orientation_key(orientation: Orientation) -> u64 {
u64::from(orientation.quarter_turns)
| (u64::from(orientation.flip_h) << 2)
| (u64::from(orientation.flip_v) << 3)
}
/// Classes a recognised face may put a name on.
///
/// Only these. A face inside a `tv` or a `laptop` is a photograph of someone on
@@ -499,6 +615,116 @@ mod tests {
assert_eq!(quantise(&[-1.0, 2.0]), vec![0, 255]);
}
/// A non-square, wholly asymmetric grid: every pixel is its own index, so
/// any permutation that is not the intended one shows up as a mismatch
/// rather than being hidden by a symmetry.
fn ramp(w: usize, h: usize) -> Vec<f32> {
(0..w * h).flat_map(|i| [i as f32, 0.0, 0.0]).collect()
}
fn red(rgb: &[f32]) -> Vec<f32> {
rgb.chunks_exact(3).map(|p| p[0]).collect()
}
/// The property the whole fix rests on: what the model is shown and what
/// comes back are the same permutation, run in opposite directions. If
/// they ever disagree, every subject mask lands somewhere other than its
/// subject — and looks like a mask while doing it.
#[test]
fn standing_a_frame_up_and_laying_it_down_is_the_identity() {
const W: usize = 5;
const H: usize = 3;
let source = ramp(W, H);
for tag in 1..=8u16 {
let o = Orientation::from_exif(tag);
let (up, uw, uh) = upright(&source, W, H, o);
let (ow, oh) = o.oriented_size(W as u32, H as u32);
assert_eq!(
(uw, uh),
(ow as usize, oh as usize),
"tag {tag}: the upright size is the oriented one"
);
let (back, _) = lay_down(&red(&up), (0.0, 0.0, 1.0, 1.0), uw, uh, o);
assert_eq!(back, red(&source), "tag {tag} did not come back");
}
}
/// The colour channels must travel together. Reading a pixel three times
/// with one index arithmetic mistake gives a plausible image with its
/// channels sheared, which the model would still detect *something* in.
#[test]
fn a_turn_carries_whole_pixels() {
let rgb: Vec<f32> = (0..2 * 3)
.flat_map(|i| [i as f32, i as f32 + 100.0, i as f32 + 200.0])
.collect();
let o = Orientation::from_exif(6);
let (up, uw, uh) = upright(&rgb, 2, 3, o);
assert_eq!((uw, uh), (3, 2));
for p in up.chunks_exact(3) {
assert_eq!(p[1], p[0] + 100.0, "green left its pixel");
assert_eq!(p[2], p[0] + 200.0, "blue left its pixel");
}
}
/// A portrait frame is the case this exists for: the sensor is landscape,
/// the photograph is not, and the model has to be given the photograph.
#[test]
fn a_sideways_frame_reaches_the_model_upright() {
let o = Orientation::from_exif(6);
assert!(!o.is_normal());
let (_, uw, uh) = upright(&ramp(1600, 1066), 1600, 1066, o);
assert_eq!((uw, uh), (1066, 1600), "the model still got a landscape");
}
/// Where the model's box ends up, worked out by hand for the one turn a
/// portrait phone or a sideways body actually writes.
#[test]
fn a_box_comes_back_in_sensor_pixels() {
let o = Orientation::from_exif(6);
// Shown 4x6; the sensor it came from is 6x4.
let bbox = lay_down_bbox((0.0, 0.0, 2.0, 3.0), 4, 6, 6, 4, o);
assert_eq!(bbox, (0.0, 2.0, 3.0, 4.0));
}
/// Whatever a turn does to a box, it must still read low-to-high — a
/// permutation exchanges which corner is which, and the rest of the mask
/// pipeline measures `(x1 - x0)` without checking the sign.
#[test]
fn a_restored_box_keeps_its_corners_in_order() {
for tag in 1..=8u16 {
let o = Orientation::from_exif(tag);
let (sw, sh) = o.oriented_size(9, 6);
let (x0, y0, x1, y1) = lay_down_bbox((1.0, 2.0, 7.0, 5.0), 9, 6, sw, sh, o);
assert!(x0 <= x1, "tag {tag}: x runs backwards");
assert!(y0 <= y1, "tag {tag}: y runs backwards");
// A permutation moves a box; it does not resize one.
let (sw, sh) = o.oriented_size(9, 6);
assert!(x1 <= sw as f32 && y1 <= sh as f32, "tag {tag}: box escaped");
assert!((((x1 - x0) * (y1 - y0)) - 18.0).abs() < 1e-3, "tag {tag}");
}
}
/// Turning the photograph changes what the model recognises, so the two
/// runs are different instance lists. If they could share a signature, a
/// layer built against one would be silently re-indexed into the other —
/// the same failure the tiling flag is in the signature to prevent.
#[test]
fn turning_the_photograph_changes_the_signature() {
let mut seen = std::collections::HashSet::new();
for tag in 1..=8u16 {
let o = Orientation::from_exif(tag);
assert!(
seen.insert(orientation_key(o)),
"tag {tag} shares a key with an earlier one"
);
}
assert_eq!(seen.len(), 8, "eight tags, but some collapsed");
}
#[test]
fn confidence_changes_the_signature() {
// A different threshold is a different instance list, so the indices a
+292
View File
@@ -0,0 +1,292 @@
//! TRACES: FR-DEV-8
//! Wiring the repair tool to the develop session.
//!
//! Translation only, exactly as [`crate::masks_ui`] is: circles out in one
//! direction, gestures in the other, and every decision in
//! [`crate::develop::DevelopSession`]. What this module does decide is when the
//! canvas is redrawn and when the panel is rebuilt, and those are the two
//! things that go wrong quietly here — see [`sync_handles`] for the one that
//! took a gesture out from under the finger.
use std::cell::RefCell;
use std::rc::Rc;
use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel};
use crate::develop::DevelopSession;
use crate::{AppWindow, SpotHandle};
/// TRACES: FR-DEV-8 | FR-UI-3
/// Move the circles to where the repairs now are.
///
/// # Why this is not `set_spot_handles(VecModel::from(…))`
///
/// **A fresh model kills the gesture that is moving them.** The circles are a
/// repeater over this model, and handing Slint a new `ModelRc` makes it throw
/// the repeated items away and build new ones — the `TouchArea` holding the
/// pointer included. The drag then dies under the finger with the button still
/// down. `masks_ui::sync_handles` carries the same warning, and `develop.rs`
/// carries it about the parameter rows, where it broke slider drags: this is
/// the third place the same mistake is available, which is why it is written
/// down in all three.
///
/// So the model is kept and its rows are rewritten in place.
pub(crate) fn sync_handles(window: &AppWindow, session: &DevelopSession) {
let next = session.spot_handles();
let model = handle_model();
while model.row_count() > next.len() {
model.remove(model.row_count() - 1);
}
for (i, handle) in next.into_iter().enumerate() {
if i < model.row_count() {
// Only where it actually moved: an unchanged row written back is
// still a change notification.
if model.row_data(i).as_ref() != Some(&handle) {
model.set_row_data(i, handle);
}
} else {
model.push(handle);
}
}
window.set_spot_handles(ModelRc::from(model));
window.set_selected_spot(session.selected_spot_id().unwrap_or_default().into());
}
/// The circles' model, held for the life of the process — one shared identity,
/// for the reason [`sync_handles`] gives.
fn handle_model() -> Rc<VecModel<SpotHandle>> {
thread_local! {
static HANDLES: Rc<VecModel<SpotHandle>> = Rc::new(VecModel::default());
}
HANDLES.with(Clone::clone)
}
/// Take the repairs off the canvas between photographs.
///
/// Emptied rather than replaced, keeping the model's identity for the reason
/// [`sync_handles`] gives.
pub(crate) fn reset(window: &AppWindow) {
let model = handle_model();
while model.row_count() > 0 {
model.remove(model.row_count() - 1);
}
window.set_spot_handles(ModelRc::from(model));
window.set_selected_spot(Default::default());
window.set_spot_count(0);
}
/// Install the repair tool's callbacks.
pub(crate) fn wire(
window: &AppWindow,
session: &Rc<RefCell<Option<DevelopSession>>>,
redraw: Rc<dyn Fn(&AppWindow)>,
) {
// --- placing ----------------------------------------------------------
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_spot_placed(move |x, y| {
let Some(w) = weak.upgrade() else { return };
let placed = session
.borrow_mut()
.as_mut()
.and_then(|s| s.place_spot(x, y));
if placed.is_none() {
// The letterbox margin, or a full set. Neither is an error and
// neither should clear the selection: a near-miss that threw
// away what the column was describing would read as hostile.
return;
}
if let Some(s) = session.borrow().as_ref() {
sync_handles(&w, s);
sync_panel(&w, s);
}
sync_undo(&w, &session);
redraw(&w);
});
}
// --- dragging ---------------------------------------------------------
//
// The repair as it stood when the press landed, held for the gesture. A
// drag is applied to *that* rather than accumulated frame by frame: the
// clamps would otherwise compound, so a source dragged past its limit and
// back would not return to where it started, and the result would depend on
// how many pointer events the platform happened to deliver.
let dragging: Rc<RefCell<Option<dr_pipeline::Spot>>> = Rc::new(RefCell::new(None));
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let dragging = dragging.clone();
window.on_spot_handle_dragged(move |id, role, from_x, from_y, to_x, to_y| {
let Some(w) = weak.upgrade() else { return };
let origin = dragging.borrow().clone();
let started = session.borrow_mut().as_mut().and_then(|s| {
s.drag_spot(
id.as_str(),
role,
origin.as_ref(),
(from_x, from_y),
(to_x, to_y),
)
});
if started.is_none() {
return;
}
*dragging.borrow_mut() = started;
// Only the circles. Rebuilding the panel on every frame of a drag
// is work for no difference, and a full rebuild would take the
// gesture out from under the finger — see `sync_handles`.
if let Some(s) = session.borrow().as_ref() {
sync_handles(&w, s);
}
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let dragging = dragging.clone();
window.on_spot_handle_released(move || {
// Forgotten on release, so the next gesture measures from wherever
// this one left the repair.
if dragging.borrow_mut().take().is_none() {
// A press with no movement — a selection, not a drag. Recording
// a step would put an identical snapshot on the undo stack.
return;
}
if let Some(s) = session.borrow_mut().as_mut() {
s.commit_spot_drag();
}
if let Some(w) = weak.upgrade() {
sync_undo(&w, &session);
}
});
}
// --- the column's controls --------------------------------------------
//
// One closure shape, four callbacks: each hands the session a change to
// make to whichever repair is selected, and the session decides whether
// there is one. Reading the selection here instead would put the same
// `Option` check in four places and let them disagree.
macro_rules! control {
($install:ident, $apply:expr) => {{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.$install(move |value| {
let Some(w) = weak.upgrade() else { return };
let changed = session
.borrow_mut()
.as_mut()
.is_some_and(|s| s.set_selected_spot(|spot| $apply(spot, value)));
if !changed {
return;
}
if let Some(s) = session.borrow().as_ref() {
sync_handles(&w, s);
sync_panel(&w, s);
}
sync_undo(&w, &session);
redraw(&w);
});
}};
}
control!(on_spot_radius_changed, |spot: &mut dr_pipeline::Spot, v| {
spot.set_radius(v)
});
control!(
on_spot_feather_changed,
|spot: &mut dr_pipeline::Spot, v| { spot.set_feather(v) }
);
control!(
on_spot_opacity_changed,
|spot: &mut dr_pipeline::Spot, v| { spot.set_opacity(v) }
);
control!(on_spot_mode_picked, |spot: &mut dr_pipeline::Spot, i| {
spot.mode = if i == 1 {
dr_pipeline::SpotMode::Clone
} else {
dr_pipeline::SpotMode::Heal
}
});
// --- selecting and removing -------------------------------------------
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_spot_selected(move |id| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.select_spot(Some(id.as_str()));
}
if let Some(s) = session.borrow().as_ref() {
sync_handles(&w, s);
sync_panel(&w, s);
}
// The canvas changes — the selected repair gains its source circle
// — but no pixel does, so this is a repaint of the overlay rather
// than of the photograph. `redraw` is the only hook there is, and
// it is cheap enough: the render caches on the invalidation key,
// which a selection does not move.
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_spot_removed(move |id| {
let Some(w) = weak.upgrade() else { return };
let removed = session
.borrow_mut()
.as_mut()
.is_some_and(|s| s.remove_spot(id.as_str()));
if !removed {
return;
}
if let Some(s) = session.borrow().as_ref() {
sync_handles(&w, s);
sync_panel(&w, s);
}
sync_undo(&w, &session);
redraw(&w);
});
}
}
/// TRACES: FR-DEV-8
/// Push the selected repair's settings into the column.
///
/// Separate from [`sync_handles`] because they answer different questions and
/// change at different times: the circles move on every frame of a drag and on
/// every pan, and these move when a repair is selected or a control is used.
pub(crate) fn sync_panel(window: &AppWindow, session: &DevelopSession) {
window.set_spot_count(session.spot_count() as i32);
if let Some(spot) = session.selected_spot() {
window.set_spot_radius(spot.radius);
window.set_spot_feather(spot.feather);
window.set_spot_opacity(spot.opacity);
window.set_spot_mode(match spot.mode {
dr_pipeline::SpotMode::Heal => 0,
dr_pipeline::SpotMode::Clone => 1,
});
}
}
/// Push whether undo and redo have anywhere to go.
///
/// Every repair is a history step, so every one of them moves these — and a
/// disabled undo button after an edit that *is* undoable is the kind of small
/// lie that stops people trusting the button at all.
fn sync_undo(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>>) {
window.set_can_undo(session.borrow().as_ref().is_some_and(|s| s.can_undo()));
window.set_can_redo(session.borrow().as_ref().is_some_and(|s| s.can_redo()));
}
+16 -6
View File
@@ -673,6 +673,14 @@ export enum ViewMode {
/// The region map is drawn, a click on the photograph selects, and the
/// column is the mask stack and the selected layer's adjustments.
local,
/// TRACES: FR-DEV-8
/// The repairs are drawn on the photograph, a click places one, and the
/// column describes whichever is selected.
///
/// A mode rather than a panel button for the reason the enum exists at
/// all: arming a click on the canvas is something only one tool may be
/// doing at a time, and two flags could both be true.
spots,
}
/// The strip: what the photographer is working on.
@@ -741,15 +749,17 @@ export component ModeStrip inherits Rectangle {
// than as an underline, so at a glance the strip reads as two runs
// and it is never ambiguous which half a lit entry belongs to.
//
// **Where a brush goes.** Painting a mask is a third mode of exactly
// this shape — it arms a canvas gesture and scopes the column — so it
// joins this list and the `ViewMode` enum, and needs nothing else here.
// `MaskSource::Brush` and the stroke calls on `MaskLayer` land in the
// core separately; what is missing on this side is only the canvas
// interaction, which is the gradient handles' neighbour.
// **Where a canvas tool goes.** A mode that arms a gesture on the
// photograph and scopes the column joins this list and the `ViewMode`
// enum, and needs nothing else here. *Repair* is the first to arrive
// that way (FR-DEV-8); painting a mask is the same shape and still to
// come — `MaskSource::Brush` and the stroke calls on `MaskLayer`
// already exist in the core, and what is missing on this side is only
// the canvas interaction the repairs now have a pattern for.
for entry in [
{ label: "Crop", value: ViewMode.crop },
{ label: "Local", value: ViewMode.local },
{ label: "Repair", value: ViewMode.spots },
]: mode-chip := TouchArea {
width: mode-name.preferred-width + 2 * Theme.gap-sm;
height: Theme.touch-target;
+275 -7
View File
@@ -1,6 +1,8 @@
import { Theme } from "theme.slint";
import { AdjustPanel, GeometryPanel, ModeStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
import { GradientHandle, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
import { SpotHandle, SpotPanel, SpotRole } from "spots.slint";
import { HistoryPanel, HistoryRow } from "history.slint";
import { LaunchScreen } from "launch.slint";
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow } from "library.slint";
@@ -11,7 +13,7 @@ import { SettingsPage } from "settings.slint";
import { ImportPage } from "import.slint";
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView }
export { ViewMode, GradientHandle, HandleRole }
export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
// Status strip — surfaces the GPU backend and adapter, which matters during
// v0.1 because assumption A1 is exactly "does this compositing path work on
@@ -327,6 +329,8 @@ export component AppWindow inherits Window {
/// comparison is written once rather than at each of a dozen call sites.
property <bool> cropping: root.view-mode == ViewMode.crop;
property <bool> local-mode: root.view-mode == ViewMode.local;
/// TRACES: FR-DEV-8
property <bool> repairing: root.view-mode == ViewMode.spots;
/// The crop rect in fractions of the frame, mirrored from Rust so the
/// overlay draws exactly what the pipeline holds.
@@ -372,6 +376,15 @@ export component AppWindow inherits Window {
in property <bool> can-redo: false;
callback undo();
callback redo();
/// Every step, newest first. Rust owns the order and stamps each row with
/// its own position in the stack, so nothing here does arithmetic to turn
/// a row back into a step.
in property <[HistoryRow]> history-rows;
/// What undo would take back, resolved. Empty when there is nowhere to go.
in property <string> undo-label: "";
/// A row's own `index`.
callback history-picked(int);
/// Scroll-to-zoom: factor, and the anchor in fractions of the visible area.
callback zoom-at(float, float, float);
callback pan-by(float, float);
@@ -1019,6 +1032,51 @@ export component AppWindow inherits Window {
/// rather than as one per frame of the gesture.
callback gradient-handle-released();
// --- repairs (FR-DEV-8) -------------------------------------------------
/// Every repair on the photograph, as circles in fractions of the shown
/// image. Rust maps them through the framing, so they follow a crop, a
/// zoom, a pan and a rotation without anything here knowing about any of
/// those.
in property <[SpotHandle]> spot-handles;
/// A click on the photograph in repair mode, in fractions of the shown
/// image: cover what is here.
callback spot-placed(float, float);
/// A repair's circle dragged: which repair, which half of it, where the
/// press landed and where the pointer is now.
///
/// The press is carried rather than a running delta for the reason the
/// gradient handles carry it — the geometry is re-derived from where it
/// stood when the gesture began, so a drag cannot accumulate rounding
/// error along its length.
callback spot-handle-dragged(string, SpotRole, float, float, float, float);
/// A drag finished, so it can be one history step rather than one a frame.
callback spot-handle-released();
/// A repair chosen, so the column describes it.
callback spot-selected(string);
/// Take the selected repair off the photograph.
callback spot-removed(string);
/// Which repair the column is describing, or "" for none.
in-out property <string> selected-spot: "";
/// The selected repair's settings, and how many repairs there are.
///
/// Pushed as scalars rather than read off `spot-handles`, because the
/// circles carry what is *drawn* — a radius already mapped through the
/// framing into fractions of the shown image — and the panel edits what is
/// *stored*. Deriving one from the other would mean a slider that moved
/// differently at different zoom levels.
in property <int> spot-count: 0;
in property <float> spot-radius: 0.012;
in property <float> spot-feather: 0.35;
in property <float> spot-opacity: 1.0;
/// 0 heal, 1 clone.
in property <int> spot-mode: 0;
callback spot-radius-changed(float);
callback spot-feather-changed(float);
callback spot-opacity-changed(float);
callback spot-mode-picked(int);
callback segment-image(bool);
/// A click on the photograph, in fractions of the shown image, plus
/// whether it should extend the selection rather than replace it.
@@ -1765,6 +1823,30 @@ in property <bool> panel-visible: true;
}
}
// TRACES: FR-DEV-8
// Placing a repair, beside the region picker and for the
// same reasons: a click and a pan want different handlers,
// and this one has to reach the click first.
//
// Below the circles declared further down, so a press that
// lands on an existing repair takes hold of it instead of
// making another one on top.
if root.repairing && root.total > 0 && root.load-error == "": TouchArea {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
height: parent.shown-h;
mouse-cursor: MouseCursor.crosshair;
enabled: !root.cropping;
clicked => {
root.spot-placed(
self.mouse-x / max(self.width, 1px),
self.mouse-y / max(self.height, 1px),
);
}
}
if root.total > 0 && root.load-error == "": TouchArea {
x: 0; y: 0;
width: 100%;
@@ -2104,6 +2186,123 @@ in property <bool> panel-visible: true;
}
}
// --- repairs (FR-DEV-8) -------------------------------------
//
// **Drawn at the size they are.** A gradient's handles are
// dots because a gradient has no edge to show; a repair is
// a disc, and whether that disc covers a speck of dust is
// the entire judgement a photographer is making. A
// fixed-size dot standing in for it would say nothing about
// the edit.
//
// The source circle appears for the **selected** repair
// only. Every repair showing both would double the circles
// on a dusty sky and leave no way to tell which source
// belongs to which disc; Rust decides, and simply does not
// send the others.
//
// Positioned against `shown-*` like everything else that
// has to land on the picture, and mapped through the
// framing on the Rust side, so a repair follows a crop, a
// zoom and a rotation rather than sitting where it used to
// be.
for spot in root.spot-handles: Rectangle {
property <length> disc: max(2 * spot.radius * parent.shown-h, 8px);
property <bool> is-source: spot.role == SpotRole.source;
x: parent.shown-x + spot.x * parent.shown-w - self.width / 2;
y: parent.shown-y + spot.y * parent.shown-h - self.height / 2;
// Never smaller than a finger, whatever the repair's
// own size: a spot on a dust speck is a few pixels
// across at fit-to-window, and a target that small
// cannot be picked up again on a phone (FR-UI-3). The
// *drawn* circle keeps its true size; only the reach
// is padded.
width: max(self.disc, Theme.touch-target);
height: self.width;
Rectangle {
width: parent.disc;
height: self.width;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: self.width / 2;
border-width: spot.selected ? 2px : 1px;
// White on a dark ring, so a repair is visible on a
// blown sky and on a black frame alike — the same
// treatment the gradient handles use, and no theme
// token, because this is drawn over the photograph
// rather than over the interface.
border-color: spot.enabled ? #ffffffcc : #ffffff55;
background: parent.is-source
? #ffffff14
: (spot.selected ? #ffffff22 : transparent);
}
// A second, darker ring just outside the first. One
// white circle vanishes against a white sky however it
// is drawn; two rings of opposite tone cannot both
// vanish against anything.
Rectangle {
width: parent.disc + 2px;
height: self.width;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: self.width / 2;
border-width: 1px;
border-color: #00000066;
}
grab := TouchArea {
width: 100%;
height: 100%;
mouse-cursor: MouseCursor.move;
// Where the press landed, in the fractions the
// callback reports in — captured on the way down so
// the gesture is measured from one origin.
property <float> from-x;
property <float> from-y;
function fraction-x(local-x: length) -> float {
return (parent.x + local-x - canvas-area.shown-x)
/ max(canvas-area.shown-w, 1px);
}
function fraction-y(local-y: length) -> float {
return (parent.y + local-y - canvas-area.shown-y)
/ max(canvas-area.shown-h, 1px);
}
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
self.from-x = self.fraction-x(self.pressed-x);
self.from-y = self.fraction-y(self.pressed-y);
// Touching a repair is choosing it. On the
// way *down*, so the column has re-scoped
// by the time the drag begins and the
// controls describe what is moving.
root.spot-selected(spot.id);
}
if (ev.kind == PointerEventKind.up) {
root.spot-handle-released();
}
}
moved => {
if (self.pressed) {
root.spot-handle-dragged(
spot.id,
spot.role,
self.from-x,
self.from-y,
self.fraction-x(self.mouse-x),
self.fraction-y(self.mouse-y),
);
}
}
}
}
// Arrow keys and space step through the folder.
//
// Focused on show rather than waiting for a click, exactly
@@ -2141,6 +2340,18 @@ in property <bool> panel-visible: true;
}
return accept;
}
// TRACES: FR-DEV-8
// Delete removes the selected repair, and only in
// repair mode: the same key in the grid judges
// photographs, and a key that means two things
// depending on a mode the user has forgotten they
// are in is how work disappears.
if (root.repairing && root.selected-spot != ""
&& (event.text == Key.Delete
|| event.text == Key.Backspace)) {
root.spot-removed(root.selected-spot);
return accept;
}
if (event.text == Key.RightArrow || event.text == " ") {
root.next-image();
return accept;
@@ -2183,7 +2394,9 @@ in property <bool> panel-visible: true;
// Names the mode being left rather than saying
// "Done", which was unambiguous while there was
// one mode and would not be with two.
text: root.cropping ? "Done Cropping" : "Done Masking";
text: root.cropping
? "Done Cropping"
: (root.repairing ? "Done Repairing" : "Done Masking");
active: true;
clicked => { root.mode-picked(ViewMode.photo); }
}
@@ -2308,13 +2521,13 @@ in property <bool> panel-visible: true;
// screen. `masks.slint` carries the same note for
// the same reason.
column := VerticalLayout {
if !root.local-mode: InfoPanel {
if !root.local-mode && !root.repairing: InfoPanel {
camera: root.camera;
exposure: root.exposure;
dimensions: root.dimensions;
}
if !root.local-mode: Rectangle {
if !root.local-mode && !root.repairing: Rectangle {
height: 1px;
background: Theme.rule;
}
@@ -2344,7 +2557,7 @@ in property <bool> panel-visible: true;
// made rather than how it is applied: the frame is decided
// by eye first and the pipeline runs it last (see
// `dr_pipeline::framing` on the coordinate order).
if !root.local-mode: GeometryPanel {
if !root.local-mode && !root.repairing: GeometryPanel {
enabled: root.adjust-enabled;
angle: root.straighten;
max-straighten: root.max-straighten;
@@ -2360,7 +2573,7 @@ in property <bool> panel-visible: true;
reset => { root.framing-reset(); }
}
if !root.local-mode: Rectangle {
if !root.local-mode && !root.repairing: Rectangle {
height: 1px;
background: Theme.rule;
}
@@ -2370,7 +2583,7 @@ in property <bool> panel-visible: true;
// burying it under thirty sliders would put the one
// control that operates on all of them below all of
// them.
if !root.local-mode: TransferPanel {
if !root.local-mode && !root.repairing: TransferPanel {
enabled: root.adjust-enabled;
armed: root.settings-armed;
summary: root.settings-summary;
@@ -2393,6 +2606,32 @@ in property <bool> panel-visible: true;
// peers that silently re-points another panel, it is
// what that mode's column *is*. That also takes one
// panel out of a scrolling column that had six.
// TRACES: FR-DEV-8
// What the column *is* in repair mode, on the same
// terms as the mask panel: not a panel among peers
// that quietly re-points the sliders below it, but
// the whole of what this mode has to say.
if root.repairing: SpotPanel {
enabled: root.adjust-enabled;
has-selection: root.selected-spot != "";
count: root.spot-count;
radius: root.spot-radius;
feather: root.spot-feather;
spot-opacity: root.spot-opacity;
mode: root.spot-mode;
radius-changed(v) => { root.spot-radius-changed(v); }
feather-changed(v) => { root.spot-feather-changed(v); }
opacity-changed(v) => { root.spot-opacity-changed(v); }
mode-picked(i) => { root.spot-mode-picked(i); }
removed => { root.spot-removed(root.selected-spot); }
}
if root.repairing: Rectangle {
height: 1px;
background: Theme.rule;
}
if root.local-mode: MaskPanel {
enabled: root.adjust-enabled;
masks: root.mask-rows;
@@ -2469,6 +2708,35 @@ in property <bool> panel-visible: true;
film-picked(i) => { root.film-picked(i); }
film-print-toggled(on) => { root.film-print-toggled(on); }
}
Rectangle {
height: 1px;
background: Theme.rule;
}
// Last in the column, and the length is why. The
// stack runs sixty-four deep, so anywhere else it
// would push the controls it is a record of below
// the fold. Its own rows run newest-first, which
// puts the steps worth reaching immediately under
// the heading rather than at the far end of them.
//
// Present in local mode as well. The mask work
// *is* history — adding a layer, moving a
// gradient and feathering an edge are all steps —
// and a list that emptied when the photographer
// entered the mode that produces the most steps
// would be a list they stopped trusting.
HistoryPanel {
rows: root.history-rows;
enabled: root.adjust-enabled;
can-undo: root.can-undo;
can-redo: root.can-redo;
undo-label: root.undo-label;
undo => { root.undo(); }
redo => { root.redo(); }
picked(i) => { root.history-picked(i); }
}
}
}
}
+192
View File
@@ -0,0 +1,192 @@
// TRACES: FR-DEV-5 | FR-DEV-7
// The steps this photograph has been through, and the way back to any of them.
//
// **Nothing here names an operation, and nothing here names a step.** The rows
// arrive already resolved — Rust turns each step's localisation key into a
// display string against the UI's catalogue — so an operation added to the
// pipeline as a YAML declaration appears in this list under a sensible name
// with no change to this file, on the same terms it appears in the panel
// (FR-DEV-3c).
//
// **Why a list and not just the two buttons.** Undo answers "take back the
// last thing", which is the question a photographer asks about the mistake
// they have just noticed. It is the wrong instrument for the one they notice
// six adjustments later: eight presses, each changing the picture, with no way
// to see how far back the mistake was without passing through it. A step is a
// whole state in `dr-pipeline`, so arriving at one from six away costs what
// arriving from one does — which is what makes a row worth making clickable
// rather than decorative.
import { Theme } from "theme.slint";
import { PanelHeading, Caption, Label, Value, Button } from "widgets.slint";
// One step, flattened for Slint's model system.
export struct HistoryRow {
// Routing back to the core: this step's position in the stack. **Not** its
// position in this list, which runs the other way — see `history-rows` in
// `develop.rs` for why the two are deliberately different numbers.
index: int,
// Resolved in Rust against the UI's catalogue; the core deals in keys.
label: string,
// The state the photograph is in right now. Exactly one row carries it.
current: bool,
// A step the photographer has stepped back *out* of, still reachable by
// redo. Listed rather than hidden — redo would otherwise arrive somewhere
// this panel never mentioned — but drawn as the branch it is.
undone: bool,
}
component StepRow inherits Rectangle {
in property <HistoryRow> data;
in property <bool> enabled: true;
callback picked();
height: Theme.row-height;
background: root.data.current
? Theme.selected
: (touch.has-hover ? Theme.hover : transparent);
touch := TouchArea {
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
enabled: root.enabled;
mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default;
clicked => { root.picked(); }
}
HorizontalLayout {
// Small, because the panel around this already pads. The row's
// background is the highlight for the current step, so it wants to be
// a band the width of the column rather than a chip inset from it.
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
// The mark for where the photograph stands. A filled bar against the
// leading edge rather than a tick beside the name: the eye finds one
// edge down a column of forty rows, and a glyph in the text column
// would have to be read.
Rectangle {
width: 2px;
height: parent.height;
background: root.data.current ? Theme.active : transparent;
}
Label {
text: root.data.label;
emphasised: root.data.current || touch.has-hover;
horizontal-stretch: 1;
overflow: elide;
// Dimmed rather than removed: this step is a future the
// photographer stepped out of, and it is still where redo goes.
opacity: root.data.undone ? 0.45 : 1.0;
}
}
}
export component HistoryPanel inherits Rectangle {
in property <[HistoryRow]> rows;
in property <bool> enabled: true;
in property <bool> can-undo: false;
in property <bool> can-redo: false;
/// What undo would take back, already resolved. Empty when there is
/// nowhere to go.
in property <string> undo-label: "";
callback undo();
callback redo();
/// A row's own `index`, not its position in `rows`.
callback picked(int);
background: Theme.surface;
// **Flat, deliberately** — `MaskPanel` carries the long version of this
// and it applies here unchanged: a nested layout under-reports its height,
// so what follows it gets drawn on top of what came before, which is
// invisible in the source and obvious the moment anyone opens the panel.
// Every element is a direct child of the one layout that measures them,
// and the ones that come and go carry their own condition rather than
// being grouped inside a wrapper.
//
// No explicit height either, for the same reason `AdjustPanel` and
// `MaskPanel` declare none: the row count changes as the photographer
// works, and a height pinned to a layout's preferred size is one more
// thing that has to keep up with a repeater.
VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
// A heading, not a collapsible. `GeometryPanel` carries the argument
// and it holds here: the list is last in the column, so what a lid
// would save is scrolling past nothing.
HorizontalLayout {
PanelHeading { text: "HISTORY"; }
Rectangle { horizontal-stretch: 1; }
if root.enabled && root.rows.length > 1: Value {
text: root.rows.length - 1 + (root.rows.length == 2 ? " step" : " steps");
}
}
if !root.enabled: Caption { text: "No image"; }
// The pair, here as well as in the status strip. The strip's copy is
// the one that survives the column being put away; this one sits
// against the list that says what it will do, which is the pairing
// that makes either of them legible.
if root.enabled: HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "Undo";
enabled: root.can-undo;
horizontal-stretch: 1;
clicked => { root.undo(); }
}
Button {
text: "Redo";
enabled: root.can-redo;
horizontal-stretch: 1;
clicked => { root.redo(); }
}
}
// What the left-hand button would take back, spelled out.
//
// On a caption rather than on the button, because `Button` sizes to
// its label and does not elide: "Undo Highlights & Shadows" is most of
// a 280px column on its own, and two of those would lever the column
// open — `MaskPanel` has the same note about an unwrapped sentence.
//
// Undo only. Redo's destination is the row directly above the mark in
// the list below, where it can be seen; the step undo takes back is
// the one the photographer is standing on and stopped tracking after a
// run of small adjustments, which is the moment they are least willing
// to press a button to find out.
if root.enabled && root.can-undo: Caption {
text: "Undo: " + root.undo-label;
overflow: elide;
}
if root.enabled && root.rows.length > 0: Rectangle {
height: 1px;
background: Theme.rule;
}
// Newest first. The list is consulted to take back something just
// done rather than browsed from the beginning — the order
// `dr_catalog::trash` settled on for the same question — and it keeps
// the end being worked at against the heading rather than sixty-four
// rows below it.
for row in root.rows: StepRow {
data: row;
enabled: root.enabled;
picked => { root.picked(row.index); }
}
}
}
+170
View File
@@ -0,0 +1,170 @@
import { Theme } from "theme.slint";
import { Button, PanelHeading, Caption, Value } from "widgets.slint";
import { Segmented, SliderRow } from "controls.slint";
// TRACES: FR-DEV-8
// Spot removal on the canvas: what is drawn over the photograph, and what a
// finger can take hold of.
//
// A repair is two circles and the line between them — the disc being covered,
// and the patch it is copied from. Both are drawn at the size they actually
// are, not as abstract handles, because the size *is* the edit: a photographer
// judging whether a disc covers a mark is judging a circle against a speck, and
// a fixed-size dot standing in for it would tell them nothing.
//
// Positions arrive already mapped through the framing, as fractions of the
// shown image, exactly as `GradientHandle` does — see `spots_ui.rs`. Nothing
// here knows what a crop is.
/// Which half of a repair a handle is.
export enum SpotRole {
/// The disc over the mark. Dragging it moves the whole repair, source and
/// all, which is what a photographer means by nudging a spot.
destination,
/// Where the patch is read from. Dragging it moves the source alone.
source,
}
/// One circle of one repair, in fractions of the shown image.
export struct SpotHandle {
id: string,
role: SpotRole,
/// Centre, in fractions of the shown image's width and height.
x: float,
y: float,
/// The disc's radius as a fraction of the shown image's **height**.
///
/// One axis rather than two, because a repair is a circle: normalising x
/// by the width and y by the height would draw it as an ellipse on every
/// frame that is not square. The caller resolves the aspect on the way in.
radius: float,
/// Whether this repair is the one the panel is describing.
selected: bool,
/// Whether the repair draws at all — a spot switched off is still shown,
/// faintly, because it is still an edit somebody made.
enabled: bool,
}
/// TRACES: FR-DEV-8
/// The column while the repair tool is up.
///
/// One repair at a time, because that is how repairs are made: a photographer
/// covers a mark, looks at it, and moves on. There is no stack here for the
/// same reason there is one for masks — a mask is a thing you come back to and
/// re-shape, and a repair is either right or deleted.
///
/// **The controls describe the selected repair, and when none is selected they
/// describe nothing.** An earlier shape had them set the defaults for the
/// *next* repair, which reads identically on screen and does something
/// completely different: a photographer dragging Size with nothing selected
/// would see no change on the photograph and conclude the slider was broken.
export component SpotPanel inherits Rectangle {
in property <bool> enabled: true;
/// Whether a repair is selected — everything below is about it.
in property <bool> has-selection: false;
/// How many repairs are on this photograph.
in property <int> count: 0;
/// In frame units, which is what the model stores. The slider shows them
/// as a percentage of the frame's height, since "0.012" means nothing to
/// anybody and "1.2%" at least says how much of the picture is covered.
in property <float> radius: 0.012;
in property <float> feather: 0.35;
// `spot-opacity` and not `opacity`, which every element already has as a
// built-in: overriding it is refused by the compiler, and had it been
// allowed it would have faded the panel instead of describing the repair.
in property <float> spot-opacity: 1.0;
/// 0 heal, 1 clone — the order the chips are listed in below.
in property <int> mode: 0;
callback radius-changed(float);
callback feather-changed(float);
callback opacity-changed(float);
callback mode-picked(int);
callback removed();
background: Theme.surface;
// Flat rather than nested, for the reason `MaskPanel` gives: a nested
// conditional layout under-reported its height and drew rows on top of one
// another.
VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
HorizontalLayout {
PanelHeading { text: "REPAIR"; }
Rectangle { horizontal-stretch: 1; }
if root.count > 0: Value {
text: root.count + (root.count == 1 ? " spot" : " spots");
}
}
if !root.enabled: Caption { text: "No image"; }
// The instruction, which is the whole interface until the first click.
// A tool whose canvas gesture is its only way in has to say so, or it
// is a mode that appears to do nothing.
if root.enabled && !root.has-selection: Caption {
text: root.count == 0
? "Click a mark on the photograph to cover it."
: "Click a mark to cover it, or a circle to adjust it.";
wrap: word-wrap;
}
if root.enabled && root.has-selection: SliderRow {
label: "Size";
hint: "% of frame";
value: root.radius * 100;
default-value: 1.2;
minimum: 0.1;
maximum: 25;
precision: 1;
changed(v) => { root.radius-changed(v / 100); }
reset => { root.radius-changed(0.012); }
}
if root.enabled && root.has-selection: SliderRow {
label: "Feather";
hint: "% of size";
value: root.feather * 100;
default-value: 35;
minimum: 0;
maximum: 100;
precision: 0;
changed(v) => { root.feather-changed(v / 100); }
reset => { root.feather-changed(0.35); }
}
if root.enabled && root.has-selection: SliderRow {
label: "Opacity";
value: root.spot-opacity * 100;
default-value: 100;
minimum: 0;
maximum: 100;
precision: 0;
changed(v) => { root.opacity-changed(v / 100); }
reset => { root.opacity-changed(1.0); }
}
// Heal first, because it is the default and the right answer for dust.
// Clone is the escape for a repair that straddles an edge, where
// interpolating the boundary smears the edge across the disc.
if root.enabled && root.has-selection: Segmented {
label: "Blend";
options: ["Heal", "Clone"];
selected: root.mode;
picked(i) => { root.mode-picked(i); }
}
if root.enabled && root.has-selection: Rectangle {
height: Theme.gap-sm;
}
if root.enabled && root.has-selection: Button {
text: "Delete Repair";
clicked => { root.removed(); }
}
}
}