Merge master into partial-preset-scope
🐳 Android image / Build and push (push) Successful in 8s
Build and test / android-image (push) Successful in 8s
Build and test / Desktop (Linux) (push) Failing after 1h17m12s
Build and test / Layer separation (push) Successful in 56s
Traceability / Requirement traces (push) Successful in 1m33s
Build and test / Android (aarch64) (push) Successful in 1h0m38s

# Conflicts:
#	docs/traceability.md
#	ui/dr-ui/ui/app.slint
This commit is contained in:
2026-08-29 22:02:03 +02:00
31 changed files with 2855 additions and 92 deletions
+127
View File
@@ -39,6 +39,7 @@ mod library_ui;
mod live_style;
mod masks_ui;
mod net_runtime;
mod peaking;
mod preset_store;
mod presets;
mod remote;
@@ -317,6 +318,12 @@ fn reset_view_state(window: &AppWindow) {
// 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());
// 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
// rather than a property of one photograph, so it survives to the next
// frame — see `chosen_peaking` for the whole of that argument.
window.set_focus_overlay_ready(false);
// TRACES: FR-DEV-3
// The region map belongs to one photograph. Carrying the stack, the
// overlay or the crosshair to the next one would offer a selection of
@@ -1465,11 +1472,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// is redrawn, and those are very different rates.
let drawn_history: Rc<Cell<Option<u64>>> = Rc::new(Cell::new(None));
// TRACES: FR-CULL-3
// How the photographer wants focus peaking drawn, or `None` for off.
//
// **Held here rather than on the session, which is the opposite of where
// every edit lives.** A session is one photograph; peaking is a way of
// *looking* at a folder of them. Someone culling three thousand frames
// switches it on once, and a flag that reset with the session would ask
// them to switch it on three thousand times — which is why
// `reset_view_state` deliberately leaves it alone while emptying the
// histogram beside it.
let chosen_peaking: Rc<Cell<Option<dr_gpu::FocusPeaking>>> = Rc::new(Cell::new(None));
let render_now: Render = {
let session = session.clone();
let viewport = viewport.clone();
let drawn_history = drawn_history.clone();
let display = display.clone();
let chosen_peaking = chosen_peaking.clone();
Rc::new(move |window: &AppWindow, draft: bool| {
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
@@ -1530,6 +1550,16 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// arrival takes.
spots_ui::sync_panel(window, s);
// TRACES: FR-CULL-3
// The session owns the pass and the interface owns the choice, so
// they are joined here — on the one path every frame takes, which
// is also what makes a photograph opened with peaking already on
// arrive with its marks rather than without them.
if s.peaking() != chosen_peaking.get() {
s.set_peaking(chosen_peaking.get());
}
window.set_peaking_available(s.peaking_available());
let (mut w, mut h) = *viewport.borrow();
// **Half resolution while the gesture is still moving.**
@@ -1592,6 +1622,34 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
.map_or_else(histogram::empty, histogram::view),
);
}
// TRACES: FR-CULL-3 | NFR-P14
// **Marked on the settled frame and no other**, and unlike
// the histogram beside it the marks are taken *down* in
// between rather than left standing.
//
// The reason is not budget — the dispatch is a fraction of
// a millisecond and would fit inside a draft frame
// comfortably. It is that peaking measures the top octave
// of the frame it is given, and a draft frame is rendered
// at half resolution: a defocused edge that spans four
// pixels there spans two, which is the signature of a
// sharp one. Measuring it would mark the out-of-focus
// background of every photograph, briefly, during every
// drag. A stale overlay is no better, because a pan moves
// the picture out from under it.
//
// So the marks pause while a control is moving and return
// when it stops, which the panel says out loud rather than
// leaving to be discovered.
let overlay = (!draft).then(|| s.focus_overlay()).flatten();
match overlay {
Some(image) => {
window.set_focus_overlay(image);
window.set_focus_overlay_ready(true);
}
None => window.set_focus_overlay_ready(false),
}
}
Err(e) => {
log::warn!("render failed: {e}");
@@ -1599,6 +1657,11 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// No frame, so nothing to describe. The stale plot would
// otherwise sit beside the error message looking current.
window.set_histogram(histogram::empty());
// TRACES: FR-CULL-3
// And nothing to mark. Focus marks over the last frame
// that rendered, beside a message saying this one did not,
// is the same confident lie in a second instrument.
window.set_focus_overlay_ready(false);
}
}
})
@@ -2844,6 +2907,70 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
});
}
// TRACES: FR-CULL-3
// The peaking switch and its two choices.
//
// All three write `chosen_peaking` and then redraw, because the marks are
// produced by a compute pass over the rendered frame: there is nothing the
// interface can change about the overlay that does not require the frame
// to be measured again. Turning peaking *off* redraws for the same reason
// — that render is what drops the overlay textures and clears the flag.
{
let weak = window.as_weak();
let chosen = chosen_peaking.clone();
let redraw = redraw.clone();
window.on_peaking_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()),
});
chosen.set(next);
w.set_peaking_on(next.is_some());
redraw(&w);
});
}
{
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);
}
});
}
{
let weak = window.as_weak();
let chosen = chosen_peaking.clone();
let redraw = redraw.clone();
window.on_peaking_colour_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
w.set_peaking_colour(index);
if let Some(mut current) = chosen.get() {
current.colour = peaking::colour(index);
chosen.set(Some(current));
redraw(&w);
}
});
}
// The chips open on whatever the vocabulary calls its default, so the
// panel and the pass agree before anything has been pressed.
window.set_peaking_sensitivity(peaking::sensitivity_index(Default::default()));
window.set_peaking_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.
display_ui::attach(&window, &display, &viewport, redraw.clone());