Files
DarkRoom/ui/dr-ui/src/develop_ui.rs
T
dtourolle d489a34190 Drive develop, the grid, the sidebar and People from the keyboard
An audit of every action by view against the keys the handlers bind left
develop without zoom, pan, fit or a way back to the grid, the grid without
select-none, thumbnail size or keywording, People with no key at all, and
the export and copy sheets without Enter. It also found the reverse gap
FR-UI-5 forbids: pick and reject had no route but P, X and U, and the
2026-09-19 amendment's judging in develop had not been built.

Develop: Ctrl+= and Ctrl+Plus zoom in and Ctrl+- out about the middle of the
view, Ctrl+0 fits and Ctrl+1 goes to 1:1, Shift and an arrow pan a magnified
view, G goes back to the grid, Ctrl+Y redoes, and Enter keeps a crop that hid
a mask. 0-5, P, X and U rate and flag the open photograph without moving on,
with stars and Pick/Reject in the top bar as the pointer and touch route.
= and - nudge the control last moved by a hundredth of its travel; the
framing sliders, perspective included, now count as "last moved", so R puts
them back as well. J turns the selected mask part's join chip.

Grid: Ctrl+D and Ctrl+Shift+A clear the selection, = and - resize the
thumbnails, Ctrl+K opens keywording, and Flag in the selection bar gives
pick and reject a pointer and touch route. Sidebar: Enter commits a
collection's name, and Enter or Escape hands the keyboard back to the grid,
where it used to go nowhere until something was clicked. People: Up and Down
walk the rail, F2 puts the name field under the keys, and Escape or Back now
leave the screen the way its back button does instead of doing nothing.
Sheets: Enter does what the export or copy sheet's button does.

The choices follow Lightroom where it has one. No new key steals typing: the
grid's and People's keys live on focus holders that are not ancestors of any
text field, and the sheets' Enter comes after a focused field has had it.
Every binding is tagged beside its handler, and the gate added in the
previous commit holds the two to each other.
2026-09-24 23:42:26 -04:00

1433 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The develop screen's callback wiring.
//!
//! This used to be the bulk of `lib.rs::run` — every `window.on_*` and
//! `global::<...>().on_*` registration the develop view needs, with no
//! module of its own, unlike every other screen (see
//! `docs/dev/code-health.md` CH-1). [`wire`] is the list `run` now calls;
//! each `wire_*` below is one of the sections that function used to contain,
//! moved with its section comment kept as the function's doc comment and its
//! callbacks registered in the same order.
//!
//! [`DevelopWiring`] gathers what those sections capture — the session, the
//! shared rows model, the redraw and render closures, and the handles onto
//! the other controllers a paste or an export reaches — so each function
//! takes one reference instead of a dozen parameters.
use std::cell::{Cell, RefCell};
use std::path::PathBuf;
use std::rc::Rc;
use slint::ComponentHandle as _;
use crate::activity;
use crate::collections_ui::CollectionsController;
use crate::develop;
use crate::library_ui::LibraryController;
use crate::masks_ui;
use crate::peaking;
use crate::presets;
use crate::settings_ui::SettingsController;
use crate::spots_ui;
use crate::{
apply_crop_aspect, batch_request, drain_outbox, export, groups_in_rail, refresh_export_label,
render_open_frame, sync_crop_aspect, sync_film, sync_framing, sync_rows, Adjustments,
AppWindow, Develop, DevelopSession, Framing, Inspection, Library, ParamRow, Peaking, Render,
Steps, ViewMode,
};
/// Shared state the develop screen's callbacks capture.
///
/// Built once in [`wire`] from what `run` already owns; each field is an
/// owned `Rc` clone rather than a borrow, so every `wire_*` function below
/// can clone what it needs out of a `&DevelopWiring` and paste in the
/// closure exactly as `run` wrote it. `window` is not a field here — it is
/// not `Clone`, so it travels as an ordinary `&AppWindow` parameter beside
/// `w`, the same way `run` held it.
#[allow(clippy::type_complexity)]
pub(crate) struct DevelopWiring {
pub(crate) session: Rc<RefCell<Option<DevelopSession>>>,
pub(crate) rows: Rc<slint::VecModel<ParamRow>>,
pub(crate) redraw: Rc<dyn Fn(&AppWindow)>,
pub(crate) render_now: Render,
pub(crate) show: Rc<dyn Fn(&AppWindow)>,
pub(crate) entries: Rc<RefCell<Vec<PathBuf>>>,
pub(crate) index: Rc<RefCell<usize>>,
pub(crate) viewport: Rc<RefCell<(u32, u32)>>,
pub(crate) inspection: Rc<Cell<Inspection>>,
pub(crate) chosen_peaking: Rc<Cell<Option<dr_gpu::FocusPeaking>>>,
pub(crate) settings: Rc<SettingsController>,
pub(crate) library: Rc<LibraryController>,
pub(crate) collections: Rc<CollectionsController>,
pub(crate) activity: Rc<activity::ActivityLog>,
pub(crate) gpu: Option<dr_gpu::GpuContext>,
pub(crate) clipboard: Rc<presets::Clipboard>,
pub(crate) open_image: presets::OpenImage,
pub(crate) leave_develop: Rc<RefCell<Option<Rc<dyn Fn()>>>>,
}
/// Install every callback the develop screen answers to.
///
/// Called once from `run`, after the session, the render loop and the other
/// screens all exist — every section below reaches at least one of them.
/// The order matches what `run` registered: presets, export, the adjustment
/// panel, film, undo/redo, geometry, then navigation and peaking.
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub(crate) fn wire(
window: &AppWindow,
session: &Rc<RefCell<Option<DevelopSession>>>,
rows: &Rc<slint::VecModel<ParamRow>>,
redraw: &Rc<dyn Fn(&AppWindow)>,
render_now: &Render,
show: &Rc<dyn Fn(&AppWindow)>,
entries: &Rc<RefCell<Vec<PathBuf>>>,
index: &Rc<RefCell<usize>>,
viewport: &Rc<RefCell<(u32, u32)>>,
inspection: &Rc<Cell<Inspection>>,
chosen_peaking: &Rc<Cell<Option<dr_gpu::FocusPeaking>>>,
settings: &Rc<SettingsController>,
library: &Rc<LibraryController>,
collections: &Rc<CollectionsController>,
activity: &Rc<activity::ActivityLog>,
gpu: &Option<dr_gpu::GpuContext>,
clipboard: &Rc<presets::Clipboard>,
open_image: &presets::OpenImage,
leave_develop: &Rc<RefCell<Option<Rc<dyn Fn()>>>>,
) {
let w = DevelopWiring {
session: session.clone(),
rows: rows.clone(),
redraw: redraw.clone(),
render_now: render_now.clone(),
show: show.clone(),
entries: entries.clone(),
index: index.clone(),
viewport: viewport.clone(),
inspection: inspection.clone(),
chosen_peaking: chosen_peaking.clone(),
settings: settings.clone(),
library: library.clone(),
collections: collections.clone(),
activity: activity.clone(),
gpu: gpu.clone(),
clipboard: clipboard.clone(),
open_image: open_image.clone(),
leave_develop: leave_develop.clone(),
};
// TRACES: FR-DEV-3
// **The ratio lock belongs to the tool, not to the photograph.** Lives
// here, for the life of the window, rather than on a `DevelopSession`
// which is rebuilt per image — see `wire_zoom_pan_crop` — and is shared
// with `wire_rotation_flips_straighten`, which is the other place a turn
// or a flip has to keep it in step.
let crop_aspect = Rc::new(Cell::new(develop::CropAspect::default()));
let crop_portrait = Rc::new(Cell::new(false));
wire_presets(window, &w);
wire_export(window, &w);
wire_adjustments(window, &w);
wire_film(window, &w);
wire_undo_redo(window, &w);
wire_zoom_pan_crop(window, &w, &crop_aspect, &crop_portrait);
wire_rotation_flips_straighten(window, &w, &crop_aspect, &crop_portrait);
wire_navigation(window, &w);
wire_peaking(window, &w);
}
/// ---- copying settings between photographs (FR-DEV-6) ----------------
///
/// Wired after the develop session and the library both exist, because a
/// paste reaches both: onto the image on screen, or onto the grid's
/// selection through its sidecars.
fn wire_presets(window: &AppWindow, w: &DevelopWiring) {
let session = w.session.clone();
let rows = w.rows.clone();
let redraw = w.redraw.clone();
let clipboard = w.clipboard.clone();
let open_image = w.open_image.clone();
let settings = w.settings.clone();
let library = w.library.clone();
let collections = w.collections.clone();
let leave_develop = w.leave_develop.clone();
presets::wire(
window,
clipboard.clone(),
presets::Develop {
session: session.clone(),
rows: rows.clone(),
redraw: redraw.clone(),
open: open_image.clone(),
},
settings.clone(),
library.clone(),
collections.clone(),
);
// TRACES: FR-DEV-6
// The saved half of the same requirement, wired from the same bundle:
// applying a named preset to the open image is the paste path with a
// different source.
presets::wire_named(
window,
presets::NamedPresets::open(),
presets::Develop {
session: session.clone(),
rows: rows.clone(),
redraw: redraw.clone(),
open: open_image.clone(),
},
settings.clone(),
library.clone(),
collections.clone(),
);
// Close the knot left open beside `open_from_library`: the grid's
// "‹ Library" button was wired before there was a session to save.
let weak = window.as_weak();
let open_image = open_image.clone();
let session = session.clone();
let library = library.clone();
*leave_develop.borrow_mut() = Some(Rc::new(move || {
let Some(w) = weak.upgrade() else { return };
presets::save_open_edit(&w, &open_image.borrow(), &session, &library);
}));
// TRACES: FR-DEV-6
// The scope chips, shared by the preset sheet and the settings page.
// Wired once and rendered once: both surfaces draw one set, because
// "what does a paste carry" is one question about how this
// photographer works rather than one per place it is asked.
presets::wire_scope(window, settings.clone(), clipboard.clone());
presets::render_scope(window, &settings);
// TRACES: FR-UI-1
// Rendered from the same settings controller and at the same moment,
// so the rail and the strip cannot both be on screen after a change to
// the preference that chooses between them.
window
.global::<Adjustments>()
.set_groups_in_rail(groups_in_rail(&settings));
presets::render(window, &clipboard, &settings);
}
/// --- export (FR-EXP-6, FR-EXP-7, FR-EXP-9) ---------------------------
///
/// Two buttons, one worker. The develop view exports the image on screen and
/// the grid exports its selection; the only difference between them is who
/// renders the frames, so both hand a `Vec<Source>` to the same batch and
/// both report through the same activity row.
///
/// A frame is always rendered at full resolution rather than taken from the
/// canvas: the display render is deliberately viewport-sized (FR-DSP-1), and
/// exporting that would hand the user a soft, screen-sized file with no
/// indication anything had been lost (FR-EXP-9).
fn wire_export(window: &AppWindow, w: &DevelopWiring) {
let session = w.session.clone();
let settings = w.settings.clone();
let library = w.library.clone();
let collections = w.collections.clone();
let activity = w.activity.clone();
let gpu = w.gpu.clone();
// Replaced at each start, so the button always cancels the run it is
// sitting on and a stale token cancels nothing.
let cancel: Rc<RefCell<export::Cancel>> = Rc::new(RefCell::new(export::Cancel::default()));
// Holds the drain. Assigning a new timer drops the previous one, which
// is what takes a superseded batch's row out of the register.
let drain: Rc<RefCell<Option<slint::Timer>>> = Rc::new(RefCell::new(None));
let start = {
let settings = settings.clone();
let library = library.clone();
let activity = activity.clone();
let gpu = gpu.clone();
let cancel = cancel.clone();
let drain = drain.clone();
Rc::new(
move |window: &AppWindow, sources: Vec<export::Source>, to: export::Reporting| {
let total = sources.len();
let request = batch_request(sources, &settings, &library, gpu.as_ref());
let token = export::Cancel::default();
*cancel.borrow_mut() = token.clone();
let rx = export::spawn_batch(request, token);
// Upload the moment the batch is done rather than waiting
// for a sync pass. An export bound for the server is
// complete on disk the instant it is staged, but it is not
// where the user asked for it until this runs — and
// "Queued for Exports" sitting unchanged until somebody
// presses Sync reads as an export that did not upload.
let library_for_drain = library.clone();
export::drain_batch(
window.as_weak(),
&activity,
&drain,
rx,
total,
to,
move || drain_outbox(&library_for_drain),
);
},
)
};
{
let weak = window.as_weak();
let session = session.clone();
let settings = settings.clone();
let start = start.clone();
window.on_export_image(move || {
let Some(w) = weak.upgrade() else { return };
// One batch at a time, and the grid's counts as one. The two
// buttons share a worker slot, so starting a second run would
// drop the first's drain — leaving a batch still writing files
// with no progress row and a button that never comes back.
if w.get_export_busy() || w.global::<Library>().get_library_exporting() {
w.set_export_status("An export is already running".into());
return;
}
w.set_export_busy(true);
// Set before the render rather than after: the render is the
// one part still on this thread, so a label written afterwards
// would never be drawn in the "Rendering…" state at all.
w.set_export_status("Rendering…".into());
match render_open_frame(&w, &session, settings.snapshot().export.colour_space) {
Ok(source) => start(&w, vec![source], export::Reporting::Develop),
Err(e) => {
w.set_export_status(format!("Export failed: {e}").into());
w.set_export_busy(false);
}
}
refresh_export_label(&w, &settings);
});
}
// TRACES: FR-EXP-7
// The grid's selection. Nothing is rendered here — the worker fetches,
// decodes and renders each photograph itself, so this returns to the
// event loop immediately and a batch of three hundred is a progress bar
// rather than a frozen window.
{
let weak = window.as_weak();
let library = library.clone();
let collections = collections.clone();
let start = start.clone();
window
.global::<Library>()
.on_library_export_selection(move || {
let Some(w) = weak.upgrade() else { return };
// See the develop button above for why one run excludes the
// other. The button is a cancel by then, so this only catches
// a batch started from develop and left running.
if w.global::<Library>().get_library_exporting() || w.get_export_busy() {
w.global::<Library>()
.set_library_status("An export is already running".into());
return;
}
let sources = library.export_sources(&collections.selected());
if sources.is_empty() {
// Said out loud rather than ignored, matching what a paste
// or a judgement keystroke does with an empty selection.
w.global::<Library>()
.set_library_status("Select an image first".into());
return;
}
w.global::<Library>().set_library_exporting(true);
w.global::<Library>()
.set_library_status(format!("Exporting {} images…", sources.len()).into());
start(&w, sources, export::Reporting::Library);
});
}
// TRACES: NFR-ARCH-3
{
let weak = window.as_weak();
window
.global::<Library>()
.on_library_cancel_export(move || {
cancel.borrow().cancel();
if let Some(w) = weak.upgrade() {
// The worker stops at the next point it is safe to — which
// may be a frame away — so the button says "asked for" and
// not "done". The drain writes the real answer.
w.global::<Library>()
.set_library_status("Cancelling the export…".into());
}
});
}
}
/// ---- Adjustment callbacks ------------------------------------------
///
/// Generic by construction: they carry indices into the capability list,
/// so adding an operation needs no change here (FR-DEV-3c).
fn wire_adjustments(window: &AppWindow, w: &DevelopWiring) {
let session = w.session.clone();
let rows = w.rows.clone();
let redraw = w.redraw.clone();
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window
.global::<Adjustments>()
.on_param_changed(move |op, param, value| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_param(op, param, value);
// TRACES: FR-DEV-3f
// The film's own exposures ride *inside* the baked tables
// rather than arriving as uniforms, because the print balance
// is solved against them — an enlarger's filtration depends on
// how the negative was exposed. So moving one has to rebuild
// the lookup, which no other slider in the panel does.
s.rebake_film_if_affected(op);
}
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window
.global::<Adjustments>()
.on_param_reset(move |op, param| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.reset_param(op, param);
}
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
{
// TRACES: FR-DEV-16
// The nudge keys, on the control last moved: a hundredth of its
// travel, which is the step the slider's own increment takes. Read
// off the row on screen rather than the descriptor, because the row
// is what the photographer is looking at — with masks selected it is
// the layer's value, not the global one.
//
// Scalars only. A switch or a choice has no "a little more", and a
// curve's points are not one number.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window
.global::<Adjustments>()
.on_param_nudged(move |op, param, direction| {
use slint::Model as _;
let Some(w) = weak.upgrade() else { return };
let shown = w.global::<Adjustments>().get_rows();
let Some(row) = shown
.iter()
.find(|r| r.op_index == op && r.param_index == param)
else {
return;
};
if row.kind != "scalar" || row.maximum <= row.minimum {
return;
}
let step = (row.maximum - row.minimum) / 100.0;
let value =
(row.value + direction.signum() as f32 * step).clamp(row.minimum, row.maximum);
if let Some(s) = session.borrow_mut().as_mut() {
s.set_param(op, param, value);
s.rebake_film_if_affected(op);
}
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
// TRACES: FR-DEV-3
// The local-adjustment panel. Wired as a block rather than inline because
// it is a dozen callbacks that all say the same three things, and they
// read better beside each other than scattered through this function.
{
// Choosing a group re-filters the panel and nothing else — no edit,
// no render. It is navigation.
let weak = window.as_weak();
let session = session.clone();
let rows = rows.clone();
window.global::<Adjustments>().on_tab_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_active_tab(index);
}
sync_rows(&w, &rows, &session);
// The stock picker is filtered by group like everything else, and
// it is not a row — so the thing that decides whether it is on
// screen has to be recomputed here as well, or it would answer for
// whichever group happened to be open when the image was loaded.
sync_film(&w, &session);
});
}
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
// per launch and changes nothing otherwise.
if std::env::var_os("DARKROOM_START_IDENTITY").is_some() {
window.invoke_identity_open();
}
if std::env::var_os("DARKROOM_START_SETTINGS").is_some() {
window.invoke_settings_open();
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.global::<Adjustments>().on_reset_all(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.reset_all();
}
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
{
// A curve is one control spanning many parameters, so resetting it
// clears all of them at once — resetting a single point would leave
// a shape the user did not ask for.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.global::<Adjustments>().on_curve_reset(move |op| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.reset_curve(op);
}
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
{
// Which of the curve's curves the plot is showing. **No redraw**, and
// that is the whole character of this control: it changes no
// parameter, so the photograph is already correct on screen and
// recomputing it would be a frame spent to produce the same pixels.
// For the same reason it records no history step — there is nothing
// to undo — and the sidecar never hears about it.
let weak = window.as_weak();
let session = session.clone();
let rows = rows.clone();
window
.global::<Adjustments>()
.on_curve_channel_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_curve_channel(index);
}
sync_rows(&w, &rows, &session);
});
}
}
/// ---- the film stock (FR-DEV-3f) -------------------------------------
///
/// Unlike the curve channel above, both of these change the photograph:
/// choosing a stock *is* the edit. So they redraw, and they leave the
/// session dirty for the sidecar in the ordinary way.
fn wire_film(window: &AppWindow, w: &DevelopWiring) {
let session = w.session.clone();
let rows = w.rows.clone();
let redraw = w.redraw.clone();
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.global::<Adjustments>().on_film_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
// Index zero is "None" — see `DevelopSession::film_choices`, which
// puts it first so that the neutral choice needs no sentinel.
let choices = DevelopSession::film_choices();
let Some((stock, _)) = choices.get(index.max(0) as usize) else {
return;
};
if let Some(s) = session.borrow_mut().as_mut() {
// Printed by default when the stock has a paper: a colour
// negative that has not been printed is an orange strip, and
// offering that as the first thing a photographer sees when
// they pick Portra would read as a bug rather than as a
// choice. The toggle is there for anyone who wants the scan.
let print = stock
.and_then(dr_film::find)
.and_then(dr_film::default_print)
.is_some();
s.pick_film(*stock, print);
}
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window
.global::<Adjustments>()
.on_film_print_toggled(move |print| {
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.pick_film(Some(&stock), print);
}
}
sync_film(&w, &session);
redraw(&w);
});
}
}
/// ---- undo and redo (FR-DEV-5) ---------------------------------------
///
/// Thin, because the history lives in the session and every mutator there
/// records into it — see `DevelopSession::history`. What is left for the
/// interface is the refresh a paste also needs: the controls are showing
/// values that have just moved underneath them.
///
/// `can-undo` and `can-redo` are not set here; `render_now` pushes them on
/// every redraw, which is every path that can change them.
fn wire_undo_redo(window: &AppWindow, w: &DevelopWiring) {
let session = w.session.clone();
let rows = w.rows.clone();
let redraw = w.redraw.clone();
let render_now = w.render_now.clone();
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.global::<Steps>().on_undo(move || {
let Some(w) = weak.upgrade() else { return };
let stepped = session.borrow_mut().as_mut().is_some_and(|s| s.undo());
if stepped {
sync_rows(&w, &rows, &session);
redraw(&w);
}
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.global::<Steps>().on_redo(move || {
let Some(w) = weak.upgrade() else { return };
let stepped = session.borrow_mut().as_mut().is_some_and(|s| s.redo());
if stepped {
sync_rows(&w, &rows, &session);
redraw(&w);
}
});
}
{
// TRACES: FR-DEV-7
// Holding the comparison, and letting it go.
//
// **No rows are synced and no history is touched**, and both absences
// are the feature. The panel is describing the edit the photographer
// still has; only the canvas changes, and it changes back. This is the
// whole difference between a comparison and the undo-look-redo that
// had to stand in for one — that put two real steps on the stack at
// the moment somebody was least sure of what they were doing.
//
// A full frame rather than a draft: comparing a half-resolution
// original against a sharp edit would show a difference the edit does
// not have, which is the one thing this must not do.
let weak = window.as_weak();
let render_now = render_now.clone();
window.on_compare_original(move |on| {
let Some(w) = weak.upgrade() else { return };
// A held key repeats. Answering a repeat with a re-render would
// spend a full-resolution pass per keystroke to arrive back at the
// frame already on screen.
if w.get_showing_original() == on {
return;
}
w.set_showing_original(on);
render_now(&w, false);
});
}
{
// TRACES: FR-DEV-3
// A neutral picked off the photograph.
//
// The operation is not named here and is not passed in: the click
// carries a point, the session turns it into a colour, and
// `dr_pipeline::neutral` finds the operation that asked to be driven
// by one. This callback's whole contribution is that a click on the
// canvas is a click on the canvas.
//
// The rows are re-synced because the sample moved parameters the panel
// is showing — two of them, from one press, which is exactly the case
// a paste and an undo already go through this path for.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_neutral_picked(move |x, y| {
let Some(w) = weak.upgrade() else { return };
let sampled = session
.borrow_mut()
.as_mut()
.is_some_and(|s| s.sample_neutral(x, y));
// Nothing to redraw otherwise: the probe renders to its own
// target and a sample that found nothing usable moved nothing.
if sampled {
sync_rows(&w, &rows, &session);
redraw(&w);
}
});
}
// 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.global::<Steps>().on_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);
}
});
}
// TRACES: FR-DEV-5
// Named snapshots. Taking and deleting one change the list and nothing
// else — no rows, no history — so they redraw only to push the list,
// through the same path everything else pushes it on. Restoring one is
// the paste's shape: the whole edit moves, so the rows resync and the
// picture is redrawn.
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.global::<Steps>().on_snapshot_taken(move |name| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.take_snapshot(&name);
}
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.global::<Steps>().on_snapshot_restored(move |id| {
let Some(w) = weak.upgrade() else { return };
let restored = session
.borrow_mut()
.as_mut()
.is_some_and(|s| s.restore_snapshot(&id));
if restored {
sync_rows(&w, &rows, &session);
// The mask panel too: a snapshot carries its layers, and
// the redraw syncs the repairs and the overlay but not the
// rows the layers are listed in.
masks_ui::sync(&w, &session);
redraw(&w);
}
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.global::<Steps>().on_snapshot_removed(move |id| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.delete_snapshot(&id);
}
redraw(&w);
});
}
{
// TRACES: FR-DEV-7
// Holding a snapshot against the edit, and letting it go — the
// Before button's shape, and its rule: no rows are synced and no
// history is touched. A full frame rather than a draft, for the
// reason the original is: a soft comparison shows a difference the
// edit does not have.
let weak = window.as_weak();
let session = session.clone();
let render_now = render_now.clone();
window
.global::<Steps>()
.on_snapshot_compared(move |id, down| {
let Some(w) = weak.upgrade() else { return };
let changed = session
.borrow_mut()
.as_mut()
.is_some_and(|s| s.compare_snapshot(down.then_some(id.as_str())));
if changed {
render_now(&w, false);
}
});
}
}
/// ---- zoom, pan and crop ---------------------------------------------
///
/// Zoom and pan are viewing state and touch no parameter, so unlike the
/// handlers above they do not `sync_rows`.
fn wire_zoom_pan_crop(
window: &AppWindow,
w: &DevelopWiring,
crop_aspect: &Rc<Cell<develop::CropAspect>>,
crop_portrait: &Rc<Cell<bool>>,
) {
let session = w.session.clone();
let rows = w.rows.clone();
let redraw = w.redraw.clone();
let viewport = w.viewport.clone();
let inspection = w.inspection.clone();
let crop_aspect = crop_aspect.clone();
let crop_portrait = crop_portrait.clone();
window.global::<Framing>().set_aspects(
develop::CropAspect::CHOICES
.iter()
.map(|a| slint::SharedString::from(a.label()))
.collect::<Vec<_>>()
.as_slice()
.into(),
);
{
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);
});
}
{
// TRACES: FR-UI-4
// Fit and 1:1, and the memory of where 1:1 was pointing.
//
// The remembered point is written from what the session *landed* on
// rather than from the coordinates that arrived, so a point near an
// edge is stored where the view was clamped to. Storing the request
// instead would carry a point the previous photograph could not show
// to a photograph that also cannot show it, and the drift would
// accumulate over a folder.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let viewport = viewport.clone();
let inspection = inspection.clone();
window.on_inspect_toggled(move |at_x, at_y| {
let Some(w) = weak.upgrade() else { return };
let (vw, vh) = *viewport.borrow();
if let Some(s) = session.borrow_mut().as_mut() {
// A negative anchor is a control with no point of its own —
// the readout button, the keyboard — asking for the last one.
// Resolved here rather than in Slint because this is where
// the memory lives.
//
// The remembered point is in fractions of the framed image and
// the session wants fractions of the *visible* area, which are
// the same thing only while the view is fitted. That is
// exactly when this branch can move the view: from a zoomed
// view the toggle goes out to fit and ignores its anchor
// entirely.
let held = inspection.get();
let (ax, ay) = if at_x < 0.0 || at_y < 0.0 {
held.at
} else {
(at_x, at_y)
};
inspection.set(match s.toggle_inspection(ax, ay, vw, vh) {
Some(at) => Inspection { at, on: true },
// The aim is kept, only switched off. See `Inspection`.
None => Inspection { on: false, ..held },
});
}
redraw(&w);
});
}
{
// TRACES: FR-UI-5
// **Entering a mode is a side effect, which is why Rust owns it** and
// the strip does not simply write the property. Each of the three has
// work to do that the interface cannot see:
//
// *Crop* 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.
//
// *Local* turns the region overlay on. It used to be a button in the
// masking panel, so the mode could be open with the overlay off —
// which is a mode you have entered that is doing nothing.
//
// *Leaving* clears the selection, and that is the fault this whole
// pass exists for: a selected layer silently re-points thirty sliders
// at that layer's chain, so a mode you have left must not leave one
// behind. After this the controls are unambiguously global again,
// which is what the panel's heading then says.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_mode_picked(move |mode| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
match mode {
ViewMode::Crop => {
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);
}
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.global::<Develop>().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);
redraw(&w);
});
}
{
// The rect arrives raw from the drag; the session normalises it, holds
// it to whatever ratio is locked, 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 — and it is what lets the lock reshape a drag
// without the overlay having to know a ratio exists.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let crop_aspect = crop_aspect.clone();
let crop_portrait = crop_portrait.clone();
window.on_crop_changed(move |x, y, width, height, hx, hy| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
let rect = dr_pipeline::CropRect {
x,
y,
width,
height,
};
// The overlay reports the corner it is *holding*; the point
// that must not move is the opposite one. A move reports no
// corner at all, and keeps the shape it already has — there is
// nothing to reshape, and reshaping about a centre would drag
// an over-moved rect smaller instead of sliding it along the
// edge.
if hx < 0.0 {
s.set_crop(rect);
} else {
s.set_crop_locked(
rect,
crop_aspect.get(),
crop_portrait.get(),
(1.0 - hx, 1.0 - hy),
);
}
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.global::<Framing>().set_modified(s.framing_edits_image());
}
redraw(&w);
});
}
{
// TRACES: FR-DEV-17
// The end of a crop gesture, carrying the rect it was pressed from.
// Measured here and never in `on_crop_changed`: the drag reports every
// frame, and a handle passing over a mask on its way somewhere else
// is not an event.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window
.global::<Framing>()
.on_crop_released(move |x, y, width, height| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
let mut before = s.framing();
before.set_crop(dr_pipeline::CropRect {
x,
y,
width,
height,
});
s.notice_hidden_masks(&before);
}
redraw(&w);
});
}
{
// TRACES: FR-DEV-17
// Keep the crop. The only thing to do is stop saying so: the crop
// was applied when it was let go and has been in the edit since.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.global::<Framing>().on_hidden_masks_kept(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.dismiss_crop_notice();
}
redraw(&w);
});
}
{
// TRACES: FR-DEV-3
// Choosing a ratio reshapes the crop there and then rather than
// waiting for the next drag. A lock that only took effect on the
// following gesture would leave the chip lit over a rect that is not
// that shape, which is the panel lying about the image.
//
// Held about the rect's centre, so the composition stays where it is
// and the shape changes around it.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let crop_aspect = crop_aspect.clone();
let crop_portrait = crop_portrait.clone();
window.global::<Framing>().on_aspect_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
let Some(&aspect) = develop::CropAspect::CHOICES.get(index.max(0) as usize) else {
return;
};
crop_aspect.set(aspect);
// A ratio with no second orientation cannot stay stood on its
// short edge, or the switch would be off and the crop upright.
if !aspect.has_orientation() {
crop_portrait.set(false);
}
sync_crop_aspect(&w, &crop_aspect, &crop_portrait);
apply_crop_aspect(&w, &session, &crop_aspect, &crop_portrait);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let crop_aspect = crop_aspect.clone();
let crop_portrait = crop_portrait.clone();
window.global::<Framing>().on_portrait_toggled(move || {
let Some(w) = weak.upgrade() else { return };
if !crop_aspect.get().has_orientation() {
return;
}
crop_portrait.set(!crop_portrait.get());
sync_crop_aspect(&w, &crop_aspect, &crop_portrait);
apply_crop_aspect(&w, &session, &crop_aspect, &crop_portrait);
redraw(&w);
});
}
}
/// TRACES: FR-DEV-17
/// Mirror the session's crop notice into the window: how many mask layers the
/// crop on top of the history left outside the frame, and which.
///
/// Called on every redraw, which is every path that can move the history —
/// so the notice goes the frame an undo takes its crop back. Written only
/// when it differs, since a drag redraws sixty times a second.
pub(crate) fn sync_crop_notice(window: &AppWindow, s: &DevelopSession) {
let framing = window.global::<Framing>();
let (count, names) = match s.crop_notice() {
Some(n) => (n.names().len() as i32, n.names().join(", ")),
None => (0, String::new()),
};
if framing.get_hidden_masks() != count {
framing.set_hidden_masks(count);
}
if framing.get_hidden_mask_names().as_str() != names {
framing.set_hidden_mask_names(names.into());
}
}
/// ---- rotation, flips and straightening -------------------------------
///
/// Framing edits, so unlike zoom and pan they mark the image modified — but
/// they are reached through named session actions rather than through a row
/// index, so they do not `sync_rows` either. `sync_framing` is what carries
/// the applied value back.
fn wire_rotation_flips_straighten(
window: &AppWindow,
w: &DevelopWiring,
crop_aspect: &Rc<Cell<develop::CropAspect>>,
crop_portrait: &Rc<Cell<bool>>,
) {
let session = w.session.clone();
let redraw = w.redraw.clone();
let crop_aspect = crop_aspect.clone();
let crop_portrait = crop_portrait.clone();
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let crop_aspect = crop_aspect.clone();
let crop_portrait = crop_portrait.clone();
window.global::<Framing>().on_rotate(move |turns| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.rotate_quarters(turns);
}
// TRACES: FR-DEV-3
// The crop travels with the frame, so a rect locked to 16:9 comes
// out of the turn at 9:16. The switch has to agree, or the next
// drag would snap it back upright and undo what the turn did to
// the composition. An even number of turns lands where it started.
let aspect = crop_aspect.get();
if aspect.turns_with_the_frame() && turns.rem_euclid(2) != 0 {
crop_portrait.set(!crop_portrait.get());
sync_crop_aspect(&w, &crop_aspect, &crop_portrait);
}
sync_framing(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.global::<Framing>().on_flip_h_toggled(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.toggle_flip_h();
}
sync_framing(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.global::<Framing>().on_flip_v_toggled(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.toggle_flip_v();
}
sync_framing(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.global::<Framing>().on_angle_changed(move |degrees| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_angle(degrees);
}
sync_framing(&w, &session);
redraw(&w);
});
}
{
// TRACES: FR-DEV-3
// **The end of a straightening gesture crops away the corners it
// exposed.** A free angle does not change the output size, by design —
// that is what leaves the frame where the user put it while the slider
// moves — so the corners of the finished photograph would otherwise be
// black wedges of undefined area.
//
// Only on release, and only on release. Doing it per frame ratchets
// the crop down through every angle the slider passed through rather
// than the one it stopped at; see `auto_crop_to_angle`.
//
// Hung off the track's `committed`, not off `engaged-changed`. The
// latter is *hover* — it has to be, because a `Flickable` withholds
// the press — so a correction keyed on it fires when the pointer first
// crosses the track, whether or not anything was dragged, and then
// never fires again for as long as the pointer stays on it. Which
// reads, exactly, as an auto-crop that works once.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window
.global::<Framing>()
.on_angle_committed(move |_degrees| {
// The angle itself is already applied: `SliderTrack` emits its
// last `changed` before it commits, so `on_straighten_changed` has
// run with this very value. What is left is the correction that
// has to happen exactly once.
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.auto_crop_to_angle();
}
sync_framing(&w, &session);
redraw(&w);
});
}
{
// TRACES: FR-DEV-20
// The keystone sliders: applied on every frame of the drag, like the
// angle, so the photograph follows the thumb.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window
.global::<Framing>()
.on_keystone_v_changed(move |amount| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_keystone_v(amount);
}
sync_framing(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window
.global::<Framing>()
.on_keystone_h_changed(move |amount| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_keystone_h(amount);
}
sync_framing(&w, &session);
redraw(&w);
});
}
{
// TRACES: FR-DEV-20
// The end of a keystone gesture refits the crop, on release only, for
// the reasons the straightening's does above: a straightened frame's
// empty corners move with the keystone.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.global::<Framing>().on_keystone_committed(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.auto_crop_to_angle();
}
sync_framing(&w, &session);
redraw(&w);
});
}
{
// The geometry section's reset: crop, angle, rotation and flips back
// to neutral, leaving every colour adjustment where it is. The panel's
// own reset-all is the one that clears everything.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.global::<Framing>().on_reset(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.reset_framing();
}
sync_framing(&w, &session);
redraw(&w);
});
}
}
/// Stepping through the browsing list, one image at a time.
fn wire_navigation(window: &AppWindow, w: &DevelopWiring) {
let index = w.index.clone();
let entries = w.entries.clone();
let show = w.show.clone();
{
let weak = window.as_weak();
let index = index.clone();
let entries = entries.clone();
let show = show.clone();
window.on_next_image(move || {
let Some(w) = weak.upgrade() else { return };
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) % len
};
*index.borrow_mut() = next;
show(&w);
});
}
{
let weak = window.as_weak();
let index = index.clone();
let entries = entries.clone();
let show = show.clone();
window.on_prev_image(move || {
let Some(w) = weak.upgrade() else { return };
let len = entries.borrow().len();
if len == 0 {
return;
}
let prev = {
let cur = *index.borrow();
if cur == 0 {
len - 1
} else {
cur - 1
}
};
*index.borrow_mut() = prev;
show(&w);
});
}
}
/// TRACES: FR-CULL-3
/// The peaking switch and its two choices.
///
/// All three write `chosen_peaking` and then redraw, because the marks are
/// produced by a compute pass over the rendered frame: there is nothing the
/// interface can change about the overlay that does not require the frame
/// to be measured again. Turning peaking *off* redraws for the same reason
/// — that render is what drops the overlay textures and clears the flag.
fn wire_peaking(window: &AppWindow, w: &DevelopWiring) {
let chosen_peaking = w.chosen_peaking.clone();
let redraw = w.redraw.clone();
{
let weak = window.as_weak();
let chosen = chosen_peaking.clone();
let redraw = redraw.clone();
window.global::<Peaking>().on_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
// Built from the chips as they currently stand rather than from a
// remembered value: they are what the photographer can see, and an
// overlay that came back in a configuration the panel is not
// showing would be the panel lying about itself.
let next = on.then(|| dr_gpu::FocusPeaking {
sensitivity: peaking::sensitivity(w.global::<Peaking>().get_sensitivity()),
colour: peaking::colour(w.global::<Peaking>().get_colour()),
});
chosen.set(next);
w.global::<Peaking>().set_showing(next.is_some());
redraw(&w);
});
}
{
let weak = window.as_weak();
let chosen = chosen_peaking.clone();
let redraw = redraw.clone();
window
.global::<Peaking>()
.on_sensitivity_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
w.global::<Peaking>().set_sensitivity(index);
// Only reachable while peaking is on — the chips are not drawn
// otherwise — but written as a conditional rather than an
// `expect`, because a panel is free to change its mind about that
// and nothing here should fall over when it does.
if let Some(mut current) = chosen.get() {
current.sensitivity = peaking::sensitivity(index);
chosen.set(Some(current));
redraw(&w);
}
});
}
{
let weak = window.as_weak();
let chosen = chosen_peaking.clone();
let redraw = redraw.clone();
window.global::<Peaking>().on_colour_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
w.global::<Peaking>().set_colour(index);
if let Some(mut current) = chosen.get() {
current.colour = peaking::colour(index);
chosen.set(Some(current));
redraw(&w);
}
});
}
// The chips open on whatever the vocabulary calls its default, so the
// panel and the pass agree before anything has been pressed.
window
.global::<Peaking>()
.set_sensitivity(peaking::sensitivity_index(Default::default()));
window
.global::<Peaking>()
.set_colour(peaking::colour_index(Default::default()));
}