Wire the develop panels through globals, so a second copy is one line

Every panel in the develop column declared its inputs and its callbacks and
had `app.slint` bind each one to a property or a callback on the window root.
That is fine while a panel is drawn once. N9 draws them a second time, in the
portrait dock, and the wiring is what would have to be copied: `MaskPanel`
alone ran to forty lines of forwarding, and a callback added to one copy and
not the other compiles, renders, and simply does nothing on the layout nobody
was looking at.

So the wiring moved to Slint globals. A panel reads the global and calls the
global; Rust hooks the global instead of the window; and the instantiation in
the column is now the panel's name and a pair of braces — every one of the ten
children of the column, with no property that differs by placement left to
supply.

There is a global per panel family rather than one for all of them, and the
reason is an import cycle. Each panel's model struct — `ParamRow`, `MaskRow`,
`HistogramView` — is declared in the panel's own file, so a single global
holding `[MaskRow]` and `[ParamRow]` would have to live in a file importing
`masks.slint` and `adjust.slint` while both imported the global back, which
Slint rejects. Breaking that needs six model declarations relocated, which is a
change to the data model and not to the plumbing this is about. A global beside
the panel it serves also lets each name drop the prefix it was carrying only
because the window root is one flat namespace: `root.spot-radius` is
`Repair.radius`, and `root.peaking-on` is `Peaking.showing`.

`session.slint` is new and holds the two facts every family needs and none of
them owns: whether there is an open photograph to edit, and which mode the view
is in, with the three readings of the mode derived once instead of at each of
the dozen places that tested one. `ViewMode` moves there from `adjust.slint`,
where it was only ever a lodger.

Nothing on screen changes. What is not here: the tool rail and the status strip
still take their properties at the instantiation, because they are drawn once
and N9 does not copy them; the preset sheet's own state stays on the window,
because the library grid opens the same sheet and a global cannot bind the
window's state — which is why `Transfer.open-presets` is handled in
`presets.rs`, beside the summary it already had to compute.
This commit is contained in:
2026-09-06 20:12:20 +02:00
parent 23a061fd80
commit fea5b1b1ee
17 changed files with 1264 additions and 1275 deletions
+187 -150
View File
@@ -353,23 +353,25 @@ fn is_supported(p: &Path) -> bool {
/// rect chosen for the first — or, since local masking became a mode, would
/// open the next photograph with a mask stack it does not have.
fn reset_view_state(window: &AppWindow) {
window.set_view_mode(ViewMode::Photo);
window.global::<Develop>().set_view_mode(ViewMode::Photo);
window.set_zoom(1.0);
window.set_zoomed(false);
window.set_crop_x(0.0);
window.set_crop_y(0.0);
window.set_crop_w(1.0);
window.set_crop_h(1.0);
window.set_max_straighten(dr_pipeline::framing::MAX_STRAIGHTEN);
window.set_straighten(0.0);
window.set_flip_h(false);
window.set_flip_v(false);
window.set_framing_modified(false);
let framing = window.global::<Framing>();
framing.set_max_straighten(dr_pipeline::framing::MAX_STRAIGHTEN);
framing.set_angle(0.0);
framing.set_flip_h(false);
framing.set_flip_v(false);
framing.set_modified(false);
// A photograph that failed to decode has no session, so nothing below
// will speak for it — and the buttons would otherwise keep offering the
// previous image's history.
window.set_can_undo(false);
window.set_can_redo(false);
let steps = window.global::<Steps>();
steps.set_can_undo(false);
steps.set_can_redo(false);
// TRACES: FR-DEV-7
// And the comparison goes down with them. Unlike the inspection point
// below it, this is not a way of looking at a folder: it is a question
@@ -382,17 +384,19 @@ fn reset_view_state(window: &AppWindow) {
// And an armed sampler goes down with it: a picker left waiting would make
// the first click on the *next* photograph sample it, which is a click
// nobody meant to spend.
window.set_sampling_op(-1);
window.global::<Adjustments>().set_sampling_op(-1);
// TRACES: FR-DSP-7
// Emptied rather than left standing: the previous photograph's histogram
// beside the next one's filename is a confident, precise lie, and the gap
// before the new frame settles is exactly long enough to read it.
window.set_histogram(histogram::empty());
window.global::<Levels>().set_data(histogram::empty());
// And the raw reading with it. It belongs to one file more completely than
// the display histogram does — nothing about the edit can move it — which
// makes leaving it standing over the next photograph's filename worse
// rather than better: it would look exactly as current as it is wrong.
window.set_raw_histogram(histogram::raw_empty(histogram::RawAbsence::NoImage));
window
.global::<Levels>()
.set_raw_data(histogram::raw_empty(histogram::RawAbsence::NoImage));
// TRACES: FR-CULL-3
// The marks go down with it, and for the same reason. What is *not* reset
// is whether peaking is switched on: that is a way of looking at a folder
@@ -492,10 +496,11 @@ fn sync_framing(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>
return;
};
let (angle, flip_h, flip_v, modified, crop) = s;
window.set_straighten(angle);
window.set_flip_h(flip_h);
window.set_flip_v(flip_v);
window.set_framing_modified(modified);
let framing = window.global::<Framing>();
framing.set_angle(angle);
framing.set_flip_h(flip_h);
framing.set_flip_v(flip_v);
framing.set_modified(modified);
// The overlay draws from these, and a rotation re-expresses the rect —
// so they have to follow a quarter turn even though no handle moved.
window.set_crop_x(crop.x);
@@ -522,9 +527,10 @@ fn sync_crop_aspect(
.iter()
.position(|a| *a == chosen)
.unwrap_or(0);
window.set_crop_aspect(index as i32);
window.set_crop_portrait(portrait.get());
window.set_crop_aspect_turnable(chosen.has_orientation());
let framing = window.global::<Framing>();
framing.set_aspect(index as i32);
framing.set_portrait(portrait.get());
framing.set_aspect_turnable(chosen.has_orientation());
}
/// TRACES: FR-DEV-3
@@ -553,7 +559,7 @@ fn apply_crop_aspect(
window.set_crop_y(crop.y);
window.set_crop_w(crop.width);
window.set_crop_h(crop.height);
window.set_framing_modified(modified);
window.global::<Framing>().set_modified(modified);
}
/// TRACES: FR-EXP-6 | FR-EXP-9 | FR-EXP-7
@@ -787,15 +793,16 @@ pub(crate) fn sync_film(window: &AppWindow, session: &Rc<RefCell<Option<DevelopS
.and_then(dr_film::default_print)
.is_some();
window.set_film_stocks(slint::ModelRc::new(slint::VecModel::from(
let adjustments = window.global::<Adjustments>();
adjustments.set_film_stocks(slint::ModelRc::new(slint::VecModel::from(
choices
.into_iter()
.map(|(_, name)| slint::SharedString::from(name))
.collect::<Vec<_>>(),
)));
window.set_film_selected(selected as i32);
window.set_film_can_print(can_print);
window.set_film_print(chosen.map(|(_, print)| print).unwrap_or(false));
adjustments.set_film_selected(selected as i32);
adjustments.set_film_can_print(can_print);
adjustments.set_film_print(chosen.map(|(_, print)| print).unwrap_or(false));
}
pub(crate) fn sync_rows(
@@ -832,8 +839,10 @@ pub(crate) fn sync_rows(
),
None => (Vec::<slint::SharedString>::new(), -1),
};
window.set_adjust_tabs(slint::ModelRc::new(slint::VecModel::from(tabs)));
window.set_adjust_active_tab(active_tab);
window
.global::<Adjustments>()
.set_tabs(slint::ModelRc::new(slint::VecModel::from(tabs)));
window.global::<Adjustments>().set_active_tab(active_tab);
let current = match session.borrow().as_ref() {
Some(s) => s.rows(),
@@ -891,7 +900,9 @@ pub(crate) fn sync_rows(
Some(s) => s.curve_channels().into_iter().map(Into::into).collect(),
None => Vec::new(),
};
window.set_curve_channels(slint::ModelRc::new(slint::VecModel::from(channels)));
window
.global::<Adjustments>()
.set_curve_channels(slint::ModelRc::new(slint::VecModel::from(channels)));
}
// Which curve is plotted, on every pass. Picking one that happens to be
@@ -899,7 +910,7 @@ pub(crate) fn sync_rows(
// moves no point, so this cannot ride on the resample below: the chips
// would go on highlighting the curve the user just navigated away from.
let channel = session.borrow().as_ref().map_or(0, |s| s.curve_channel());
window.set_curve_channel(channel);
window.global::<Adjustments>().set_curve_channel(channel);
if !curve_moved {
return;
@@ -912,7 +923,9 @@ pub(crate) fn sync_rows(
// The drawn curve follows the points. Replacing this model wholesale is
// safe where replacing `rows` was not: nothing in it is a drag target.
window.set_curve_samples(slint::ModelRc::new(slint::VecModel::from(samples)));
window
.global::<Adjustments>()
.set_curve_samples(slint::ModelRc::new(slint::VecModel::from(samples)));
}
/// Copy `fresh`'s values into `existing`, keeping the model identity.
@@ -1683,7 +1696,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// One model for the lifetime of the window. Rows are mutated in place;
// see `sync_rows` for why replacing it breaks dragging.
let rows: Rc<slint::VecModel<ParamRow>> = Rc::new(slint::VecModel::default());
window.set_adjust_rows(rows.clone().into());
window.global::<Adjustments>().set_rows(rows.clone().into());
// Viewport size, tracked so a re-render after a slider move matches it.
//
// In *physical* pixels, which is what the render target wants and what
@@ -1770,8 +1783,9 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// edit ends in a redraw and nothing else is on all of their paths:
// the parameter callbacks sync rows, the framing ones sync the
// geometry panel, and a paste arrives through neither.
window.set_can_undo(s.can_undo());
window.set_can_redo(s.can_redo());
let steps = window.global::<Steps>();
steps.set_can_undo(s.can_undo());
steps.set_can_redo(s.can_redo());
// TRACES: FR-DEV-5 | FR-DEV-7
// The list, on the same path and for the same reason — but only
@@ -1782,9 +1796,8 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let revision = s.history_revision();
if drawn_history.get() != Some(revision) {
drawn_history.set(Some(revision));
window
.set_history_rows(slint::ModelRc::new(slint::VecModel::from(s.history_rows())));
window.set_undo_label(s.undo_label().into());
steps.set_rows(slint::ModelRc::new(slint::VecModel::from(s.history_rows())));
steps.set_undo_label(s.undo_label().into());
}
// TRACES: FR-DEV-3
@@ -1814,7 +1827,9 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
if s.peaking() != chosen_peaking.get() {
s.set_peaking(chosen_peaking.get());
}
window.set_peaking_available(s.peaking_available());
window
.global::<Peaking>()
.set_available(s.peaking_available());
let (mut w, mut h) = *viewport.borrow();
@@ -1843,7 +1858,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// Crop mode shows the whole frame, or the area being cropped away
// would not be on screen for the handles to drag across. The
// overlay draws the rect on top of it.
let rendered = if window.get_view_mode() == ViewMode::Crop {
let rendered = if window.global::<Develop>().get_view_mode() == ViewMode::Crop {
s.render_uncropped(w, h).map(|(image, _, _)| image)
} else if window.get_showing_original() {
s.render_original(w, h)
@@ -1880,7 +1895,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// half-resolution frame flickering past under a finger is
// not a reading anyone takes.
if !draft {
window.set_histogram(
window.global::<Levels>().set_data(
s.histogram()
.as_ref()
.map_or_else(histogram::empty, histogram::view),
@@ -1910,7 +1925,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
histogram::raw_view,
)
};
window.set_raw_histogram(raw);
window.global::<Levels>().set_raw_data(raw);
}
// TRACES: FR-CULL-3 | NFR-P14
@@ -1946,7 +1961,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
window.set_load_error(e.into());
// No frame, so nothing to describe. The stale plot would
// otherwise sit beside the error message looking current.
window.set_histogram(histogram::empty());
window.global::<Levels>().set_data(histogram::empty());
// **The raw reading is left standing, and that is not an
// oversight.** It describes the sensor data behind the
// session, which a failed *render* says nothing about: the
@@ -2095,16 +2110,17 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
match load(gpu.as_ref(), path) {
Ok(l) => {
window.set_load_error("".into());
window.set_camera(describe_camera(&l.meta).into());
window.set_exposure(describe_exposure(&l.meta).into());
window.set_dimensions(format!("{} × {}", l.width, l.height).into());
let capture = window.global::<Capture>();
capture.set_camera(describe_camera(&l.meta).into());
capture.set_exposure(describe_exposure(&l.meta).into());
capture.set_dimensions(format!("{} × {}", l.width, l.height).into());
// Composed by the session rather than here, because only it
// knows whether the lookup found anything — the header can
// name a lens the database has never heard of, which is the
// ordinary case for third-party and adapted glass and must
// read as a fact rather than as a failure.
window.set_lens(
capture.set_lens(
l.session
.as_ref()
.map(|s| s.lens_summary())
@@ -2116,7 +2132,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// this code names no operation (FR-DEV-3a).
match l.session {
Some(s) => {
window.set_adjust_enabled(true);
window.global::<Develop>().set_enabled(true);
*session.borrow_mut() = Some(s);
// A local file stores its edit beside itself. Read
@@ -2149,7 +2165,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
*session.borrow_mut() = None;
*open_image.borrow_mut() = presets::Stored::Nowhere;
rows.set_vec(Vec::<ParamRow>::new());
window.set_adjust_enabled(false);
window.global::<Develop>().set_enabled(false);
if let Some(image) = l.fallback {
window.set_canvas(image);
}
@@ -2161,12 +2177,13 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// A failure on one image must not stop browsing (FR-RAW-4).
log::warn!("{name}: {e}");
*session.borrow_mut() = None;
window.set_adjust_enabled(false);
window.global::<Develop>().set_enabled(false);
window.set_load_error(e.into());
window.set_camera("".into());
window.set_exposure("".into());
window.set_dimensions("".into());
window.set_lens("".into());
let capture = window.global::<Capture>();
capture.set_camera("".into());
capture.set_exposure("".into());
capture.set_dimensions("".into());
capture.set_lens("".into());
}
}
})
@@ -2200,9 +2217,10 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
reset_view_state(&w);
w.set_filename(name.clone().into());
w.set_load_error("".into());
w.set_camera("".into());
w.set_exposure("".into());
w.set_dimensions("".into());
let capture = w.global::<Capture>();
capture.set_camera("".into());
capture.set_exposure("".into());
capture.set_dimensions("".into());
// The grid is one image at a time, so next/previous have nothing
// to walk. Shown as 1 of 1 rather than left reading 0.
w.set_index(0);
@@ -2316,12 +2334,13 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
match load_bytes(gpu.as_ref(), &bytes) {
Ok(l) => {
w.set_load_error("".into());
w.set_camera(describe_camera(&l.meta).into());
w.set_exposure(describe_exposure(&l.meta).into());
w.set_dimensions(format!("{} × {}", l.width, l.height).into());
let capture = w.global::<Capture>();
capture.set_camera(describe_camera(&l.meta).into());
capture.set_exposure(describe_exposure(&l.meta).into());
capture.set_dimensions(format!("{} × {}", l.width, l.height).into());
match l.session {
Some(mut s) => {
w.set_adjust_enabled(true);
w.global::<Develop>().set_enabled(true);
// TRACES: FR-CULL-10
// Who is in this photograph, so a
// segmentation run can say "Anna" where
@@ -2380,7 +2399,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
None => {
*session.borrow_mut() = None;
rows.set_vec(Vec::<ParamRow>::new());
w.set_adjust_enabled(false);
w.global::<Develop>().set_enabled(false);
if let Some(image) = l.fallback {
w.set_canvas(image);
}
@@ -2391,7 +2410,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
Err(e) => {
log::warn!("{name}: {e}");
*session.borrow_mut() = None;
w.set_adjust_enabled(false);
w.global::<Develop>().set_enabled(false);
w.set_load_error(e.into());
}
}
@@ -2461,7 +2480,9 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// 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.set_groups_in_rail(groups_in_rail(&settings));
window
.global::<Adjustments>()
.set_groups_in_rail(groups_in_rail(&settings));
presets::render(&window, &clipboard, &settings);
}
@@ -2611,35 +2632,39 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.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);
});
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.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);
});
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-3
// The local-adjustment panel. Wired as a block rather than inline because
@@ -2651,7 +2676,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let weak = window.as_weak();
let session = session.clone();
let rows = rows.clone();
window.on_adjust_tab_picked(move |index| {
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);
@@ -2678,7 +2703,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_reset_all(move || {
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();
@@ -2695,7 +2720,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_curve_reset(move |op| {
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);
@@ -2714,13 +2739,15 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let weak = window.as_weak();
let session = session.clone();
let rows = rows.clone();
window.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);
});
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) -------------------------------------
@@ -2733,7 +2760,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_film_picked(move |index| {
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.
@@ -2761,16 +2788,18 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.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);
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);
});
sync_film(&w, &session);
redraw(&w);
});
}
// ---- undo and redo (FR-DEV-5) ---------------------------------------
@@ -2787,7 +2816,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_undo(move || {
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 {
@@ -2801,7 +2830,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_redo(move || {
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 {
@@ -2880,7 +2909,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_history_picked(move |index| {
window.global::<Steps>().on_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
let stepped = session
.borrow_mut()
@@ -2910,7 +2939,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// the pipeline needs and the whole of what another application could read.
let crop_aspect = Rc::new(Cell::new(develop::CropAspect::default()));
let crop_portrait = Rc::new(Cell::new(false));
window.set_crop_aspects(
window.global::<Framing>().set_aspects(
develop::CropAspect::CHOICES
.iter()
.map(|a| slint::SharedString::from(a.label()))
@@ -3044,7 +3073,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
}
}
}
w.set_view_mode(mode);
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);
@@ -3097,7 +3126,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
w.set_crop_y(c.y);
w.set_crop_w(c.width);
w.set_crop_h(c.height);
w.set_framing_modified(s.framing_edits_image());
w.global::<Framing>().set_modified(s.framing_edits_image());
}
redraw(&w);
});
@@ -3116,7 +3145,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let redraw = redraw.clone();
let crop_aspect = crop_aspect.clone();
let crop_portrait = crop_portrait.clone();
window.on_crop_aspect_picked(move |index| {
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;
@@ -3138,7 +3167,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let redraw = redraw.clone();
let crop_aspect = crop_aspect.clone();
let crop_portrait = crop_portrait.clone();
window.on_crop_portrait_toggled(move || {
window.global::<Framing>().on_portrait_toggled(move || {
let Some(w) = weak.upgrade() else { return };
if !crop_aspect.get().has_orientation() {
return;
@@ -3162,7 +3191,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let redraw = redraw.clone();
let crop_aspect = crop_aspect.clone();
let crop_portrait = crop_portrait.clone();
window.on_rotate_quarters(move |turns| {
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);
@@ -3185,7 +3214,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_flip_h_toggled(move || {
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();
@@ -3198,7 +3227,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_flip_v_toggled(move || {
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();
@@ -3211,7 +3240,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_straighten_changed(move |degrees| {
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);
@@ -3241,18 +3270,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_straighten_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);
});
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);
});
}
{
// The geometry section's reset: crop, angle, rotation and flips back
@@ -3261,7 +3292,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_framing_reset(move || {
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();
@@ -3354,18 +3385,18 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let weak = window.as_weak();
let chosen = chosen_peaking.clone();
let redraw = redraw.clone();
window.on_peaking_toggled(move |on| {
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.get_peaking_sensitivity()),
colour: peaking::colour(w.get_peaking_colour()),
sensitivity: peaking::sensitivity(w.global::<Peaking>().get_sensitivity()),
colour: peaking::colour(w.global::<Peaking>().get_colour()),
});
chosen.set(next);
w.set_peaking_on(next.is_some());
w.global::<Peaking>().set_showing(next.is_some());
redraw(&w);
});
}
@@ -3373,27 +3404,29 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let weak = window.as_weak();
let chosen = chosen_peaking.clone();
let redraw = redraw.clone();
window.on_peaking_sensitivity_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
w.set_peaking_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);
}
});
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.on_peaking_colour_picked(move |index| {
window.global::<Peaking>().on_colour_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
w.set_peaking_colour(index);
w.global::<Peaking>().set_colour(index);
if let Some(mut current) = chosen.get() {
current.colour = peaking::colour(index);
chosen.set(Some(current));
@@ -3403,8 +3436,12 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
}
// The chips open on whatever the vocabulary calls its default, so the
// panel and the pass agree before anything has been pressed.
window.set_peaking_sensitivity(peaking::sensitivity_index(Default::default()));
window.set_peaking_colour(peaking::colour_index(Default::default()));
window
.global::<Peaking>()
.set_sensitivity(peaking::sensitivity_index(Default::default()));
window
.global::<Peaking>()
.set_colour(peaking::colour_index(Default::default()));
// TRACES: FR-DSP-8 | FR-DSP-6
// And which display that canvas is on, from now until the window closes.
@@ -3649,7 +3686,7 @@ fn back_one_step(w: &AppWindow) -> bool {
launch: w.get_show_launch(),
browsing: w.get_launch_browsing(),
library: w.get_show_library(),
mode: w.get_view_mode(),
mode: w.global::<Develop>().get_view_mode(),
zoomed: w.get_zoomed(),
// Files named on the command line have no grid behind them — the same
// condition the status strip uses to decide whether to offer the way
+163 -130
View File
@@ -30,7 +30,10 @@ use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel};
use crate::develop::{Abandon, DevelopSession, RefinedInstance, Segmented, SessionId};
use crate::segmentation;
use crate::{sync_rows, AppWindow, CategoryRow, GradientHandle, MaskRow, ParamRow, SubjectRow};
use crate::{
sync_rows, Adjustments, AppWindow, CategoryRow, GradientHandle, MaskRow, Masking, ParamRow,
Steps, SubjectRow,
};
/// What the adjust panel's heading says when the controls are global.
///
@@ -164,12 +167,15 @@ fn category_label(name: &str) -> String {
/// Push every mask-related property from the session into the window.
pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>>) {
let slot = session.borrow();
let masking = window.global::<Masking>();
let Some(s) = slot.as_ref() else {
window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default()));
window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default()));
window.set_category_rows(ModelRc::new(VecModel::<CategoryRow>::default()));
window.set_segmented(false);
window.set_adjust_scope(GLOBAL_SCOPE.into());
masking.set_masks(ModelRc::new(VecModel::<MaskRow>::default()));
masking.set_subjects(ModelRc::new(VecModel::<SubjectRow>::default()));
masking.set_categories(ModelRc::new(VecModel::<CategoryRow>::default()));
masking.set_segmented(false);
window
.global::<Adjustments>()
.set_scope(GLOBAL_SCOPE.into());
clear_handles(window);
window.set_overlay_on(false);
return;
@@ -211,7 +217,7 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
}
})
.collect();
window.set_mask_rows(ModelRc::new(VecModel::from(rows)));
masking.set_masks(ModelRc::new(VecModel::from(rows)));
let subjects: Vec<SubjectRow> = s
.detected_subjects()
@@ -223,7 +229,7 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
score,
})
.collect();
window.set_subject_rows(ModelRc::new(VecModel::from(subjects)));
masking.set_subjects(ModelRc::new(VecModel::from(subjects)));
// Already largest-first from the precompute, and already filtered to the
// ones with enough coverage to be worth a control.
@@ -236,10 +242,12 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
coverage: c.coverage,
})
.collect();
window.set_category_rows(ModelRc::new(VecModel::from(categories)));
masking.set_categories(ModelRc::new(VecModel::from(categories)));
window.set_segmented(s.has_segmentation());
window.set_adjust_scope(scope_label(s).into());
masking.set_segmented(s.has_segmentation());
window
.global::<Adjustments>()
.set_scope(scope_label(s).into());
sync_handles(window, s);
// The overlay is regenerated only when there is one to draw. It is a
@@ -355,7 +363,7 @@ pub(crate) fn wire(
let redraw = redraw.clone();
let rows = rows.clone();
let running = running.clone();
window.on_segment_image(move |fine| {
window.global::<Masking>().on_segment(move |fine| {
let Some(w) = weak.upgrade() else { return };
// Both the photograph and the device come out of the session, so
// no session is nothing to look at and nothing to look with.
@@ -372,7 +380,7 @@ pub(crate) fn wire(
// Set before the thread rather than by it, so there is no moment
// in which the press has been taken and nothing on screen says so.
// The button reads "Looking…" and goes insensitive off this.
w.set_segmenting(true);
w.global::<Masking>().set_segmenting(true);
let (tx, rx) = std::sync::mpsc::channel();
// The only thing the button decides. Everything else about the
@@ -406,7 +414,7 @@ pub(crate) fn wire(
let redraw = redraw.clone();
let rows = rows.clone();
let running = refining.clone();
window.on_mask_refined(move |id| {
window.global::<Masking>().on_mask_refined(move |id| {
let Some(w) = weak.upgrade() else { return };
let Some(index) = session
.borrow()
@@ -425,7 +433,7 @@ pub(crate) fn wire(
return;
}
w.set_refining(true);
w.global::<Masking>().set_refining(true);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
@@ -488,8 +496,10 @@ pub(crate) fn wire(
s.commit_gradient_drag();
}
if let Some(w) = weak.upgrade() {
w.set_can_undo(session.borrow().as_ref().is_some_and(|s| s.can_undo()));
w.set_can_redo(session.borrow().as_ref().is_some_and(|s| s.can_redo()));
w.global::<Steps>()
.set_can_undo(session.borrow().as_ref().is_some_and(|s| s.can_undo()));
w.global::<Steps>()
.set_can_redo(session.borrow().as_ref().is_some_and(|s| s.can_redo()));
}
});
}
@@ -524,40 +534,42 @@ pub(crate) fn wire(
let weak = window.as_weak();
let session = session.clone();
let rows = rows.clone();
window.on_mask_selected(move |id, extend| {
let Some(w) = weak.upgrade() else { return };
{
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
if extend {
// Control/command-click: add or remove this one layer,
// keeping whatever else was already selected.
s.toggle_active_mask(&id);
} else if s.active_masks() == [id.to_string()] {
// Clicking the sole selected layer again deselects it,
// which is how the panel gets back to the whole
// photograph without a separate "edit globally" control.
// Only when it is the *only* one selected — a plain click
// on one row of a multi-selection collapses to just that
// row rather than clearing everything, which is the more
// useful reading of "I clicked a specific layer".
s.set_active_mask(None);
} else {
s.set_active_mask(Some(&id));
window
.global::<Masking>()
.on_mask_selected(move |id, extend| {
let Some(w) = weak.upgrade() else { return };
{
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
if extend {
// Control/command-click: add or remove this one layer,
// keeping whatever else was already selected.
s.toggle_active_mask(&id);
} else if s.active_masks() == [id.to_string()] {
// Clicking the sole selected layer again deselects it,
// which is how the panel gets back to the whole
// photograph without a separate "edit globally" control.
// Only when it is the *only* one selected — a plain click
// on one row of a multi-selection collapses to just that
// row rather than clearing everything, which is the more
// useful reading of "I clicked a specific layer".
s.set_active_mask(None);
} else {
s.set_active_mask(Some(&id));
}
}
}
sync(&w, &session);
// The scope changed, so the adjust panel below is now describing a
// different chain.
sync_rows(&w, &rows, &session);
});
sync(&w, &session);
// The scope changed, so the adjust panel below is now describing a
// different chain.
sync_rows(&w, &rows, &session);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_mask_removed(move |id| {
window.global::<Masking>().on_mask_removed(move |id| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.remove_mask(&id);
@@ -571,7 +583,7 @@ pub(crate) fn wire(
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_toggled(move |id, on| {
window.global::<Masking>().on_mask_toggled(move |id, on| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_enabled(&id, on);
@@ -584,27 +596,31 @@ pub(crate) fn wire(
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_invert_toggled(move |id, on| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_invert(&id, on);
}
sync(&w, &session);
redraw(&w);
});
window
.global::<Masking>()
.on_mask_invert_toggled(move |id, on| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_invert(&id, on);
}
sync(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_opacity_changed(move |id, value| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_opacity(&id, value);
}
sync(&w, &session);
redraw(&w);
});
window
.global::<Masking>()
.on_mask_opacity_changed(move |id, value| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_opacity(&id, value);
}
sync(&w, &session);
redraw(&w);
});
}
// --- the edge treatment -------------------------------------------------
@@ -616,14 +632,16 @@ pub(crate) fn wire(
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_feather_changed(move |id, value| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_feather(&id, value);
}
sync(&w, &session);
redraw(&w);
});
window
.global::<Masking>()
.on_mask_feather_changed(move |id, value| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_feather(&id, value);
}
sync(&w, &session);
redraw(&w);
});
}
// TRACES: FR-DEV-10
// A range mask's band, and a colour range's arc.
@@ -640,27 +658,31 @@ pub(crate) fn wire(
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_band_changed(move |id, lo, hi, softness| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_band(&id, lo, hi, softness);
}
sync(&w, &session);
redraw(&w);
});
window
.global::<Masking>()
.on_mask_band_changed(move |id, lo, hi, softness| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_band(&id, lo, hi, softness);
}
sync(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_hue_changed(move |id, hue, width| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_hue(&id, hue, width);
}
sync(&w, &session);
redraw(&w);
});
window
.global::<Masking>()
.on_mask_hue_changed(move |id, hue, width| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_hue(&id, hue, width);
}
sync(&w, &session);
redraw(&w);
});
}
// The refine control. Same shape as the feather's handler, and a separate
// one for the same reason every other mask control has its own: the
@@ -673,53 +695,61 @@ pub(crate) fn wire(
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_refine_changed(move |id, value| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_refine(&id, value);
}
sync(&w, &session);
redraw(&w);
});
window
.global::<Masking>()
.on_mask_refine_changed(move |id, value| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_refine(&id, value);
}
sync(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_falloff_picked(move |id, index| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_falloff(&id, index.max(0) as usize);
}
sync(&w, &session);
redraw(&w);
});
window
.global::<Masking>()
.on_mask_falloff_picked(move |id, index| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_falloff(&id, index.max(0) as usize);
}
sync(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_morphology_picked(move |id, index| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_morphology(&id, index.max(0) as usize);
}
sync(&w, &session);
redraw(&w);
});
window
.global::<Masking>()
.on_mask_morphology_picked(move |id, index| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_morphology(&id, index.max(0) as usize);
}
sync(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_morph_radius_changed(move |id, value| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_morph_radius(&id, value);
}
sync(&w, &session);
redraw(&w);
});
window
.global::<Masking>()
.on_mask_morph_radius_changed(move |id, value| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_morph_radius(&id, value);
}
sync(&w, &session);
redraw(&w);
});
}
// --- adding layers ------------------------------------------------------
@@ -728,7 +758,7 @@ pub(crate) fn wire(
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_add_gradient_mask(move |radial| {
window.global::<Masking>().on_add_gradient(move |radial| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.add_gradient_mask(radial);
@@ -744,7 +774,7 @@ pub(crate) fn wire(
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_add_range_mask(move |chromatic| {
window.global::<Masking>().on_add_range(move |chromatic| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.add_range_mask(chromatic);
@@ -759,7 +789,7 @@ pub(crate) fn wire(
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_add_category_mask(move |name| {
window.global::<Masking>().on_add_category(move |name| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.add_category_mask(&name);
@@ -775,7 +805,7 @@ pub(crate) fn wire(
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_add_subject_mask(move |index| {
window.global::<Masking>().on_add_subject(move |index| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.add_subject_mask(index.max(0) as usize);
@@ -850,7 +880,7 @@ fn watch(
Ok(None) => {}
Err(e) => log::warn!("segmentation failed: {e}"),
}
w.set_segmenting(false);
w.global::<Masking>().set_segmenting(false);
sync(&w, &session);
sync_rows(&w, &rows, &session);
redraw(&w);
@@ -907,7 +937,7 @@ fn watch_refine(
Ok(None) => {}
Err(e) => log::warn!("refine failed: {e}"),
}
w.set_refining(false);
w.global::<Masking>().set_refining(false);
sync(&w, &session);
sync_rows(&w, &rows, &session);
redraw(&w);
@@ -925,13 +955,16 @@ fn watch_refine(
/// follows the view mode, which `reset_view_state` returns to `photo`.
pub(crate) fn reset(window: &AppWindow) {
window.set_overlay_on(false);
window.set_segmenting(false);
window.set_refining(false);
window.set_segmented(false);
window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default()));
window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default()));
window.set_category_rows(ModelRc::new(VecModel::<CategoryRow>::default()));
window.set_adjust_scope(GLOBAL_SCOPE.into());
let masking = window.global::<Masking>();
masking.set_segmenting(false);
masking.set_refining(false);
masking.set_segmented(false);
masking.set_masks(ModelRc::new(VecModel::<MaskRow>::default()));
masking.set_subjects(ModelRc::new(VecModel::<SubjectRow>::default()));
masking.set_categories(ModelRc::new(VecModel::<CategoryRow>::default()));
window
.global::<Adjustments>()
.set_scope(GLOBAL_SCOPE.into());
clear_handles(window);
}
+30 -10
View File
@@ -40,7 +40,7 @@ use slint::ComponentHandle;
use crate::develop::DevelopSession;
use crate::preset_store::PresetStore;
use crate::{library, library_ui, settings_ui, AppWindow, ParamRow, ScopeKind};
use crate::{library, library_ui, settings_ui, AppWindow, ParamRow, ScopeKind, Steps, Transfer};
/// Where the develop view's current edit is stored.
///
@@ -502,8 +502,8 @@ fn refresh_thumbnail_for(
session: &Rc<RefCell<Option<DevelopSession>>>,
library: &Rc<library_ui::LibraryController>,
) {
let worth_it =
window.get_can_undo() || session.borrow().as_ref().is_some_and(|s| !s.is_neutral());
let worth_it = window.global::<Steps>().get_can_undo()
|| session.borrow().as_ref().is_some_and(|s| !s.is_neutral());
if !worth_it {
return;
}
@@ -557,12 +557,13 @@ pub fn render(
settings: &Rc<settings_ui::SettingsController>,
) {
let scope = scope_for(&settings.snapshot());
window.set_settings_armed(clipboard.is_armed());
window.set_settings_summary(clipboard.describe(scope).into());
let transfer = window.global::<Transfer>();
transfer.set_armed(clipboard.is_armed());
transfer.set_summary(clipboard.describe(scope).into());
// Only worth saying when it is actually true of *this* copy: a clipboard
// holding no crop loses nothing to the setting, and saying so anyway would
// train the user to ignore the line.
window.set_settings_framing_withheld(
transfer.set_framing_withheld(
clipboard.holds_framing() && !scope.has(dr_pipeline::Attribute::Compose),
);
}
@@ -603,7 +604,7 @@ pub fn wire(
let clipboard = clipboard.clone();
let session = session.clone();
let settings = settings.clone();
window.on_copy_settings(move || {
window.global::<Transfer>().on_copy(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow().as_ref() {
clipboard.copy_from(s);
@@ -622,7 +623,7 @@ pub fn wire(
let redraw = redraw.clone();
let open = open.clone();
let library = library.clone();
window.on_paste_settings(move || {
window.global::<Transfer>().on_paste(move || {
let Some(w) = weak.upgrade() else { return };
let Some(preset) = clipboard.peek() else {
return;
@@ -933,11 +934,22 @@ pub fn wire_named(
});
}
// --- what saving would capture ----------------------------------------
// --- opening the sheet over the open photograph -----------------------
//
// The develop column's "Presets…" button, and everything opening the sheet
// from there means. It used to be four lines inside the panel's
// instantiation in `app.slint`, which is exactly the sort of behaviour a
// second drawing of that panel would have had to copy; `Transfer` is a
// global and a global cannot bind the window's own state, so it lands
// here instead — where the summary it also has to compute already lived.
//
// The grid opens the same sheet with its own count and no capture summary,
// still from `app.slint`: nothing there is worth saving, so `can-save` is
// false and the summary would describe an edit that is not in hand.
{
let weak = window.as_weak();
let session = session.clone();
window.on_presets_opened(move || {
window.global::<Transfer>().on_open_presets(move || {
let Some(w) = weak.upgrade() else { return };
// At the scope a save would use, which is full: a preset keeps
// the framing it was captured with and drops it at apply time.
@@ -947,6 +959,14 @@ pub fn wire_named(
.map(|s| describe(&s.copy_settings(), Scope::everything()))
.unwrap_or_default();
w.set_preset_capture_summary(summary.into());
// Opened with no count, which is what tells the apply handler this
// means the open photograph rather than a selection.
w.set_preset_apply_count(0);
// `crate::Develop` spelled out: this module has a `Develop` of its
// own, and it is the bundle of session handles below, not the
// interface global.
w.set_preset_can_save(w.global::<crate::Develop>().get_enabled());
w.set_presets_open(true);
});
}
+3 -2
View File
@@ -32,7 +32,7 @@ use dr_types::{
use slint::ComponentHandle;
use crate::settings_store::SettingsStore;
use crate::AppWindow;
use crate::{Adjustments, AppWindow};
use dr_sync::Connection;
/// Shared settings state for the running window.
@@ -504,7 +504,8 @@ pub fn wire<F, G>(
// control to the other, and without it the preference would
// save correctly and change nothing until the next launch.
render(&w, &ctl);
w.set_groups_in_rail(choice.groups_in_rail(dr_plat::is_touch_first()));
w.global::<Adjustments>()
.set_groups_in_rail(choice.groups_in_rail(dr_plat::is_touch_first()));
});
}
+28 -23
View File
@@ -14,7 +14,7 @@ use std::rc::Rc;
use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel};
use crate::develop::DevelopSession;
use crate::{AppWindow, SpotHandle};
use crate::{AppWindow, Repair, SpotHandle, Steps};
/// TRACES: FR-DEV-8 | FR-UI-3
/// Move the circles to where the repairs now are.
@@ -51,7 +51,9 @@ pub(crate) fn sync_handles(window: &AppWindow, session: &DevelopSession) {
}
window.set_spot_handles(ModelRc::from(model));
window.set_selected_spot(session.selected_spot_id().unwrap_or_default().into());
window
.global::<Repair>()
.set_selected(session.selected_spot_id().unwrap_or_default().into());
}
/// The circles' model, held for the life of the process — one shared identity,
@@ -73,8 +75,8 @@ pub(crate) fn reset(window: &AppWindow) {
model.remove(model.row_count() - 1);
}
window.set_spot_handles(ModelRc::from(model));
window.set_selected_spot(Default::default());
window.set_spot_count(0);
window.global::<Repair>().set_selected(Default::default());
window.global::<Repair>().set_count(0);
}
/// Install the repair tool's callbacks.
@@ -179,7 +181,7 @@ pub(crate) fn wire(
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.$install(move |value| {
window.global::<Repair>().$install(move |value| {
let Some(w) = weak.upgrade() else { return };
let changed = session
.borrow_mut()
@@ -198,18 +200,16 @@ pub(crate) fn wire(
}};
}
control!(on_spot_radius_changed, |spot: &mut dr_pipeline::Spot, v| {
control!(on_radius_changed, |spot: &mut dr_pipeline::Spot, v| {
spot.set_radius(v)
});
control!(
on_spot_feather_changed,
|spot: &mut dr_pipeline::Spot, v| { spot.set_feather(v) }
);
control!(
on_spot_opacity_changed,
|spot: &mut dr_pipeline::Spot, v| { spot.set_opacity(v) }
);
control!(on_spot_mode_picked, |spot: &mut dr_pipeline::Spot, i| {
control!(on_feather_changed, |spot: &mut dr_pipeline::Spot, v| {
spot.set_feather(v)
});
control!(on_opacity_changed, |spot: &mut dr_pipeline::Spot, v| {
spot.set_opacity(v)
});
control!(on_mode_picked, |spot: &mut dr_pipeline::Spot, i| {
spot.mode = if i == 1 {
dr_pipeline::SpotMode::Clone
} else {
@@ -243,7 +243,7 @@ pub(crate) fn wire(
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_spot_removed(move |id| {
window.global::<Repair>().on_removed(move |id| {
let Some(w) = weak.upgrade() else { return };
let removed = session
.borrow_mut()
@@ -269,12 +269,13 @@ pub(crate) fn wire(
/// change at different times: the circles move on every frame of a drag and on
/// every pan, and these move when a repair is selected or a control is used.
pub(crate) fn sync_panel(window: &AppWindow, session: &DevelopSession) {
window.set_spot_count(session.spot_count() as i32);
let repair = window.global::<Repair>();
repair.set_count(session.spot_count() as i32);
if let Some(spot) = session.selected_spot() {
window.set_spot_radius(spot.radius);
window.set_spot_feather(spot.feather);
window.set_spot_opacity(spot.opacity);
window.set_spot_mode(match spot.mode {
repair.set_radius(spot.radius);
repair.set_feather(spot.feather);
repair.set_opacity(spot.opacity);
repair.set_mode(match spot.mode {
dr_pipeline::SpotMode::Heal => 0,
dr_pipeline::SpotMode::Clone => 1,
});
@@ -287,6 +288,10 @@ pub(crate) fn sync_panel(window: &AppWindow, session: &DevelopSession) {
/// disabled undo button after an edit that *is* undoable is the kind of small
/// lie that stops people trusting the button at all.
fn sync_undo(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>>) {
window.set_can_undo(session.borrow().as_ref().is_some_and(|s| s.can_undo()));
window.set_can_redo(session.borrow().as_ref().is_some_and(|s| s.can_redo()));
window
.global::<Steps>()
.set_can_undo(session.borrow().as_ref().is_some_and(|s| s.can_undo()));
window
.global::<Steps>()
.set_can_redo(session.borrow().as_ref().is_some_and(|s| s.can_redo()));
}
+296 -234
View File
@@ -8,6 +8,7 @@
// (FR-DEV-3c).
import { Theme } from "theme.slint";
import { Develop } from "session.slint";
import { PanelHeading, Label, Value, Caption, Button, IconButton, Swatch } from "widgets.slint";
import { SliderTrack, ControlRow, CurveEditor, Segmented, ChoiceChip, ChipGrid } from "controls.slint";
@@ -599,6 +600,66 @@ component ParamControl inherits Rectangle {
}
}
/// TRACES: FR-DEV-3
/// The frame: its shape, its angle, and the two ways of turning it over.
///
/// A global rather than nine properties and eight callbacks on the panel — see
/// `session.slint` for why the develop panels stopped taking their wiring
/// through the window root.
///
/// Everything here is **mirrored from the session rather than held here**, for
/// the same reason the crop rect is: Rust clamps and wraps these, so the panel
/// must show what was actually applied and not what the click asked for.
///
/// The crop overlay's own state is not here. Whether the overlay is up is
/// `Develop.cropping`, a reading of the view's mode, and the rect itself is
/// dragged on the photograph rather than set from this panel.
export global Framing {
in property <float> angle: 0.0;
/// The straighten slider's travel either way, from the descriptor.
in property <float> max-straighten: 45.0;
in property <bool> flip-h: false;
in property <bool> flip-v: false;
/// Any of crop, angle, rotation or flips differs from neutral — what
/// lights the section's dot and enables its reset. Distinct from the crop
/// rect being non-full: a rotation or a flip is an edit with the crop
/// still full.
in property <bool> modified: false;
/// TRACES: FR-DEV-3
/// The ratios the crop may be locked to, and which one is chosen.
///
/// Named by Rust rather than listed here: the set is `CropAspect::CHOICES`
/// and the labels come off it, so a ratio added there appears without this
/// file changing.
in property <[string]> aspects;
in property <int> aspect: 0;
/// Whether the chosen ratio is standing on its short edge.
in property <bool> portrait: false;
/// Whether the chosen ratio has a second orientation at all — `Free` and
/// the square do not, and the switch says so rather than vanishing.
in property <bool> aspect-turnable: false;
/// Quarter turns, positive clockwise.
callback rotate(int);
callback flip-h-toggled();
callback flip-v-toggled();
callback angle-changed(float);
/// TRACES: FR-DEV-3
/// The straightening gesture is over and this is the angle to keep.
///
/// What the auto-crop hangs off — see `DevelopSession::auto_crop_to_angle`
/// for why it must be the end of the drag and not every frame of it, and
/// `PlainSlider::committed` for why it must not be `drag-changed`.
callback angle-committed(float);
/// Lock the crop to one of `aspects`, by index.
callback aspect-picked(int);
/// Stand the chosen ratio on its other edge.
callback portrait-toggled();
/// Crop, angle, rotation and flips back to neutral, leaving colour alone.
callback reset();
}
// Crop, rotation, flips and straightening — the framing controls.
//
// **Why this is hand-built when the rest of the panel is generated.** The
@@ -613,41 +674,6 @@ component ParamControl inherits Rectangle {
// of a descriptor or routes by index. It calls named session actions, which is
// what a bespoke widget for a known stage is entitled to do.
export component ComposePanel inherits Rectangle {
in property <bool> enabled: true;
in property <float> angle: 0.0;
in property <float> max-straighten: 45.0;
in property <bool> flip-h: false;
in property <bool> flip-v: false;
/// Any of crop, angle, rotation or flips differs from neutral.
in property <bool> modified: false;
/// TRACES: FR-DEV-3
/// The crop overlay is up, so the ratio lock is a control for a gesture
/// that is currently on screen.
in property <bool> cropping: false;
/// The ratios on offer, named by Rust — see `CropAspect::CHOICES`.
in property <[string]> aspects;
in property <int> aspect: 0;
in property <bool> portrait: false;
/// Whether the chosen ratio has a second orientation. `Free` and the
/// square do not.
in property <bool> aspect-turnable: false;
callback rotate(int);
callback flip-h-toggled();
callback flip-v-toggled();
callback angle-changed(float);
/// The straightening gesture is over and this is the angle to keep.
///
/// What the auto-crop hangs off — see `DevelopSession::auto_crop_to_angle`
/// for why it must be the end of the drag and not every frame of it, and
/// `PlainSlider::committed` for why it must not be `drag-changed`.
callback angle-committed(float);
callback angle-reset();
callback aspect-picked(int);
callback portrait-toggled();
callback reset();
height: layout.preferred-height;
layout := VerticalLayout {
@@ -664,9 +690,9 @@ export component ComposePanel inherits Rectangle {
// reset — without the hiding.
GroupHeading {
title: "COMPOSE";
modified: root.modified;
has-reset: root.modified;
reset => { root.reset(); }
modified: Framing.modified;
has-reset: Framing.modified;
reset => { Framing.reset(); }
}
VerticalLayout {
@@ -696,14 +722,14 @@ export component ComposePanel inherits Rectangle {
IconButton {
icon: "rotate-ccw";
label: "Rotate left";
enabled: root.enabled;
clicked => { root.rotate(-1); }
enabled: Develop.enabled;
clicked => { Framing.rotate(-1); }
}
IconButton {
icon: "rotate-cw";
label: "Rotate right";
enabled: root.enabled;
clicked => { root.rotate(1); }
enabled: Develop.enabled;
clicked => { Framing.rotate(1); }
}
Rectangle { horizontal-stretch: 1; }
@@ -711,33 +737,33 @@ export component ComposePanel inherits Rectangle {
IconButton {
icon: "flip-h";
label: "Flip horizontally";
active: root.flip-h;
active: Framing.flip-h;
accessible-checkable: true;
accessible-checked: root.flip-h;
enabled: root.enabled;
clicked => { root.flip-h-toggled(); }
accessible-checked: Framing.flip-h;
enabled: Develop.enabled;
clicked => { Framing.flip-h-toggled(); }
}
IconButton {
icon: "flip-v";
label: "Flip vertically";
active: root.flip-v;
active: Framing.flip-v;
accessible-checkable: true;
accessible-checked: root.flip-v;
enabled: root.enabled;
clicked => { root.flip-v-toggled(); }
accessible-checked: Framing.flip-v;
enabled: Develop.enabled;
clicked => { Framing.flip-v-toggled(); }
}
}
PlainSlider {
label: "Straighten";
value: root.angle;
value: Framing.angle;
default-value: 0.0;
minimum: -root.max-straighten;
maximum: root.max-straighten;
minimum: -Framing.max-straighten;
maximum: Framing.max-straighten;
unit: "°";
changed(v) => { root.angle-changed(v); }
committed(v) => { root.angle-committed(v); }
reset => { root.angle-reset(); }
changed(v) => { Framing.angle-changed(v); }
committed(v) => { Framing.angle-committed(v); }
reset => { Framing.angle-changed(0); }
}
// TRACES: FR-DEV-3
@@ -759,16 +785,19 @@ export component ComposePanel inherits Rectangle {
// on top of one another — invisible in this file and obvious on
// screen. `app.slint` and `masks.slint` both carry the same note
// for the same reason.
if root.cropping: Label { text: "Ratio"; body: true; }
// **Only while the crop overlay is up.** Shown at all times these
// would be a control for a gesture that is not on screen; hidden
// behind a lid they would be a feature nobody finds.
if Develop.cropping: Label { text: "Ratio"; body: true; }
// The same wrapping grid the generated enum rows use, for the
// same reason: six chips in a row would be 414px of chips setting
// the width of a column that is meant to be 280.
if root.cropping: ChipGrid {
options: root.aspects;
selected: root.aspect;
enabled: root.enabled;
picked(i) => { root.aspect-picked(i); }
if Develop.cropping: ChipGrid {
options: Framing.aspects;
selected: Framing.aspect;
enabled: Develop.enabled;
picked(i) => { Framing.aspect-picked(i); }
}
// One chip, lit when the ratio is standing on its short edge,
@@ -783,7 +812,7 @@ export component ComposePanel inherits Rectangle {
// under the first column: a `ChoiceChip` placed straight into the
// `VerticalLayout` is stretched across the whole panel, which reads
// as a button for the section rather than as one more chip.
if root.cropping: Rectangle {
if Develop.cropping: Rectangle {
height: Theme.control-height;
ChoiceChip {
@@ -791,29 +820,51 @@ export component ComposePanel inherits Rectangle {
width: 88px;
height: 100%;
label: "Portrait";
selected: root.portrait;
enabled: root.enabled && root.aspect-turnable;
clicked => { root.portrait-toggled(); }
selected: Framing.portrait;
enabled: Develop.enabled && Framing.aspect-turnable;
clicked => { Framing.portrait-toggled(); }
}
}
}
}
}
// The panel: a heading per multi-parameter operation, a control per parameter.
//
// **Why the loop is shaped the way it is.** `rows` is flat, and Slint can
// neither slice a model nor nest a `for` over a run of it. What it *can* do is
// repeat over an integer — `for n in row.group-len` — so each group's heading
// row renders its whole group by indexing back into `rows` from `group-head`,
// and every other row renders nothing.
//
// **Nothing here collapses.** Every group was a `Section` with a disclosure
// triangle until it became clear what that cost on a tablet: five of the
// pipeline's operations carry one parameter, so the lid was most of the row,
// and a control behind a lid is one the user does not know the pipeline has.
// The column is closed as a whole from the status strip instead, which is the
// control that was actually wanted.
/// TRACES: FR-DEV-6
/// The settings clipboard, as the develop column sees it.
///
/// A global rather than three properties and three callbacks on the panel —
/// `session.slint` gives the argument.
///
/// The clipboard itself is a **window-lifetime** thing and not a view's: a
/// copy is taken in develop and may be pasted onto a selection back in the
/// library grid, and the grid's own paste stays on the window root where the
/// grid can reach it. What is here is the develop half — the two buttons and
/// what they would do.
export global Transfer {
/// Whether anything has been copied yet. Distinct from the clipboard
/// being *neutral*: a copy of an unedited frame is a real thing to paste,
/// since it clears the target.
in property <bool> armed: false;
/// What a paste would apply, at the current scope — "3 adjustments", or
/// "Neutral".
in property <string> summary;
/// Whether the clipboard holds framing the current scope is dropping.
/// Only then is it worth saying anything about the crop.
in property <bool> framing-withheld: false;
callback copy();
callback paste();
/// TRACES: FR-DEV-6
/// Open the named-preset sheet over the open photograph. Beside copy and
/// paste because it is the same thought given a name — this edit, kept.
///
/// Handled in Rust rather than by setting the sheet's properties here, as
/// the panel's instantiation used to: a global cannot bind the window's
/// state, and the handler has to say what saving would capture anyway.
/// The grid opens the same sheet with its own count, from `app.slint`.
callback open-presets();
}
// TRACES: FR-DEV-6
// Copying this photograph's settings, and pasting settings onto it.
//
@@ -828,24 +879,6 @@ export component ComposePanel inherits Rectangle {
// whether the crop is coming with it — a question the label answers by
// naming the count the *current* scope would apply.
export component TransferPanel inherits VerticalLayout {
in property <bool> enabled: true;
/// Whether anything has been copied yet. Distinct from the clipboard
/// being *neutral*: a copy of an unedited frame is a real thing to paste,
/// since it clears the target.
in property <bool> armed: false;
/// What a paste would apply — "3 adjustments", or "Neutral".
in property <string> summary;
/// Whether the clipboard holds framing the current scope is dropping.
/// Only then is it worth saying anything about the crop.
in property <bool> framing-withheld: false;
callback copy();
callback paste();
/// TRACES: FR-DEV-6
/// Open the named-preset sheet. Beside copy and paste because it is the
/// same thought given a name — this edit, kept.
callback open-presets();
padding: Theme.gap;
spacing: Theme.gap-sm;
@@ -859,9 +892,9 @@ export component TransferPanel inherits VerticalLayout {
Button {
text: "Copy";
enabled: root.enabled;
enabled: Develop.enabled;
horizontal-stretch: 1;
clicked => { root.copy(); }
clicked => { Transfer.copy(); }
}
Button {
@@ -869,9 +902,9 @@ export component TransferPanel inherits VerticalLayout {
// Enabled on `armed` rather than on the summary being non-empty,
// so pasting a neutral copy — which clears this image — stays
// available. Still needs an image to paste *onto*.
enabled: root.enabled && root.armed;
enabled: Develop.enabled && Transfer.armed;
horizontal-stretch: 1;
clicked => { root.paste(); }
clicked => { Transfer.paste(); }
}
}
@@ -881,39 +914,117 @@ export component TransferPanel inherits VerticalLayout {
// that does neither sitting between them would read as part of that pair.
Button {
text: "Presets…";
enabled: root.enabled;
clicked => { root.open-presets(); }
enabled: Develop.enabled;
clicked => { Transfer.open-presets(); }
}
if root.armed: Caption {
text: root.summary + (root.framing-withheld ? " · crop not included" : "");
if Transfer.armed: Caption {
text: Transfer.summary + (Transfer.framing-withheld ? " · crop not included" : "");
}
}
/// Which mode the develop view is in.
/// TRACES: FR-DEV-3
/// The generated controls: what they show, what they are pointed at, and what
/// pressing one means.
///
/// `crop` was a bare `bool` on the window and local masking was a panel with
/// two toggles, so nothing stopped both being on at once — and nothing on
/// screen said which of them the canvas and the column were obeying. One value
/// with three states cannot be in two of them, which is the whole reason this
/// is an enum rather than a tidier pair of flags.
export enum ViewMode {
/// The whole photograph. Sliders are global, the canvas pans and zooms.
photo,
/// The crop overlay is up and the canvas shows the uncropped frame.
crop,
/// The region map is drawn, a click on the photograph selects, and the
/// column is the mask stack and the selected layer's adjustments.
local,
/// TRACES: FR-DEV-8
/// The repairs are drawn on the photograph, a click places one, and the
/// column describes whichever is selected.
/// A global rather than eleven properties and eight callbacks spread over two
/// panels — `session.slint` gives the argument. `GroupStrip` and `AdjustPanel`
/// share it because they are one control between them: the strip says which
/// slice of `rows` the panel is drawing, and a strip that filtered a different
/// panel from the one beside it would be a control for nothing.
///
/// **This names no operation and no parameter.** Everything here is a model
/// the core supplied or an index back into it (ARCH §4.3a, FR-DEV-3a).
export global Adjustments {
in property <[ParamRow]> rows;
/// What these controls are pointed at — the whole photograph, or one mask
/// layer by name.
///
/// A mode rather than a panel button for the reason the enum exists at
/// all: arming a click on the canvas is something only one tool may be
/// doing at a time, and two flags could both be true.
spots,
/// Supplied already resolved: whether a layer is selected and what it is
/// called are session facts, and deriving them in the panel would need
/// that file to reason about the mask stack.
in property <string> scope: "ADJUST";
/// The groups the strip offers, already resolved, and which is chosen.
/// -1 is "everything".
in property <[string]> tabs;
in property <int> active-tab: -1;
callback tab-picked(int);
/// TRACES: FR-UI-1 | FR-UI-7
/// Whether the groups are chosen from the tool rail instead of from the
/// strip above the column.
///
/// Owned by Rust, which composes it from the input modality and the
/// photographer's override — see `ToolRail::groups-in-rail` for why the
/// axis is input rather than platform or width.
///
/// The two controls are mutually exclusive by construction: this shows one
/// and hides the other, so there is never a moment with two ways to answer
/// the same question, and no state to keep in step between them.
in property <bool> groups-in-rail: false;
/// The tone curve's sampled shape, evaluated in Rust by the same spline
/// the shader runs so the drawn line cannot disagree with the applied one.
in property <[float]> curve-samples;
/// The curves the tone curve widget can plot, named by the core. Fewer
/// than two of them means there is nothing to choose and no selector.
in property <[string]> curve-channels;
/// Which of them `curve-samples` and the row's points describe.
in property <int> curve-channel;
callback param-changed(int, int, float);
callback param-reset(int, int);
/// Every parameter of one operation back to its default. A section's reset
/// and a curve's reset are the same action, so they share the one callback
/// rather than duplicating a handler that would have to be kept in step
/// with it.
callback curve-reset(int);
/// Plot a different one of the curve's curves.
callback curve-channel-picked(int);
callback reset-all();
// TRACES: FR-DEV-3
// The on-canvas sampler: which group has armed it.
//
// The op index rather than a bool, because arming is a question about a
// *group* — two operations could ask for a sampler and only one of them
// can be waiting for the click. -1 is none armed, the sentinel this file
// already uses for "no swatch" and "everything" and for the same reason:
// a Slint struct or property cannot carry an optional.
in-out property <int> sampling-op: -1;
/// The heading's picker was pressed for the operation at this index.
///
/// A function rather than a callback into Rust, and that is deliberate:
/// arming changes what a click on the photograph does and nothing about
/// the edit, so there is nothing for the session to hear about until a
/// point is actually picked. It lived in `app.slint`'s instantiation of
/// the panel before, which is exactly the sort of behaviour a second
/// instantiation would have had to copy.
public function arm-sampler(op: int) {
self.sampling-op = self.sampling-op == op ? -1 : op;
}
// TRACES: FR-DEV-3f
// The film stock, which is not a parameter and so is not a `ParamRow`.
//
// A bespoke control for the same reason the mask panel is one: a stock is
// a choice of *material*, named by an id, not a number on a slider. The
// generic rows keep their rule — they still name no operation — and the
// film's own exposure sliders arrive through them like any other.
//
// Names are supplied already resolved, and the list is whatever profiles
// are installed: this file does not know what a Kodachrome is.
in property <[string]> film-stocks;
/// Index into `film-stocks`. Zero is the first entry, which Rust makes
/// "no film" — so a fresh photograph selects it without a sentinel.
in property <int> film-selected: 0;
/// Whether the chosen stock is a negative with a paper to print on. False
/// for a reversal stock, which is the picture already, and for no film.
in property <bool> film-can-print: false;
/// Print it, rather than view the negative as scanned.
in property <bool> film-print: false;
callback film-picked(int);
callback film-print-toggled(bool);
}
/// The group strip: which kind of adjustment the column is showing.
@@ -944,16 +1055,16 @@ export enum ViewMode {
/// joins a group without an edit here (FR-DEV-3a). With the tools gone there is
/// no longer any hand-written word in this row at all, save "All".
export component GroupStrip inherits Rectangle {
in property <[string]> tabs;
/// Index into `tabs`, or -1 for "everything".
in property <int> active-tab: -1;
in property <bool> enabled: true;
callback picked(int);
/// Whether the strip has anything to offer and is the control that offers
/// it: an open photograph, with the groups not already in the rail.
///
/// Derived here rather than bound at the instantiation, so a second
/// composition of this column cannot get the rule slightly different.
property <bool> live: Develop.enabled && !Adjustments.groups-in-rail;
background: Theme.surface;
height: root.enabled ? layout.preferred-height : 0px;
visible: root.enabled;
height: root.live ? layout.preferred-height : 0px;
visible: root.live;
// Scrolls rather than overflowing, exactly as the develop status strip
// does and for the same reason: a `HorizontalLayout` given less width than
@@ -994,11 +1105,11 @@ export component GroupStrip inherits Rectangle {
width: 34px;
height: Theme.touch-target;
mouse-cursor: pointer;
clicked => { root.picked(-1); }
clicked => { Adjustments.tab-picked(-1); }
Label {
text: "All";
emphasised: root.active-tab == -1 || all.has-hover;
emphasised: Adjustments.active-tab == -1 || all.has-hover;
vertical-alignment: center;
}
@@ -1006,19 +1117,19 @@ export component GroupStrip inherits Rectangle {
y: parent.height - 2px;
height: 2px;
width: parent.width;
background: root.active-tab == -1 ? Theme.ink : transparent;
background: Adjustments.active-tab == -1 ? Theme.ink : transparent;
}
}
for tab[i] in root.tabs: tab-area := TouchArea {
for tab[i] in Adjustments.tabs: tab-area := TouchArea {
width: name.preferred-width + 10px;
height: Theme.touch-target;
mouse-cursor: pointer;
clicked => { root.picked(i); }
clicked => { Adjustments.tab-picked(i); }
name := Label {
text: tab;
emphasised: root.active-tab == i || tab-area.has-hover;
emphasised: Adjustments.active-tab == i || tab-area.has-hover;
vertical-alignment: center;
horizontal-alignment: center;
}
@@ -1029,83 +1140,34 @@ export component GroupStrip inherits Rectangle {
y: parent.height - 2px;
height: 2px;
width: parent.width;
background: root.active-tab == i ? Theme.ink : transparent;
background: Adjustments.active-tab == i ? Theme.ink : transparent;
}
}
}
}
}
// The panel: a heading per multi-parameter operation, a control per parameter.
//
// **Why the loop is shaped the way it is.** `rows` is flat, and Slint can
// neither slice a model nor nest a `for` over a run of it. What it *can* do is
// repeat over an integer — `for n in row.group-len` — so each group's heading
// row renders its whole group by indexing back into `rows` from `group-head`,
// and every other row renders nothing.
//
// **Nothing here collapses.** Every group was a `Section` with a disclosure
// triangle until it became clear what that cost on a tablet: five of the
// pipeline's operations carry one parameter, so the lid was most of the row,
// and a control behind a lid is one the user does not know the pipeline has.
// The column is closed as a whole from the status strip instead, which is the
// control that was actually wanted.
export component AdjustPanel inherits Rectangle {
in property <[ParamRow]> rows;
in property <bool> enabled: true;
/// What these controls are pointed at — the whole photograph, or one mask
/// layer by name.
///
/// **The heading, not a caption beside it.** Selecting a layer re-points
/// every one of these controls at that layer's chain, and until now the
/// only sign of it was a sentence in the panel above. Putting the answer
/// in the heading of the thing that changed means the scope cannot be read
/// without also reading what it applies to.
///
/// Supplied already resolved: whether a layer is selected and what it is
/// called are session facts, and deriving them here would need this file
/// to reason about the mask stack.
in property <string> scope: "ADJUST";
/// The tone curve's sampled shape, evaluated in Rust by the same spline
/// the shader runs so the drawn line cannot disagree with the applied one.
in property <[float]> curve-samples;
/// The curves the tone curve widget can plot, named by the core. Fewer
/// than two of them means there is nothing to choose and no selector.
in property <[string]> curve-channels;
/// Which of them `curve-samples` and the row's points describe.
in property <int> curve-channel;
callback param-changed(int, int, float);
callback param-reset(int, int);
callback curve-reset(int);
/// Plot a different one of the curve's curves.
callback curve-channel-picked(int);
/// Return every parameter of one operation to its default — the reset on
/// a section's own header, beside the panel-wide one.
callback op-reset(int);
callback reset-all();
// TRACES: FR-DEV-3
// The on-canvas sampler: which group has armed it, and the request to arm
// one.
//
// The op index rather than a bool, because arming is a question about a
// *group* — two operations could ask for a sampler and only one of them
// can be waiting for the click. -1 is none armed, the sentinel this file
// already uses for "no swatch" and "everything" and for the same reason:
// a Slint struct or property cannot carry an optional.
in property <int> sampling-op: -1;
/// The heading's picker was pressed, for the operation at this index.
callback sampler-armed(int);
// TRACES: FR-DEV-3f
// The film stock, which is not a parameter and so is not a `ParamRow`.
//
// A bespoke control for the same reason the mask panel is one: a stock is
// a choice of *material*, named by an id, not a number on a slider. The
// generic rows above keep their rule — they still name no operation — and
// the film's own exposure sliders arrive through them like any other.
//
// Names are supplied already resolved, and the list is whatever profiles
// are installed: this file does not know what a Kodachrome is.
in property <[string]> film-stocks;
/// Index into `film-stocks`. Zero is the first entry, which Rust makes
/// "no film" — so a fresh photograph selects it without a sentinel.
in property <int> film-selected: 0;
/// Whether the chosen stock is a negative with a paper to print on. False
/// for a reversal stock, which is the picture already, and for no film.
in property <bool> film-can-print: false;
/// Print it, rather than view the negative as scanned.
in property <bool> film-print: false;
callback film-picked(int);
callback film-print-toggled(bool);
/// Whether the stock list is open. Pure interface state: it changes no
/// parameter and the core never hears about it.
///
/// Private to the panel rather than in `Adjustments`, and deliberately: it
/// is a lid, and two drawings of this panel may honestly have their lids
/// in different positions.
private property <bool> film-expanded: false;
background: Theme.surface;
@@ -1134,7 +1196,7 @@ export component AdjustPanel inherits Rectangle {
HorizontalLayout {
PanelHeading {
text: root.scope;
text: Adjustments.scope;
// Elided rather than wrapped. A layer's name is the user's and
// can be any length; a heading that wrapped would change the
// panel's height as the selection moved, and an unwrapped one
@@ -1148,7 +1210,7 @@ export component AdjustPanel inherits Rectangle {
reset := TouchArea {
width: 44px;
height: 20px;
clicked => { root.reset-all(); }
clicked => { Adjustments.reset-all(); }
Label {
text: "reset";
emphasised: reset.has-hover;
@@ -1157,13 +1219,13 @@ export component AdjustPanel inherits Rectangle {
}
}
if !root.enabled: Caption { text: "No image"; }
if !Develop.enabled: Caption { text: "No image"; }
// TRACES: FR-DEV-3f
// Above the rows, because it decides what they mean: the exposure
// slider below is a slider on *this stock's* characteristic curve, and
// the same number is a different picture on a different film.
if root.enabled && root.film-stocks.length > 1: VerticalLayout {
if Develop.enabled && Adjustments.film-stocks.length > 1: VerticalLayout {
spacing: Theme.gap-sm;
// **Collapsed to one row, and opened only to change it.**
@@ -1203,7 +1265,7 @@ export component AdjustPanel inherits Rectangle {
// The chosen stock's own name, elided rather than
// wrapped: it is a product name of any length and
// this column is 280px.
text: root.film-stocks[root.film-selected];
text: Adjustments.film-stocks[Adjustments.film-selected];
overflow: elide;
horizontal-alignment: right;
horizontal-stretch: 1;
@@ -1220,7 +1282,7 @@ export component AdjustPanel inherits Rectangle {
if root.film-expanded: VerticalLayout {
spacing: 0px;
for stock[i] in root.film-stocks: film-row := TouchArea {
for stock[i] in Adjustments.film-stocks: film-row := TouchArea {
// Shorter than a touch target, which is a deliberate
// exception and the only one here: a list row's neighbours
// are other rows, so growing the hit area the way a chip
@@ -1229,7 +1291,7 @@ export component AdjustPanel inherits Rectangle {
height: 32px;
mouse-cursor: pointer;
clicked => {
root.film-picked(i);
Adjustments.film-picked(i);
// Closed on choosing. The answer is now in the summary
// row, and leaving two dozen entries open after the
// question has been answered is the behaviour this
@@ -1239,7 +1301,7 @@ export component AdjustPanel inherits Rectangle {
Rectangle {
border-radius: Theme.radius;
background: i == root.film-selected ? Theme.active
background: i == Adjustments.film-selected ? Theme.active
: (film-row.pressed ? Theme.pressed
: (film-row.has-hover ? Theme.hover : transparent));
@@ -1260,15 +1322,15 @@ export component AdjustPanel inherits Rectangle {
// Only for a negative. A reversal stock has no paper — it is the
// photograph as it comes — so offering the choice would be
// offering something that cannot happen.
if root.film-can-print: HorizontalLayout {
if Adjustments.film-can-print: HorizontalLayout {
spacing: Theme.gap-sm;
alignment: start;
print-toggle := TouchArea {
width: 120px;
height: Theme.touch-target;
clicked => { root.film-print-toggled(!root.film-print); }
clicked => { Adjustments.film-print-toggled(!Adjustments.film-print); }
Label {
text: root.film-print ? "Printed" : "Scanned negative";
text: Adjustments.film-print ? "Printed" : "Scanned negative";
emphasised: print-toggle.has-hover;
}
}
@@ -1281,7 +1343,7 @@ export component AdjustPanel inherits Rectangle {
// sliders needed and the instrument the sliders are judged against was
// unreachable. The column scrolls as one now, and a scroller inside a
// scroller would give every drag a third thing to be lost to.
if root.enabled: VerticalLayout {
if Develop.enabled: VerticalLayout {
content := VerticalLayout {
spacing: 0px;
alignment: start;
@@ -1315,7 +1377,7 @@ export component AdjustPanel inherits Rectangle {
//
// This used to nest: a group's head row drew the *whole* group
// by repeating over `row.group-len` and indexing back into
// `root.rows` for each member, and every other row drew
// `Adjustments.rows` for each member, and every other row drew
// nothing. It produced the right picture and could not be
// dragged.
//
@@ -1340,7 +1402,7 @@ export component AdjustPanel inherits Rectangle {
// It also drops the old hazard of the head row building every
// control in its group — thirty-six live TouchAreas behind the
// mixer's twelve visible ones.
for row[i] in root.rows: VerticalLayout {
for row[i] in Adjustments.rows: VerticalLayout {
spacing: 0px;
// The group's name, drawn by the row that heads it, above
@@ -1373,11 +1435,11 @@ export component AdjustPanel inherits Rectangle {
// waiting is the panel's business, because only
// the panel can see the others.
has-sampler: row.group-samples;
sampling: root.sampling-op == row.op-index;
sample => { root.sampler-armed(row.op-index); }
sampling: Adjustments.sampling-op == row.op-index;
sample => { Adjustments.arm-sampler(row.op-index); }
// Resetting is what *this* panel's groups do; the
// heading itself has no opinion about it.
reset => { root.op-reset(row.op-index); }
reset => { Adjustments.curve-reset(row.op-index); }
}
}
@@ -1391,16 +1453,16 @@ export component AdjustPanel inherits Rectangle {
ParamControl {
data: row;
curve-samples: root.curve-samples;
curve-channels: root.curve-channels;
curve-channel: root.curve-channel;
curve-samples: Adjustments.curve-samples;
curve-channels: Adjustments.curve-channels;
curve-channel: Adjustments.curve-channel;
drag-changed(on) => { root.slider-dragging = on; }
param-changed(op, param, v) => {
root.param-changed(op, param, v);
Adjustments.param-changed(op, param, v);
}
param-reset(op, param) => { root.param-reset(op, param); }
curve-reset(op) => { root.curve-reset(op); }
curve-channel-picked(i) => { root.curve-channel-picked(i); }
param-reset(op, param) => { Adjustments.param-reset(op, param); }
curve-reset(op) => { Adjustments.curve-reset(op); }
curve-channel-picked(i) => { Adjustments.curve-channel-picked(i); }
}
}
}
+89 -502
View File
@@ -1,28 +1,35 @@
import { Theme } from "theme.slint";
import { AdjustPanel, ComposePanel, GroupStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
import { CategoryRow, GradientHandle, GradientHandles, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
import { SpotHandle, SpotHandles, SpotPanel, SpotRole } from "spots.slint";
import { Develop, ViewMode } from "session.slint";
import { AdjustPanel, Adjustments, ComposePanel, Framing, GroupStrip, ParamRow, Transfer, TransferPanel } from "adjust.slint";
import { CategoryRow, GradientHandle, GradientHandles, HandleRole, MaskPanel, MaskRow, Masking, SubjectRow } from "masks.slint";
import { Repair, SpotHandle, SpotHandles, SpotPanel, SpotRole } from "spots.slint";
import { CropOverlay } from "crop.slint";
import { HistoryPanel, HistoryRow } from "history.slint";
import { HistoryPanel, HistoryRow, Steps } from "history.slint";
import { LaunchScreen } from "launch.slint";
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip } from "library.slint";
import { GestureRow } from "gestures.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
import { HistogramPanel, HistogramView } from "histogram.slint";
import { HistogramPanel, HistogramView, Levels } from "histogram.slint";
import { RecoveryPrompt } from "recovery.slint";
import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
import { FocusMarks, FocusPanel } from "peaking.slint";
import { FocusMarks, FocusPanel, Peaking } from "peaking.slint";
import { SettingsPage } from "settings.slint";
import { ImportPage } from "import.slint";
import { StatusBar, InfoPanel } from "develop.slint";
import { Capture, StatusBar, InfoPanel } from "develop.slint";
import { ToolRail } from "toolrail.slint";
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView, PersonChip }
export { GestureRow }
export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
// The develop view's globals, re-exported so Rust can reach them: a global is
// hooked through `window.global::<Adjustments>()` and only what this document
// exports appears in the generated API. `session.slint` says why the develop
// panels take their wiring this way and why there is one global per family.
export { Develop, Capture, Levels, Peaking, Adjustments, Framing, Transfer, Masking, Repair, Steps }
export component AppWindow inherits Window {
title: "DarkRoom";
// The one raster asset in the UI, and the exception that the header of
@@ -64,49 +71,17 @@ export component AppWindow inherits Window {
// Current image, for the status strip and empty state.
in property <string> filename: "";
in property <string> camera: "";
/// TRACES: FR-DEV-3
/// The lens the file names, and whether a correction profile was found for
/// it. Composed in Rust because the sentence depends on a database lookup
/// this file cannot see, and because "not recorded" and "no profile" are
/// different facts that must not be collapsed into one.
in property <string> lens: "";
in property <string> exposure: "";
in property <string> dimensions: "";
in property <int> index: 0;
in property <int> total: 0;
in property <string> load-error: "";
/// TRACES: FR-DSP-7
/// The counted frame. Set only when the render has settled — a histogram
/// of a draft frame is a histogram of an image nobody is reading.
in property <HistogramView> histogram;
/// The same photograph counted in the raw domain — the demosaiced
/// scene-linear texture, before white balance, the camera matrix or any
/// curve, on an axis of stops below sensor saturation.
///
/// Beside the display histogram rather than instead of it: the two answer
/// different questions and the panel offers both. Unlike the one above it
/// this is a property of the *file* and not of the render, so it survives
/// a crop, a zoom and every slider — see `raw_histogram.rs`.
in property <HistogramView> raw-histogram;
/// TRACES: FR-CULL-3
/// Focus peaking: the marks, whether they describe *this* frame, and the
/// three things the photographer chose. All of them are Rust's, because
/// the marks come from a compute pass — see `peaking.slint` for why
/// `focus-overlay-ready` is a separate question from `peaking-on`.
/// The focus marks themselves, and whether they describe *this* frame.
/// Rust's, because they come from a compute pass — see `peaking.slint` for
/// why `focus-overlay-ready` is a separate question from
/// `Peaking.showing`, and for the three things the photographer chose.
in property <image> focus-overlay;
in property <bool> focus-overlay-ready: false;
in property <bool> peaking-on: false;
in property <bool> peaking-available: true;
in property <int> peaking-sensitivity: 1;
in property <int> peaking-colour: 0;
callback peaking-toggled(bool);
callback peaking-sensitivity-picked(int);
callback peaking-colour-picked(int);
// --- zoom, pan and crop (FR-DEV-4) ---
//
@@ -124,28 +99,6 @@ export component AppWindow inherits Window {
/// resolution against the viewport's, which Slint does not know.
in property <bool> magnified: false;
/// TRACES: FR-UI-5
/// What the develop view is currently doing (see `ViewMode`).
///
/// One value rather than a flag per mode, so two modes cannot be on at
/// once — which, with `crop-mode` a bare bool beside a masking panel that
/// armed the canvas through two toggles of its own, they could be. Rust
/// owns it: entering a mode has side effects on the session (crop drops
/// the zoom, local clears nothing and leaving it clears the selection),
/// and a property the interface could set directly would let the view
/// change without them.
///
/// In `crop` the canvas shows the *whole* frame — otherwise the area being
/// cropped away would not be on screen to drag across — and the surround
/// is greyed.
in property <ViewMode> view-mode: ViewMode.photo;
/// Shorthand for the two conditions half this file tests, so a mode
/// comparison is written once rather than at each of a dozen call sites.
property <bool> cropping: root.view-mode == ViewMode.crop;
property <bool> local-mode: root.view-mode == ViewMode.local;
/// TRACES: FR-DEV-8
property <bool> repairing: root.view-mode == ViewMode.spots;
/// The crop rect in fractions of the frame, mirrored from Rust so the
/// overlay draws exactly what the pipeline holds.
in-out property <float> crop-x: 0.0;
@@ -153,20 +106,6 @@ export component AppWindow inherits Window {
in-out property <float> crop-w: 1.0;
in-out property <float> crop-h: 1.0;
/// TRACES: FR-DEV-3
/// The ratios the crop may be locked to, and which one is chosen.
///
/// Named by Rust rather than listed here: the set is `CropAspect::CHOICES`
/// and the labels come off it, so a ratio added there appears without this
/// file changing.
in property <[string]> crop-aspects;
in property <int> crop-aspect: 0;
/// Whether the chosen ratio is standing on its short edge.
in property <bool> crop-portrait: false;
/// Whether the chosen ratio has a portrait form at all — `Free` and the
/// square do not, and the switch says so rather than vanishing.
in property <bool> crop-aspect-turnable: false;
/// Enter a mode, or `photo` to leave whichever one is current.
callback mode-picked(ViewMode);
/// A dragged crop rect in fractions of the frame, then the corner the drag
@@ -174,50 +113,6 @@ export component AppWindow inherits Window {
/// shape it has. See `CropOverlay` in crop.slint for why the corner has to
/// travel with the rect.
callback crop-changed(float, float, float, float, float, float);
/// Lock the crop to one of `crop-aspects`, by index.
callback crop-aspect-picked(int);
/// Stand the chosen ratio on its other edge.
callback crop-portrait-toggled();
// --- rotation, flips and straightening (FR-DEV-3) ---
//
// Mirrored from the session rather than held here, for the same reason the
// crop rect is: Rust clamps and wraps these, so the panel must show what
// was actually applied and not what the click asked for.
in property <float> straighten: 0.0;
/// The straighten slider's travel either way, from the descriptor.
in property <float> max-straighten: 45.0;
in property <bool> flip-h: false;
in property <bool> flip-v: false;
/// Any framing edit is in effect — what lights the section's dot and
/// enables its reset. Distinct from the crop rect being non-full: a
/// rotation or a flip is an edit with the crop still full.
in property <bool> framing-modified: false;
/// Quarter turns, positive clockwise.
callback rotate-quarters(int);
callback flip-h-toggled();
callback flip-v-toggled();
callback straighten-changed(float);
/// TRACES: FR-DEV-3
/// The straightening gesture is over and this is the angle to keep.
///
/// Reported separately from the value because the auto-crop that follows a
/// straighten has to happen once, at the end — see
/// `DevelopSession::auto_crop_to_angle`.
callback straighten-committed(float);
/// Crop, angle, rotation and flips back to neutral, leaving colour alone.
callback framing-reset();
// --- undo and redo (FR-DEV-5) ---
//
// Rust owns whether there is anywhere to step, because "anywhere" is a
// position in a stack of snapshots this side never sees. A whole drag is
// one step; the coalescing that makes it so lives in `dr-pipeline`.
in property <bool> can-undo: false;
in property <bool> can-redo: false;
callback undo();
callback redo();
// --- before and after (FR-DEV-7) ---
@@ -240,20 +135,17 @@ export component AppWindow inherits Window {
// --- sampling a neutral off the photograph (FR-DEV-3) ---
/// TRACES: FR-DEV-3
/// Which operation's on-canvas sampler is armed, or -1 for none.
///
/// Held here rather than in Rust, unlike the view mode beside it, and the
/// difference is real: entering a mode has side effects on the session and
/// this has none. Arming changes what the next click on the photograph
/// means and nothing else — no parameter moves, no history step is taken,
/// and the session has nothing to hear about until a point is picked. It
/// is one-shot: the click that samples disarms it.
///
/// An index rather than a bool because arming belongs to a *group*. Two
/// operations could offer a sampler and only one of them can be waiting.
in-out property <int> sampling-op: -1;
/// Shorthand for the condition the canvas tests, written once.
property <bool> sampling: root.sampling-op >= 0;
///
/// Which operation is armed lives in `Adjustments`, with the panel whose
/// headings arm it. It is held in the interface rather than in Rust,
/// unlike the view mode beside it, and the difference is real: entering a
/// mode has side effects on the session and this has none. Arming changes
/// what the next click on the photograph means and nothing else — no
/// parameter moves, no history step is taken, and the session has nothing
/// to hear about until a point is picked. It is one-shot: the click that
/// samples disarms it.
property <bool> sampling: Adjustments.sampling-op >= 0;
/// A neutral was picked, in fractions of the visible image.
///
/// The operation is not passed: which one a sampled colour belongs to is
@@ -261,14 +153,6 @@ export component AppWindow inherits Window {
/// knowing. See `dr_pipeline::neutral`.
callback neutral-picked(float, float);
/// Every step, newest first. Rust owns the order and stamps each row with
/// its own position in the stack, so nothing here does arithmetic to turn
/// a row back into a step.
in property <[HistoryRow]> history-rows;
/// What undo would take back, resolved. Empty when there is nowhere to go.
in property <string> undo-label: "";
/// A row's own `index`.
callback history-picked(int);
/// Scroll-to-zoom: factor, and the anchor in fractions of the visible area.
callback zoom-at(float, float, float);
callback pan-by(float, float);
@@ -770,62 +654,12 @@ export component AppWindow inherits Window {
callback next-image();
callback prev-image();
// Adjustment controls, generated from what the pipeline reports it can
// do. This window knows the *shape* of a control, never which operations
// exist (FR-DEV-3a).
in property <[ParamRow]> adjust-rows;
// TRACES: FR-DEV-3f
// The installed film stocks, "None" first, and which one this photograph
// is on. Resolved names: the core publishes ids, and a stock's name is a
// product name that does not translate.
in property <[string]> film-stocks;
in property <int> film-selected: 0;
in property <bool> film-can-print: false;
in property <bool> film-print: false;
callback film-picked(int);
callback film-print-toggled(bool);
/// TRACES: FR-UI-1 | FR-UI-7
/// Whether the adjustment groups are chosen from the tool rail or from the
/// strip above the develop column.
///
/// Owned by Rust, which composes it from the input modality and the
/// photographer's override — see `ToolRail::groups-in-rail` for why the
/// axis is input rather than platform or width.
///
/// The two controls are mutually exclusive by construction: this shows one
/// and hides the other, so there is never a moment with two ways to
/// answer the same question, and no state to keep in step between them.
in property <bool> groups-in-rail: false;
in property <[string]> adjust-tabs;
in property <int> adjust-active-tab: -1;
callback adjust-tab-picked(int);
in property <bool> adjust-enabled: false;
// The tone curve's sampled shape, evaluated by the core so the drawn
// line and the applied one cannot disagree.
in property <[float]> curve-samples;
// The curves that widget can plot, named by the core, and which of them
// `curve-samples` describes. Fewer than two means nothing to choose.
in property <[string]> curve-channels;
in property <int> curve-channel;
callback param-changed(int, int, float);
callback param-reset(int, int);
callback curve-reset(int);
callback curve-channel-picked(int);
callback reset-all();
// --- copying settings between photographs (FR-DEV-6) ---
//
// The clipboard is a window-lifetime thing rather than a view's, which is
// why it lives here and not inside the develop column: a copy is taken in
// develop and may be pasted onto a selection back in the library grid.
/// Whether anything has been copied this session.
in property <bool> settings-armed: false;
/// What a paste would apply, at the current scope — "3 adjustments".
in property <string> settings-summary;
/// Whether the copy holds framing that the current setting is dropping.
in property <bool> settings-framing-withheld: false;
callback copy-settings();
callback paste-settings();
// why the grid's half of it lives here and not inside the develop column:
// a copy is taken in develop and may be pasted onto a selection back in
// the library grid. The develop half is `Transfer`, in `adjust.slint`.
/// Apply the clipboard to every selected image in the grid.
callback paste-settings-to-selection();
@@ -853,10 +687,6 @@ export component AppWindow inherits Window {
callback rename-preset(string, string);
callback delete-preset(string);
callback preset-name-edited(string);
/// Raised as the sheet opens over an open photograph, so Rust can say what
/// saving would capture. A property refreshed on every slider drag would
/// be recomputing a string nobody is looking at.
callback presets-opened();
/// TRACES: FR-DEV-6
/// Which kinds of edit a copy, paste or preset carries.
@@ -1064,16 +894,6 @@ export component AppWindow inherits Window {
/// opaque map hides the thing being judged.
in property <float> overlay-strength: 0.55;
in property <bool> overlay-on: false;
/// TRACES: FR-DEV-3
/// The photographer's switch for the same overlay, kept separate from
/// `overlay-on` because the two answer different questions: `overlay-on`
/// is "is there a mask to draw", which Rust decides, and this is "do I
/// want to look at it right now", which only they can.
///
/// Folding them into one property would mean the next recompute — any
/// mask edit — silently switched the overlay back on under someone who had
/// just turned it off to check their work against the photograph.
in-out property <bool> overlay-hidden: false;
/// Which part of the source-space overlay the view is showing, in overlay
/// pixels. Without it the overlay stays frame-sized while the photograph
/// moves under it.
@@ -1090,7 +910,7 @@ export component AppWindow inherits Window {
/// which meant local masking could be open with picking off — a mode
/// entered and doing nothing. Arming a click is part of what local mode
/// *is*, so it follows the mode and the existence of something to pick.
property <bool> region-picking: root.local-mode && root.segmented;
property <bool> region-picking: Develop.local-mode && Masking.segmented;
/// Whether shift is down, tracked by the develop key scope below.
///
@@ -1099,16 +919,6 @@ export component AppWindow inherits Window {
/// nothing outside it has any business setting it.
property <bool> shift-held: false;
in property <[MaskRow]> mask-rows;
in property <[SubjectRow]> subject-rows;
in property <[CategoryRow]> category-rows;
in property <bool> segmented: false;
in property <bool> segmenting: false;
in property <bool> refining: false;
/// The selected layer's name, already resolved, or "ADJUST" when the
/// controls are global. Names the adjust panel's heading.
in property <string> adjust-scope: "ADJUST";
/// The selected gradient's handles, in fractions of the shown image.
/// Empty unless a gradient layer is selected in local mode.
in property <[GradientHandle]> gradient-handles;
@@ -1146,50 +956,9 @@ export component AppWindow inherits Window {
callback spot-handle-released();
/// A repair chosen, so the column describes it.
callback spot-selected(string);
/// Take the selected repair off the photograph.
callback spot-removed(string);
/// Which repair the column is describing, or "" for none.
in-out property <string> selected-spot: "";
/// The selected repair's settings, and how many repairs there are.
///
/// Pushed as scalars rather than read off `spot-handles`, because the
/// circles carry what is *drawn* — a radius already mapped through the
/// framing into fractions of the shown image — and the panel edits what is
/// *stored*. Deriving one from the other would mean a slider that moved
/// differently at different zoom levels.
in property <int> spot-count: 0;
in property <float> spot-radius: 0.012;
in property <float> spot-feather: 0.35;
in property <float> spot-opacity: 1.0;
/// 0 heal, 1 clone.
in property <int> spot-mode: 0;
callback spot-radius-changed(float);
callback spot-feather-changed(float);
callback spot-opacity-changed(float);
callback spot-mode-picked(int);
callback segment-image(bool);
/// A click on the photograph, in fractions of the shown image, plus
/// whether it should extend the selection rather than replace it.
callback region-picked(float, float, bool);
callback mask-selected(string, bool);
callback mask-removed(string);
callback mask-refined(string);
callback mask-toggled(string, bool);
callback mask-invert-toggled(string, bool);
callback mask-opacity-changed(string, float);
callback mask-feather-changed(string, float);
callback mask-refine-changed(string, float);
callback mask-falloff-picked(string, int);
callback mask-morphology-picked(string, int);
callback mask-morph-radius-changed(string, float);
callback mask-band-changed(string, float, float, float);
callback mask-hue-changed(string, float, float);
callback add-gradient-mask(bool);
callback add-range-mask(bool);
callback add-subject-mask(int);
callback add-category-mask(string);
/// Whether the develop column — image info, geometry, adjust — is shown.
///
@@ -1682,7 +1451,7 @@ in property <bool> panel-visible: true;
offset: root.library-offset;
selected-count: root.library-selected-count;
scope-label: root.collection-scope-label;
settings-armed: root.settings-armed;
settings-armed: Transfer.armed;
paste-settings-to-selection => {
root.paste-settings-to-selection();
}
@@ -1832,15 +1601,15 @@ in property <bool> panel-visible: true;
export-label: root.export-label;
export-busy: root.export-busy;
export-status: root.export-status;
can-undo: root.can-undo;
can-redo: root.can-redo;
can-undo: Steps.can-undo;
can-redo: Steps.can-redo;
back-to-library() => { root.back-to-library(); }
open-people() => { root.identity-open(); }
open-settings() => { root.settings-open(); }
toggle-panel() => { root.toggle-panel(); }
export-image() => { root.export-image(); }
undo() => { root.undo(); }
redo() => { root.redo(); }
undo() => { Steps.undo(); }
redo() => { Steps.redo(); }
}
HorizontalLayout {
@@ -1865,7 +1634,7 @@ in property <bool> panel-visible: true;
// (see the develop column below).
ToolRail {
enabled: root.total > 0 && root.load-error == "";
mode: root.view-mode;
mode: Develop.view-mode;
picked(m) => { root.mode-picked(m); }
// The same model and the same callback the strip below
@@ -1874,10 +1643,10 @@ in property <bool> panel-visible: true;
// told which control was pressed and has no reason to
// care — the choice between them is a presentation
// decision, and it stays one.
groups-in-rail: root.groups-in-rail;
tabs: root.adjust-tabs;
active-tab: root.adjust-active-tab;
group-picked(i) => { root.adjust-tab-picked(i); }
groups-in-rail: Adjustments.groups-in-rail;
tabs: Adjustments.tabs;
active-tab: Adjustments.active-tab;
group-picked(i) => { Adjustments.tab-picked(i); }
}
// The canvas: compute output composited directly. No CPU
@@ -1919,7 +1688,7 @@ in property <bool> panel-visible: true;
// it is a diagnostic and not an edit: it must not reach
// the histogram, an export, or the texture the develop
// pass hands the compositor.
if root.overlay-on && !root.overlay-hidden && root.total > 0: Image {
if root.overlay-on && !Masking.overlay-hidden && root.total > 0: Image {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
@@ -2016,7 +1785,7 @@ in property <bool> panel-visible: true;
x: 0; y: 0;
width: 100%;
height: 100%;
enabled: !root.cropping;
enabled: !Develop.cropping;
property <float> last-scale: 1.0;
@@ -2047,7 +1816,7 @@ in property <bool> panel-visible: true;
width: parent.shown-w;
height: parent.shown-h;
mouse-cursor: MouseCursor.crosshair;
enabled: !root.cropping;
enabled: !Develop.cropping;
clicked => {
root.region-picked(
@@ -2068,13 +1837,13 @@ in property <bool> panel-visible: true;
// Below the circles declared further down, so a press that
// lands on an existing repair takes hold of it instead of
// making another one on top.
if root.repairing && root.total > 0 && root.load-error == "": TouchArea {
if Develop.repairing && root.total > 0 && root.load-error == "": TouchArea {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
height: parent.shown-h;
mouse-cursor: MouseCursor.crosshair;
enabled: !root.cropping;
enabled: !Develop.cropping;
clicked => {
root.spot-placed(
@@ -2105,7 +1874,7 @@ in property <bool> panel-visible: true;
width: parent.shown-w;
height: parent.shown-h;
mouse-cursor: MouseCursor.crosshair;
enabled: !root.cropping;
enabled: !Develop.cropping;
clicked => {
root.neutral-picked(
@@ -2117,7 +1886,7 @@ in property <bool> panel-visible: true;
// pan — a second sample, and the photographer
// would have to remember to put a tool away that
// has already done its job.
root.sampling-op = -1;
Adjustments.sampling-op = -1;
}
}
@@ -2128,7 +1897,7 @@ in property <bool> panel-visible: true;
// A pan is only meaningful once there is something outside
// the viewport to reach.
mouse-cursor: root.zoomed ? MouseCursor.grab : MouseCursor.default;
enabled: !root.cropping;
enabled: !Develop.cropping;
property <length> last-x;
property <length> last-y;
@@ -2205,7 +1974,7 @@ in property <bool> panel-visible: true;
//
// Placed over the fitted image, so the fractions it draws are
// fractions of the picture. See `CropOverlay` in crop.slint.
if root.cropping && root.total > 0 && root.load-error == "": CropOverlay {
if Develop.cropping && root.total > 0 && root.load-error == "": CropOverlay {
x: canvas-area.shown-x;
y: canvas-area.shown-y;
width: canvas-area.shown-w;
@@ -2335,9 +2104,9 @@ in property <bool> panel-visible: true;
if (event.modifiers.control
&& (event.text == "z" || event.text == "Z")) {
if (event.modifiers.shift) {
root.redo();
Steps.redo();
} else {
root.undo();
Steps.undo();
}
return accept;
}
@@ -2347,10 +2116,10 @@ in property <bool> panel-visible: true;
// photographs, and a key that means two things
// depending on a mode the user has forgotten they
// are in is how work disappears.
if (root.repairing && root.selected-spot != ""
if (Develop.repairing && Repair.selected != ""
&& (event.text == Key.Delete
|| event.text == Key.Backspace)) {
root.spot-removed(root.selected-spot);
Repair.removed(Repair.selected);
return accept;
}
// GESTURE: Copy the settings from this photograph
@@ -2385,12 +2154,12 @@ in property <bool> panel-visible: true;
// clear, it is a keystroke that should do nothing.
if (event.modifiers.control
&& (event.text == "c" || event.text == "C")) {
root.copy-settings();
Transfer.copy();
return accept;
}
if (event.modifiers.control && root.settings-armed
if (event.modifiers.control && Transfer.armed
&& (event.text == "v" || event.text == "V")) {
root.paste-settings();
Transfer.paste();
return accept;
}
// GESTURE: Change which group of adjustments is on screen
@@ -2414,18 +2183,18 @@ in property <bool> panel-visible: true;
// the groups arrives back at the whole set instead
// of being trapped in the last one.
if (event.text == "]") {
root.adjust-tab-picked(
(root.adjust-active-tab + 1
>= root.adjust-tabs.length)
Adjustments.tab-picked(
(Adjustments.active-tab + 1
>= Adjustments.tabs.length)
? -1
: (root.adjust-active-tab + 1));
: (Adjustments.active-tab + 1));
return accept;
}
if (event.text == "[") {
root.adjust-tab-picked(
(root.adjust-active-tab - 1 < -1)
? (root.adjust-tabs.length - 1)
: (root.adjust-active-tab - 1));
Adjustments.tab-picked(
(Adjustments.active-tab - 1 < -1)
? (Adjustments.tabs.length - 1)
: (Adjustments.active-tab - 1));
return accept;
}
// GESTURE: Look at the photograph at 1:1
@@ -2541,7 +2310,7 @@ in property <bool> panel-visible: true;
spacing: 6px;
alignment: start;
if root.view-mode != ViewMode.photo: Button {
if Develop.view-mode != ViewMode.photo: Button {
// Names the mode being left rather than saying
// "Done", which was unambiguous while there was
// one mode and would not be with two.
@@ -2551,9 +2320,9 @@ in property <bool> panel-visible: true;
// exit after one of the mode's controls, when the
// rail beside it named the mode, would be the same
// control answering to two words.
text: root.cropping
text: Develop.cropping
? "Done Composing"
: (root.repairing ? "Done Repairing" : "Done Masking");
: (Develop.repairing ? "Done Repairing" : "Done Masking");
active: true;
clicked => { root.mode-picked(ViewMode.photo); }
}
@@ -2616,7 +2385,7 @@ in property <bool> panel-visible: true;
// same thing on both edges; see the focus scope below.
Button {
text: "Before";
enabled: root.adjust-enabled;
enabled: Develop.enabled;
active: root.showing-original;
accessible-checkable: true;
accessible-checked: root.showing-original;
@@ -2732,18 +2501,12 @@ in property <bool> panel-visible: true;
// would both sit at its origin and overlap. The strip is pinned by
// being outside the Flickable rather than by any coordinate.
VerticalLayout {
GroupStrip {
// Stands down when the rail is carrying the
// groups. `enabled` rather than an `if`, because
// the strip already collapses itself to zero
// height on it — and a conditional child in this
// layout is the shape that has caused binding
// loops in this file before.
enabled: root.adjust-enabled && !root.groups-in-rail;
tabs: root.adjust-tabs;
active-tab: root.adjust-active-tab;
picked(i) => { root.adjust-tab-picked(i); }
}
// Stands down when the rail is carrying the groups, and
// collapses itself to zero height rather than being
// conditioned here — a conditional child in this layout is
// the shape that has caused binding loops in this file
// before. `GroupStrip.live` holds the rule.
GroupStrip { }
Flickable {
viewport-height: column.preferred-height;
@@ -2816,14 +2579,9 @@ in property <bool> panel-visible: true;
// screen. `masks.slint` carries the same note for
// the same reason.
column := VerticalLayout {
if !root.local-mode && !root.repairing: InfoPanel {
camera: root.camera;
lens: root.lens;
exposure: root.exposure;
dimensions: root.dimensions;
}
if !Develop.local-mode && !Develop.repairing: InfoPanel { }
if !root.local-mode && !root.repairing: Rectangle {
if !Develop.local-mode && !Develop.repairing: Rectangle {
height: 1px;
background: Theme.rule;
}
@@ -2840,10 +2598,7 @@ in property <bool> panel-visible: true;
// disagreement is real and is N3's to close; taking
// the instrument away instead would be a worse
// answer than an honest one that is not yet scoped.
HistogramPanel {
data: root.histogram;
raw-data: root.raw-histogram;
}
HistogramPanel { }
Rectangle {
height: 1px;
@@ -2855,15 +2610,7 @@ in property <bool> panel-visible: true;
// kind of thing: instruments that report on the
// photograph rather than change it. Kept in every
// mode for the same reason the histogram is.
FocusPanel {
available: root.peaking-available;
showing: root.peaking-on;
sensitivity: root.peaking-sensitivity;
colour: root.peaking-colour;
toggled(v) => { root.peaking-toggled(v); }
sensitivity-picked(i) => { root.peaking-sensitivity-picked(i); }
colour-picked(i) => { root.peaking-colour-picked(i); }
}
FocusPanel { }
Rectangle {
height: 1px;
@@ -2874,39 +2621,9 @@ in property <bool> panel-visible: true;
// made rather than how it is applied: the frame is decided
// by eye first and the pipeline runs it last (see
// `dr_pipeline::framing` on the coordinate order).
if !root.local-mode && !root.repairing: ComposePanel {
enabled: root.adjust-enabled;
angle: root.straighten;
max-straighten: root.max-straighten;
flip-h: root.flip-h;
flip-v: root.flip-v;
modified: root.framing-modified;
if !Develop.local-mode && !Develop.repairing: ComposePanel { }
// The ratio chips belong to the crop tool, so
// they appear with it. Shown at all times they
// would be a control for a gesture that is not
// on screen; hidden behind a lid they would be
// a feature nobody finds.
cropping: root.cropping;
aspects: root.crop-aspects;
aspect: root.crop-aspect;
portrait: root.crop-portrait;
aspect-turnable: root.crop-aspect-turnable;
aspect-picked(i) => { root.crop-aspect-picked(i); }
portrait-toggled => { root.crop-portrait-toggled(); }
rotate(turns) => { root.rotate-quarters(turns); }
flip-h-toggled => { root.flip-h-toggled(); }
flip-v-toggled => { root.flip-v-toggled(); }
angle-changed(v) => { root.straighten-changed(v); }
angle-committed(v) => {
root.straighten-committed(v);
}
angle-reset => { root.straighten-changed(0); }
reset => { root.framing-reset(); }
}
if !root.local-mode && !root.repairing: Rectangle {
if !Develop.local-mode && !Develop.repairing: Rectangle {
height: 1px;
background: Theme.rule;
}
@@ -2916,23 +2633,7 @@ in property <bool> panel-visible: true;
// burying it under thirty sliders would put the one
// control that operates on all of them below all of
// them.
if !root.local-mode && !root.repairing: TransferPanel {
enabled: root.adjust-enabled;
armed: root.settings-armed;
summary: root.settings-summary;
framing-withheld: root.settings-framing-withheld;
copy => { root.copy-settings(); }
paste => { root.paste-settings(); }
// Opened with no count, which is what tells
// the apply handler this means the open
// photograph rather than a selection.
open-presets => {
root.preset-apply-count = 0;
root.preset-can-save = root.adjust-enabled;
root.presets-opened();
root.presets-open = true;
}
}
if !Develop.local-mode && !Develop.repairing: TransferPanel { }
Rectangle {
height: 1px;
@@ -2953,126 +2654,21 @@ in property <bool> panel-visible: true;
// terms as the mask panel: not a panel among peers
// that quietly re-points the sliders below it, but
// the whole of what this mode has to say.
if root.repairing: SpotPanel {
enabled: root.adjust-enabled;
has-selection: root.selected-spot != "";
count: root.spot-count;
radius: root.spot-radius;
feather: root.spot-feather;
spot-opacity: root.spot-opacity;
mode: root.spot-mode;
if Develop.repairing: SpotPanel { }
radius-changed(v) => { root.spot-radius-changed(v); }
feather-changed(v) => { root.spot-feather-changed(v); }
opacity-changed(v) => { root.spot-opacity-changed(v); }
mode-picked(i) => { root.spot-mode-picked(i); }
removed => { root.spot-removed(root.selected-spot); }
}
if root.repairing: Rectangle {
if Develop.repairing: Rectangle {
height: 1px;
background: Theme.rule;
}
if root.local-mode: MaskPanel {
enabled: root.adjust-enabled;
masks: root.mask-rows;
subjects: root.subject-rows;
categories: root.category-rows;
segmented: root.segmented;
segmenting: root.segmenting;
refining: root.refining;
// Two-way: the panel is where it is toggled and
// the canvas is what obeys it, so neither owns
// the value alone.
overlay-hidden <=> root.overlay-hidden;
if Develop.local-mode: MaskPanel { }
segment(fine) => { root.segment-image(fine); }
mask-selected(id, extend) => { root.mask-selected(id, extend); }
mask-removed(id) => { root.mask-removed(id); }
mask-refined(id) => { root.mask-refined(id); }
mask-toggled(id, on) => { root.mask-toggled(id, on); }
mask-invert-toggled(id, on) => {
root.mask-invert-toggled(id, on);
}
mask-opacity-changed(id, v) => {
root.mask-opacity-changed(id, v);
}
mask-refine-changed(id, v) => {
root.mask-refine-changed(id, v);
}
mask-feather-changed(id, v) => {
root.mask-feather-changed(id, v);
}
mask-falloff-picked(id, i) => {
root.mask-falloff-picked(id, i);
}
mask-morphology-picked(id, i) => {
root.mask-morphology-picked(id, i);
}
mask-morph-radius-changed(id, v) => {
root.mask-morph-radius-changed(id, v);
}
mask-band-changed(id, lo, hi, soft) => {
root.mask-band-changed(id, lo, hi, soft);
}
mask-hue-changed(id, h, w) => {
root.mask-hue-changed(id, h, w);
}
add-gradient(radial) => { root.add-gradient-mask(radial); }
add-range(chromatic) => { root.add-range-mask(chromatic); }
add-subject(i) => { root.add-subject-mask(i); }
add-category(n) => { root.add-category-mask(n); }
}
if root.local-mode: Rectangle {
if Develop.local-mode: Rectangle {
height: 1px;
background: Theme.rule;
}
adjust := AdjustPanel {
rows: root.adjust-rows;
enabled: root.adjust-enabled;
scope: root.adjust-scope;
curve-samples: root.curve-samples;
curve-channels: root.curve-channels;
curve-channel: root.curve-channel;
curve-channel-picked(i) => {
root.curve-channel-picked(i);
}
param-changed(op, param, value) => {
root.param-changed(op, param, value);
}
param-reset(op, param) => {
root.param-reset(op, param);
}
curve-reset(op) => { root.curve-reset(op); }
// A section's reset and a curve's reset are the same
// action — every parameter of one operation back to
// its default — so they share the one callback rather
// than duplicating a handler that would have to be
// kept in step with it.
op-reset(op) => { root.curve-reset(op); }
reset-all => { root.reset-all(); }
// TRACES: FR-DEV-3
// Arming is a toggle and it is resolved here,
// not in Rust: it changes what a click on the
// photograph does and nothing about the edit,
// so there is nothing for the session to hear
// about until a point is actually picked.
sampling-op: root.sampling-op;
sampler-armed(op) => {
root.sampling-op =
root.sampling-op == op ? -1 : op;
}
// TRACES: FR-DEV-3f
film-stocks: root.film-stocks;
film-selected: root.film-selected;
film-can-print: root.film-can-print;
film-print: root.film-print;
film-picked(i) => { root.film-picked(i); }
film-print-toggled(on) => { root.film-print-toggled(on); }
}
adjust := AdjustPanel { }
Rectangle {
height: 1px;
@@ -3092,16 +2688,7 @@ in property <bool> panel-visible: true;
// and a list that emptied when the photographer
// entered the mode that produces the most steps
// would be a list they stopped trusting.
HistoryPanel {
rows: root.history-rows;
enabled: root.adjust-enabled;
can-undo: root.can-undo;
can-redo: root.can-redo;
undo-label: root.undo-label;
undo => { root.undo(); }
redo => { root.redo(); }
picked(i) => { root.history-picked(i); }
}
HistoryPanel { }
}
}
}
+26 -14
View File
@@ -208,14 +208,17 @@ export component StatusBar inherits Rectangle {
}
}
// Capture metadata. Read-only; the adjustment controls live in AdjustPanel,
// which is generated from pipeline capabilities rather than written here.
//
// Flat, like everything else in this column. This was briefly a collapsible
// section so that every panel could be put away; the sidebar as a whole now
// closes from the status strip instead, which is the control that was actually
// wanted, and the per-group lids only stood between the user and the controls.
export component InfoPanel inherits Rectangle {
/// What the file says about how the photograph was taken.
///
/// A global rather than four properties on the panel, for the reason
/// `session.slint` gives at length: the panel is drawn twice — once in the
/// develop column and once in the portrait dock — and four lines of forwarding
/// copied into the second composition is four lines that can fall behind the
/// first without anything failing.
///
/// Nothing here is a control. It is the one panel in the column that only
/// reports, which is why this global carries no callback at all.
export global Capture {
in property <string> camera;
/// TRACES: FR-DEV-3
/// The lens, and whether it matched a correction profile.
@@ -230,7 +233,16 @@ export component InfoPanel inherits Rectangle {
in property <string> lens;
in property <string> exposure;
in property <string> dimensions;
}
// Capture metadata. Read-only; the adjustment controls live in AdjustPanel,
// which is generated from pipeline capabilities rather than written here.
//
// Flat, like everything else in this column. This was briefly a collapsible
// section so that every panel could be put away; the sidebar as a whole now
// closes from the status strip instead, which is the control that was actually
// wanted, and the per-group lids only stood between the user and the controls.
export component InfoPanel inherits Rectangle {
background: transparent;
height: panel.preferred-height;
@@ -243,23 +255,23 @@ export component InfoPanel inherits Rectangle {
PanelHeading { text: "IMAGE"; }
Value {
text: root.camera == "" ? "—" : root.camera;
placeholder: root.camera == "";
text: Capture.camera == "" ? "—" : Capture.camera;
placeholder: Capture.camera == "";
wrap: word-wrap;
}
Label { text: root.exposure; }
Label { text: Capture.exposure; }
// Dim, like the dimensions below it: this is something the file says,
// not something the photographer chose. An empty string draws nothing
// — a session built from pixels with no header has no lens to report,
// and an empty row is honest where "Unknown" would be noise.
Caption {
text: root.lens;
visible: root.lens != "";
text: Capture.lens;
visible: Capture.lens != "";
wrap: word-wrap;
}
Caption { text: root.dimensions; }
Caption { text: Capture.dimensions; }
}
}
+14 -3
View File
@@ -165,13 +165,24 @@ component ClipReadout inherits HorizontalLayout {
Caption { text: root.figure; warn: root.lit; }
}
// TRACES: FR-DSP-7
export component HistogramPanel inherits Rectangle {
/// The two readings the histogram can draw.
///
/// A global rather than two properties on the panel: the panel is drawn twice
/// — see `session.slint` — and both compositions want the same measurement,
/// because there is only one photograph being measured.
///
/// Only the readings live here. *Which* of them is on the plot does not: that
/// is a property of the plot, held by the panel, and two plots of the same
/// photograph may honestly be showing different ones.
export global Levels {
/// The frame the display is about to show (FR-DSP-7).
in property <HistogramView> data;
/// The sensor data the file holds (FR-CULL-3).
in property <HistogramView> raw-data;
}
// TRACES: FR-DSP-7
export component HistogramPanel inherits Rectangle {
/// Which of the two is on the plot. 0 is the display reading.
///
/// **Held here rather than pushed from Rust**, and for the reason
@@ -186,7 +197,7 @@ export component HistogramPanel inherits Rectangle {
/// The reading currently drawn. Everything below reads this and not the
/// two above it, so there is exactly one place the choice is made.
property <HistogramView> shown: root.mode == 1 ? root.raw-data : root.data;
property <HistogramView> shown: root.mode == 1 ? Levels.raw-data : Levels.data;
// **This panel does not get a say in how wide the column is.**
//
+32 -16
View File
@@ -18,6 +18,7 @@
// rather than decorative.
import { Theme } from "theme.slint";
import { Develop } from "session.slint";
import { PanelHeading, Caption, Label, Value, Button } from "widgets.slint";
// One step, flattened for Slint's model system.
@@ -88,9 +89,22 @@ component StepRow inherits Rectangle {
}
}
export component HistoryPanel inherits Rectangle {
/// TRACES: FR-DEV-5
/// The edit as a stack of steps, and the two ways of moving through it.
///
/// A global rather than five properties and three callbacks on the panel — see
/// `session.slint` for the argument. The status strip and the keyboard reach
/// the same two actions; before this all three spellings went through the
/// window root and had to be kept in step by hand.
///
/// Rust owns whether there is anywhere to step, because "anywhere" is a
/// position in a stack of snapshots this side never sees. A whole drag is one
/// step; the coalescing that makes it so lives in `dr-pipeline`.
export global Steps {
/// Every step, newest first. Rust owns the order and stamps each row with
/// its own position in the stack, so nothing here does arithmetic to turn
/// a row back into a step.
in property <[HistoryRow]> rows;
in property <bool> enabled: true;
in property <bool> can-undo: false;
in property <bool> can-redo: false;
/// What undo would take back, already resolved. Empty when there is
@@ -101,7 +115,9 @@ export component HistoryPanel inherits Rectangle {
callback redo();
/// A row's own `index`, not its position in `rows`.
callback picked(int);
}
export component HistoryPanel inherits Rectangle {
background: Theme.surface;
// **Flat, deliberately** — `MaskPanel` carries the long version of this
@@ -127,32 +143,32 @@ export component HistoryPanel inherits Rectangle {
HorizontalLayout {
PanelHeading { text: "HISTORY"; }
Rectangle { horizontal-stretch: 1; }
if root.enabled && root.rows.length > 1: Value {
text: root.rows.length - 1 + (root.rows.length == 2 ? " step" : " steps");
if Develop.enabled && Steps.rows.length > 1: Value {
text: Steps.rows.length - 1 + (Steps.rows.length == 2 ? " step" : " steps");
}
}
if !root.enabled: Caption { text: "No image"; }
if !Develop.enabled: Caption { text: "No image"; }
// The pair, here as well as in the status strip. The strip's copy is
// the one that survives the column being put away; this one sits
// against the list that says what it will do, which is the pairing
// that makes either of them legible.
if root.enabled: HorizontalLayout {
if Develop.enabled: HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "Undo";
enabled: root.can-undo;
enabled: Steps.can-undo;
horizontal-stretch: 1;
clicked => { root.undo(); }
clicked => { Steps.undo(); }
}
Button {
text: "Redo";
enabled: root.can-redo;
enabled: Steps.can-redo;
horizontal-stretch: 1;
clicked => { root.redo(); }
clicked => { Steps.redo(); }
}
}
@@ -168,12 +184,12 @@ export component HistoryPanel inherits Rectangle {
// the one the photographer is standing on and stopped tracking after a
// run of small adjustments, which is the moment they are least willing
// to press a button to find out.
if root.enabled && root.can-undo: Caption {
text: "Undo: " + root.undo-label;
if Develop.enabled && Steps.can-undo: Caption {
text: "Undo: " + Steps.undo-label;
overflow: elide;
}
if root.enabled && root.rows.length > 0: Rectangle {
if Develop.enabled && Steps.rows.length > 0: Rectangle {
height: 1px;
background: Theme.rule;
}
@@ -183,10 +199,10 @@ export component HistoryPanel inherits Rectangle {
// `dr_catalog::trash` settled on for the same question — and it keeps
// the end being worked at against the heading rather than sixty-four
// rows below it.
for row in root.rows: StepRow {
for row in Steps.rows: StepRow {
data: row;
enabled: root.enabled;
picked => { root.picked(row.index); }
enabled: Develop.enabled;
picked => { Steps.picked(row.index); }
}
}
}
+78 -56
View File
@@ -7,6 +7,7 @@
// added to `ops/` would need a local twin.
import { Theme } from "theme.slint";
import { Develop } from "session.slint";
import { Button, Caption, IconButton, Label, PanelHeading, Value } from "widgets.slint";
import { Segmented, SliderRow } from "controls.slint";
@@ -429,8 +430,22 @@ component MaskEntry inherits Rectangle {
}
}
export component MaskPanel inherits Rectangle {
in property <bool> enabled: true;
/// TRACES: FR-DEV-3
/// The mask stack, what the model found, and everything that can be done to a
/// layer.
///
/// A global rather than eight properties and eighteen callbacks on the panel.
/// `session.slint` gives the general argument; this panel is the case that
/// made it, because its instantiation in `app.slint` ran to forty lines of
/// forwarding — the longest in the file, and every line of it saying the same
/// thing twice.
///
/// The overlay is the other half of the reason. `overlay-hidden` was a
/// two-way binding between the panel and the window because the panel is where
/// it is toggled and the canvas is what obeys it; neither owned it, so it was
/// declared twice and tied together at the instantiation. Here it is declared
/// once and both read it, which is what a two-way binding was standing in for.
export global Masking {
in property <[MaskRow]> masks;
in property <[SubjectRow]> subjects;
in property <[CategoryRow]> categories;
@@ -442,12 +457,17 @@ export component MaskPanel inherits Rectangle {
/// A refine pass is running for one subject layer.
in property <bool> refining: false;
/// Whether the photographer has hidden the overlay to see the photograph.
/// Two-way bound to the window's own, so the canvas and this control never
/// disagree about it.
/// TRACES: FR-DEV-3
/// The photographer's switch for the region overlay, kept separate from
/// the window's `overlay-on` because the two answer different questions:
/// `overlay-on` is "is there a mask to draw", which Rust decides, and this
/// is "do I want to look at it right now", which only they can.
///
/// Folding them into one property would mean the next recompute — any
/// mask edit — silently switched the overlay back on under someone who had
/// just turned it off to check their work against the photograph.
in-out property <bool> overlay-hidden: false;
/// `true` asks for the slower, tiled pass.
callback segment(bool);
@@ -472,7 +492,9 @@ export component MaskPanel inherits Rectangle {
callback add-range(bool);
callback add-subject(int);
callback add-category(string);
}
export component MaskPanel inherits Rectangle {
background: Theme.surface;
// **Flat, deliberately.** An earlier version grouped each state into its
@@ -490,18 +512,18 @@ export component MaskPanel inherits Rectangle {
HorizontalLayout {
PanelHeading { text: "MASKS"; }
Rectangle { horizontal-stretch: 1; }
if root.segmented: Value { text: root.subjects.length + (root.subjects.length == 1 ? " subject" : " subjects");
if Masking.segmented: Value { text: Masking.subjects.length + (Masking.subjects.length == 1 ? " subject" : " subjects");
}
}
if !root.enabled: Caption { text: "No image"; }
if !Develop.enabled: Caption { text: "No image"; }
// --- the region map ---------------------------------------------
//
// Segmentation is a thing the user starts. Half a second of watershed
// and inference is too long to spend on every photograph opened, and
// most edits never need one.
if root.enabled && !root.segmented: Caption {
if Develop.enabled && !Masking.segmented: Caption {
text: "Find the subjects in this photograph, so a mask can follow one.";
// Wrapped, or this sentence's single-line width becomes the
// panel's minimum and levers the fixed 280px develop column open,
@@ -509,11 +531,11 @@ export component MaskPanel inherits Rectangle {
wrap: word-wrap;
}
if root.enabled && !root.segmented: Button {
text: root.segmenting ? "Looking…" : "Find subjects";
enabled: !root.segmenting;
if Develop.enabled && !Masking.segmented: Button {
text: Masking.segmenting ? "Looking…" : "Find subjects";
enabled: !Masking.segmenting;
primary: true;
clicked => { root.segment(false); }
clicked => { Masking.segment(false); }
}
// No "Overlay" button and no "Select" button. Both switched on things
@@ -521,7 +543,7 @@ export component MaskPanel inherits Rectangle {
// region map and makes a click on the photograph mean "select". A mode
// whose behaviour has to be armed separately is a mode that can be
// entered and still do nothing, which is what these two allowed.
if root.enabled && root.segmented: Caption {
if Develop.enabled && Masking.segmented: Caption {
text: "Click a subject in the photograph to mask it.";
wrap: word-wrap;
}
@@ -537,9 +559,9 @@ export component MaskPanel inherits Rectangle {
//
// Reads as its own action rather than its state: "Show the mask" is
// what pressing it will do, not what is currently true.
if root.enabled && root.segmented: Button {
text: root.overlay-hidden ? "Show the mask" : "Hide the mask";
clicked => { root.overlay-hidden = !root.overlay-hidden; }
if Develop.enabled && Masking.segmented: Button {
text: Masking.overlay-hidden ? "Show the mask" : "Hide the mask";
clicked => { Masking.overlay-hidden = !Masking.overlay-hidden; }
}
// The second pass, offered rather than taken automatically.
@@ -556,17 +578,17 @@ export component MaskPanel inherits Rectangle {
// photograph, where the subject fills much of the frame and the first
// pass is already the best answer available — so it is a button and
// not a default, and it says what it costs.
if root.enabled && root.segmented: Button {
text: root.segmenting ? "Looking closer…" : "Look closer (slower)";
enabled: !root.segmenting;
clicked => { root.segment(true); }
if Develop.enabled && Masking.segmented: Button {
text: Masking.segmenting ? "Looking closer…" : "Look closer (slower)";
enabled: !Masking.segmenting;
clicked => { Masking.segment(true); }
}
// Granularity, labelled by what it does rather than by its number:
// "detail" is what a photographer is choosing between, where "300
// regions" is an implementation detail they would have to learn.
// --- what the model found ----------------------------------------
if root.enabled && root.segmented && root.subjects.length > 0: Caption {
if Develop.enabled && Masking.segmented && Masking.subjects.length > 0: Caption {
// Says where these came from and how they differ from a region.
// A list of four beside a count of three thousand invites exactly
// one question, and the panel should answer it rather than
@@ -575,12 +597,12 @@ export component MaskPanel inherits Rectangle {
wrap: word-wrap;
}
if root.enabled && root.segmented: VerticalLayout {
if Develop.enabled && Masking.segmented: VerticalLayout {
spacing: 0px;
for subject in root.subjects: subject-row := TouchArea {
for subject in Masking.subjects: subject-row := TouchArea {
height: Theme.touch-target;
mouse-cursor: pointer;
clicked => { root.add-subject(subject.index); }
clicked => { Masking.add-subject(subject.index); }
HorizontalLayout {
spacing: Theme.gap-sm;
@@ -626,17 +648,17 @@ export component MaskPanel inherits Rectangle {
// clicking the photograph is how a local adjustment usually starts, so
// the things that were *found* come first. Categories are the move you
// reach for deliberately — grade the sky, not this one bird.
if root.enabled && root.segmented && root.categories.length > 0: Caption {
if Develop.enabled && Masking.segmented && Masking.categories.length > 0: Caption {
text: "Or grade a whole category. One adjustment covers every pixel of it.";
wrap: word-wrap;
}
if root.enabled && root.segmented: VerticalLayout {
if Develop.enabled && Masking.segmented: VerticalLayout {
spacing: 0px;
for category in root.categories: category-row := TouchArea {
for category in Masking.categories: category-row := TouchArea {
height: Theme.touch-target;
mouse-cursor: pointer;
clicked => { root.add-category(category.name); }
clicked => { Masking.add-category(category.name); }
HorizontalLayout {
spacing: Theme.gap-sm;
@@ -669,7 +691,7 @@ export component MaskPanel inherits Rectangle {
}
}
if root.enabled && root.segmented && root.subjects.length == 0: Caption {
if Develop.enabled && Masking.segmented && Masking.subjects.length == 0: Caption {
text: "Nothing recognised. The model knows people, animals and vehicles — "
+ "a landscape has no subject for it to find. Add a gradient instead.";
wrap: word-wrap;
@@ -691,13 +713,13 @@ export component MaskPanel inherits Rectangle {
Button {
text: "Linear";
enabled: root.enabled;
clicked => { root.add-gradient(false); }
enabled: Develop.enabled;
clicked => { Masking.add-gradient(false); }
}
Button {
text: "Radial";
enabled: root.enabled;
clicked => { root.add-gradient(true); }
enabled: Develop.enabled;
clicked => { Masking.add-gradient(true); }
}
}
@@ -714,44 +736,44 @@ export component MaskPanel inherits Rectangle {
Button {
text: "Tone";
enabled: root.enabled;
clicked => { root.add-range(false); }
enabled: Develop.enabled;
clicked => { Masking.add-range(false); }
}
Button {
text: "Colour";
enabled: root.enabled;
clicked => { root.add-range(true); }
enabled: Develop.enabled;
clicked => { Masking.add-range(true); }
}
}
if root.enabled: Caption {
if Develop.enabled: Caption {
text: "A tone or colour mask follows the photograph's own values, "
+ "so the edit blends where the picture does.";
wrap: word-wrap;
}
if root.enabled && root.masks.length == 0: Caption {
if Develop.enabled && Masking.masks.length == 0: Caption {
text: "No local adjustments. Select a region, or add a gradient.";
wrap: word-wrap;
}
for mask in root.masks: MaskEntry {
for mask in Masking.masks: MaskEntry {
data: mask;
enabled: root.enabled;
refining: root.refining;
selected(extend) => { root.mask-selected(mask.id, extend); }
removed => { root.mask-removed(mask.id); }
refined => { root.mask-refined(mask.id); }
toggled(on) => { root.mask-toggled(mask.id, on); }
invert-toggled(on) => { root.mask-invert-toggled(mask.id, on); }
opacity-changed(v) => { root.mask-opacity-changed(mask.id, v); }
feather-changed(v) => { root.mask-feather-changed(mask.id, v); }
refine-changed(v) => { root.mask-refine-changed(mask.id, v); }
falloff-picked(i) => { root.mask-falloff-picked(mask.id, i); }
morphology-picked(i) => { root.mask-morphology-picked(mask.id, i); }
morph-radius-changed(v) => { root.mask-morph-radius-changed(mask.id, v); }
band-changed(lo, hi, soft) => { root.mask-band-changed(mask.id, lo, hi, soft); }
hue-changed(h, w) => { root.mask-hue-changed(mask.id, h, w); }
enabled: Develop.enabled;
refining: Masking.refining;
selected(extend) => { Masking.mask-selected(mask.id, extend); }
removed => { Masking.mask-removed(mask.id); }
refined => { Masking.mask-refined(mask.id); }
toggled(on) => { Masking.mask-toggled(mask.id, on); }
invert-toggled(on) => { Masking.mask-invert-toggled(mask.id, on); }
opacity-changed(v) => { Masking.mask-opacity-changed(mask.id, v); }
feather-changed(v) => { Masking.mask-feather-changed(mask.id, v); }
refine-changed(v) => { Masking.mask-refine-changed(mask.id, v); }
falloff-picked(i) => { Masking.mask-falloff-picked(mask.id, i); }
morphology-picked(i) => { Masking.mask-morphology-picked(mask.id, i); }
morph-radius-changed(v) => { Masking.mask-morph-radius-changed(mask.id, v); }
band-changed(lo, hi, soft) => { Masking.mask-band-changed(mask.id, lo, hi, soft); }
hue-changed(h, w) => { Masking.mask-hue-changed(mask.id, h, w); }
}
// No caption saying which chain the sliders below are pointed at.
+27 -14
View File
@@ -53,8 +53,18 @@ export component FocusMarks inherits Image {
image-rendering: ImageRendering.pixelated;
}
// TRACES: FR-CULL-3
export component FocusPanel inherits Rectangle {
/// TRACES: FR-CULL-3
/// The focus overlay: whether it can be drawn, whether it is, and how.
///
/// A global rather than four properties and three callbacks on the panel — see
/// `session.slint` for why the develop panels stopped taking their wiring
/// through the window root.
///
/// The overlay is one thing on one photograph, so every drawing of this panel
/// is a view of the same state: turning peaking on in the dock and finding the
/// column's copy still saying "Show focus peaking" would be two controls
/// disagreeing about one fact.
export global Peaking {
/// Whether this device could build the overlay at all.
///
/// A compute pass can fail to compile on a driver nobody here has, and the
@@ -76,7 +86,10 @@ export component FocusPanel inherits Rectangle {
callback toggled(bool);
callback sensitivity-picked(int);
callback colour-picked(int);
}
// TRACES: FR-CULL-3
export component FocusPanel inherits Rectangle {
background: Theme.surface;
// Both chip rows wrap at three rather than running four abreast. That was
@@ -96,39 +109,39 @@ export component FocusPanel inherits Rectangle {
PanelHeading { text: "FOCUS"; }
if !root.available: Caption {
if !Peaking.available: Caption {
text: "This device could not build the overlay.";
wrap: word-wrap;
}
if root.available: Button {
if Peaking.available: Button {
// The label states the action rather than the state, as the mask
// overlay's does: a photographer reads a button for what pressing
// it will do.
text: root.showing ? "Hide focus peaking" : "Show focus peaking";
active: root.showing;
clicked => { root.toggled(!root.showing); }
text: Peaking.showing ? "Hide focus peaking" : "Show focus peaking";
active: Peaking.showing;
clicked => { Peaking.toggled(!Peaking.showing); }
}
if root.available && root.showing: Segmented {
if Peaking.available && Peaking.showing: Segmented {
label: "Sensitivity";
hint: "lower on a noisy frame";
options: ["Low", "Medium", "High"];
selected: root.sensitivity;
selected: Peaking.sensitivity;
columns: 3;
picked(i) => { root.sensitivity-picked(i); }
picked(i) => { Peaking.sensitivity-picked(i); }
}
if root.available && root.showing: Segmented {
if Peaking.available && Peaking.showing: Segmented {
label: "Marks";
hint: "pick what the subject is not";
options: ["Red", "Yellow", "Cyan", "Magenta"];
selected: root.colour;
selected: Peaking.colour;
columns: 3;
picked(i) => { root.colour-picked(i); }
picked(i) => { Peaking.colour-picked(i); }
}
if root.available && root.showing: Caption {
if Peaking.available && Peaking.showing: Caption {
// Said once, here, rather than left to be discovered: the marks go
// away while a control is moving because a half-resolution draft
// frame cannot be measured for sharpness (see `FocusPeakPass`).
+104
View File
@@ -0,0 +1,104 @@
// What the develop view is doing, and whether there is anything to do it to.
//
// **Why the develop panels have globals at all.** Every panel in the develop
// column used to declare its inputs and its callbacks and have `app.slint`
// bind each one to a property or a callback on the window root. That is fine
// while a panel is drawn once. `ui-navigation.md` N9 draws them a second time,
// in the portrait dock, and forty lines of hand-written forwarding copied into
// a second composition is the kind of duplication that drifts silently: a
// callback added to one copy and not the other compiles, renders, and simply
// does nothing on the layout nobody was looking at.
//
// So the wiring moved to globals. A panel reads the global and calls the
// global; Rust hooks the global instead of the window; and a second
// instantiation is the panel's name and a pair of braces.
//
// **Why there is more than one of them.** The obvious shape is a single
// `Develop` global carrying all of it. Slint rejects cyclic imports, and each
// panel's model struct — `ParamRow`, `MaskRow`, `HistogramView` — is declared
// in the panel's own file. One global holding `[MaskRow]` and `[ParamRow]`
// would therefore have to live in a file importing `masks.slint` and
// `adjust.slint`, both of which would import the global back. Breaking that
// needs the six model declarations relocated, which is a change to the data
// model rather than to the plumbing this is about.
//
// A global per panel family instead, each declared in the file of the panel it
// serves: `Adjustments`, `Framing` and `Transfer` in `adjust.slint`, `Masking`
// in `masks.slint`, `Repair` in `spots.slint`, `Peaking` in `peaking.slint`,
// `Levels` in `histogram.slint`, `Capture` in `develop.slint`, `Steps` in
// `history.slint`. That also lets each name drop the prefix it was carrying
// only because the window root is one flat namespace: `root.spot-radius` is
// `Repair.radius`, and `root.peaking-on` is `Peaking.showing`.
//
// This file holds what is left: the two facts every one of those families
// needs and none of them owns. It imports nothing, which is what lets every
// panel file import it.
/// Which mode the develop view is in.
///
/// `crop` was a bare `bool` on the window and local masking was a panel with
/// two toggles, so nothing stopped both being on at once — and nothing on
/// screen said which of them the canvas and the column were obeying. One value
/// with three states cannot be in two of them, which is the whole reason this
/// is an enum rather than a tidier pair of flags.
export enum ViewMode {
/// The whole photograph. Sliders are global, the canvas pans and zooms.
photo,
/// The crop overlay is up and the canvas shows the uncropped frame.
crop,
/// The region map is drawn, a click on the photograph selects, and the
/// column is the mask stack and the selected layer's adjustments.
local,
/// TRACES: FR-DEV-8
/// The repairs are drawn on the photograph, a click places one, and the
/// column describes whichever is selected.
///
/// A mode rather than a panel button for the reason the enum exists at
/// all: arming a click on the canvas is something only one tool may be
/// doing at a time, and two flags could both be true.
spots,
}
/// The develop session, as every panel in the column sees it.
///
/// Two facts, and the three readings of the second one. They are here rather
/// than in any one family's global because all of them need at least one:
/// every panel greys out on `enabled`, and which panels are drawn at all is
/// the mode's answer.
export global Develop {
/// Whether there is an open photograph whose edit can be changed.
///
/// Was `adjust-enabled` on the window root. Every panel in the column
/// takes it, which is why it is not in any of their globals.
in property <bool> enabled: false;
/// TRACES: FR-UI-5
/// What the develop view is currently doing (see `ViewMode`).
///
/// One value rather than a flag per mode, so two modes cannot be on at
/// once — which, with `crop-mode` a bare bool beside a masking panel that
/// armed the canvas through two toggles of its own, they could be. Rust
/// owns it: entering a mode has side effects on the session (crop drops
/// the zoom, local clears nothing and leaving it clears the selection),
/// and a property the interface could set directly would let the view
/// change without them — which is why nothing here assigns it and the
/// rail's pick goes through `mode-picked` on the window instead.
///
/// In `crop` the canvas shows the *whole* frame — otherwise the area being
/// cropped away would not be on screen to drag across — and the surround
/// is greyed.
///
/// `in-out` because Rust reads it back as well as setting it, to know
/// which gesture the canvas is armed for.
in-out property <ViewMode> view-mode: ViewMode.photo;
/// The three readings of the mode, derived once here rather than written
/// out at each of the dozen places in `app.slint` that test one.
///
/// `out`, so nothing outside can set one of them and leave it disagreeing
/// with the mode it is supposed to be a reading of.
out property <bool> cropping: view-mode == ViewMode.crop;
out property <bool> local-mode: view-mode == ViewMode.local;
/// TRACES: FR-DEV-8
out property <bool> repairing: view-mode == ViewMode.spots;
}
+66 -47
View File
@@ -1,4 +1,5 @@
import { Theme } from "theme.slint";
import { Develop } from "session.slint";
import { Button, PanelHeading, Caption, Value } from "widgets.slint";
import { Segmented, SliderRow } from "controls.slint";
@@ -45,6 +46,48 @@ export struct SpotHandle {
enabled: bool,
}
/// TRACES: FR-DEV-8
/// The selected repair, and the controls that describe it.
///
/// A global rather than properties on the panel — `session.slint` gives the
/// argument. Which repair is selected is a fact about the photograph, not
/// about a panel, and the keyboard's Delete acts on the same selection the
/// panel's button does; before this both went through the window root and the
/// two spellings had to be kept in step by hand.
///
/// `opacity` may be spelled plainly here. On the panel it had to be
/// `spot-opacity`, because every *element* already has an `opacity` built in
/// and overriding it would have faded the panel rather than described the
/// repair; a global is not an element and has no such built-in.
export global Repair {
/// Which repair the column is describing, or "" for none.
in-out property <string> selected: "";
/// Whether a repair is selected — everything below is about it.
///
/// Derived rather than pushed, so it cannot fall out of step with the
/// selection it is a reading of.
out property <bool> has-selection: selected != "";
/// How many repairs are on this photograph.
in property <int> count: 0;
/// In frame units, which is what the model stores. The slider shows them
/// as a percentage of the frame's height, since "0.012" means nothing to
/// anybody and "1.2%" at least says how much of the picture is covered.
in property <float> radius: 0.012;
in property <float> feather: 0.35;
in property <float> opacity: 1.0;
/// 0 heal, 1 clone — the order the chips are listed in below.
in property <int> mode: 0;
callback radius-changed(float);
callback feather-changed(float);
callback opacity-changed(float);
callback mode-picked(int);
/// Take the named repair off the photograph. Named rather than implied,
/// because the keyboard reaches this too.
callback removed(string);
}
/// TRACES: FR-DEV-8
/// The column while the repair tool is up.
///
@@ -59,30 +102,6 @@ export struct SpotHandle {
/// completely different: a photographer dragging Size with nothing selected
/// would see no change on the photograph and conclude the slider was broken.
export component SpotPanel inherits Rectangle {
in property <bool> enabled: true;
/// Whether a repair is selected — everything below is about it.
in property <bool> has-selection: false;
/// How many repairs are on this photograph.
in property <int> count: 0;
/// In frame units, which is what the model stores. The slider shows them
/// as a percentage of the frame's height, since "0.012" means nothing to
/// anybody and "1.2%" at least says how much of the picture is covered.
in property <float> radius: 0.012;
in property <float> feather: 0.35;
// `spot-opacity` and not `opacity`, which every element already has as a
// built-in: overriding it is refused by the compiler, and had it been
// allowed it would have faded the panel instead of describing the repair.
in property <float> spot-opacity: 1.0;
/// 0 heal, 1 clone — the order the chips are listed in below.
in property <int> mode: 0;
callback radius-changed(float);
callback feather-changed(float);
callback opacity-changed(float);
callback mode-picked(int);
callback removed();
background: Theme.surface;
// Flat rather than nested, for the reason `MaskPanel` gives: a nested
@@ -96,75 +115,75 @@ export component SpotPanel inherits Rectangle {
HorizontalLayout {
PanelHeading { text: "REPAIR"; }
Rectangle { horizontal-stretch: 1; }
if root.count > 0: Value {
text: root.count + (root.count == 1 ? " spot" : " spots");
if Repair.count > 0: Value {
text: Repair.count + (Repair.count == 1 ? " spot" : " spots");
}
}
if !root.enabled: Caption { text: "No image"; }
if !Develop.enabled: Caption { text: "No image"; }
// The instruction, which is the whole interface until the first click.
// A tool whose canvas gesture is its only way in has to say so, or it
// is a mode that appears to do nothing.
if root.enabled && !root.has-selection: Caption {
text: root.count == 0
if Develop.enabled && !Repair.has-selection: Caption {
text: Repair.count == 0
? "Click a mark on the photograph to cover it."
: "Click a mark to cover it, or a circle to adjust it.";
wrap: word-wrap;
}
if root.enabled && root.has-selection: SliderRow {
if Develop.enabled && Repair.has-selection: SliderRow {
label: "Size";
hint: "% of frame";
value: root.radius * 100;
value: Repair.radius * 100;
default-value: 1.2;
minimum: 0.1;
maximum: 25;
precision: 1;
changed(v) => { root.radius-changed(v / 100); }
reset => { root.radius-changed(0.012); }
changed(v) => { Repair.radius-changed(v / 100); }
reset => { Repair.radius-changed(0.012); }
}
if root.enabled && root.has-selection: SliderRow {
if Develop.enabled && Repair.has-selection: SliderRow {
label: "Feather";
hint: "% of size";
value: root.feather * 100;
value: Repair.feather * 100;
default-value: 35;
minimum: 0;
maximum: 100;
precision: 0;
changed(v) => { root.feather-changed(v / 100); }
reset => { root.feather-changed(0.35); }
changed(v) => { Repair.feather-changed(v / 100); }
reset => { Repair.feather-changed(0.35); }
}
if root.enabled && root.has-selection: SliderRow {
if Develop.enabled && Repair.has-selection: SliderRow {
label: "Opacity";
value: root.spot-opacity * 100;
value: Repair.opacity * 100;
default-value: 100;
minimum: 0;
maximum: 100;
precision: 0;
changed(v) => { root.opacity-changed(v / 100); }
reset => { root.opacity-changed(1.0); }
changed(v) => { Repair.opacity-changed(v / 100); }
reset => { Repair.opacity-changed(1.0); }
}
// Heal first, because it is the default and the right answer for dust.
// Clone is the escape for a repair that straddles an edge, where
// interpolating the boundary smears the edge across the disc.
if root.enabled && root.has-selection: Segmented {
if Develop.enabled && Repair.has-selection: Segmented {
label: "Blend";
options: ["Heal", "Clone"];
selected: root.mode;
picked(i) => { root.mode-picked(i); }
selected: Repair.mode;
picked(i) => { Repair.mode-picked(i); }
}
if root.enabled && root.has-selection: Rectangle {
if Develop.enabled && Repair.has-selection: Rectangle {
height: Theme.gap-sm;
}
if root.enabled && root.has-selection: Button {
if Develop.enabled && Repair.has-selection: Button {
text: "Delete Repair";
clicked => { root.removed(); }
clicked => { Repair.removed(Repair.selected); }
}
}
}
+1 -1
View File
@@ -52,7 +52,7 @@
import { Theme } from "theme.slint";
import { Icon } from "icons.slint";
import { ViewMode } from "adjust.slint";
import { ViewMode } from "session.slint";
// One tool. A struct rather than four parallel arrays so a row cannot be
// half-added — the compiler will not let a new entry omit its icon.