//! 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>>, pub(crate) rows: Rc>, pub(crate) redraw: Rc, pub(crate) render_now: Render, pub(crate) show: Rc, pub(crate) entries: Rc>>, pub(crate) index: Rc>, pub(crate) viewport: Rc>, pub(crate) inspection: Rc>, pub(crate) chosen_peaking: Rc>>, pub(crate) settings: Rc, pub(crate) library: Rc, pub(crate) collections: Rc, pub(crate) activity: Rc, pub(crate) gpu: Option, pub(crate) clipboard: Rc, pub(crate) open_image: presets::OpenImage, pub(crate) leave_develop: Rc>>>, } /// 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>>, rows: &Rc>, redraw: &Rc, render_now: &Render, show: &Rc, entries: &Rc>>, index: &Rc>, viewport: &Rc>, inspection: &Rc>, chosen_peaking: &Rc>>, settings: &Rc, library: &Rc, collections: &Rc, activity: &Rc, gpu: &Option, clipboard: &Rc, open_image: &presets::OpenImage, leave_develop: &Rc>>>, ) { 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::() .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` 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> = 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>> = 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, 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::().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::() .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::().get_library_exporting() || w.get_export_busy() { w.global::() .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::() .set_library_status("Select an image first".into()); return; } w.global::().set_library_exporting(true); w.global::() .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::() .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::() .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::() .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::() .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::() .on_param_nudged(move |op, param, direction| { use slint::Model as _; let Some(w) = weak.upgrade() else { return }; let shown = w.global::().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::().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::().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::().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::() .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::().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::() .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::().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::().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::().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::().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::().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::().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::() .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>, crop_portrait: &Rc>, ) { 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::().set_aspects( develop::CropAspect::CHOICES .iter() .map(|a| slint::SharedString::from(a.label())) .collect::>() .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::().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::().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::() .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::().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::().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::().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::(); 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>, crop_portrait: &Rc>, ) { 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::().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::().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::().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::().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::() .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::() .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::() .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::().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::().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::().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::().get_sensitivity()), colour: peaking::colour(w.global::().get_colour()), }); chosen.set(next); w.global::().set_showing(next.is_some()); redraw(&w); }); } { let weak = window.as_weak(); let chosen = chosen_peaking.clone(); let redraw = redraw.clone(); window .global::() .on_sensitivity_picked(move |index| { let Some(w) = weak.upgrade() else { return }; w.global::().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::().on_colour_picked(move |index| { let Some(w) = weak.upgrade() else { return }; w.global::().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::() .set_sensitivity(peaking::sensitivity_index(Default::default())); window .global::() .set_colour(peaking::colour_index(Default::default())); }