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
🐳 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:
+124
-1
@@ -14,7 +14,8 @@ use std::sync::Arc;
|
||||
|
||||
use dr_decode::RawImage;
|
||||
use dr_gpu::{
|
||||
AdjustPass, DemosaicedImage, Demosaicer, GpuContext, Histogram, HistogramPass, MaskPass,
|
||||
AdjustPass, DemosaicedImage, Demosaicer, FocusPeakPass, FocusPeaking, GpuContext, Histogram,
|
||||
HistogramPass, MaskPass,
|
||||
};
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource};
|
||||
|
||||
@@ -722,6 +723,25 @@ pub struct DevelopSession {
|
||||
/// old driver, a device without the storage-buffer atomics it needs — the
|
||||
/// photographer loses the histogram and keeps the photograph.
|
||||
histogram: Option<HistogramPass>,
|
||||
/// TRACES: FR-CULL-3
|
||||
/// The focus-peaking overlay, on the same terms as the histogram above:
|
||||
/// optional, because a device that cannot compile the pass is still a
|
||||
/// device that can develop the photograph. What is lost is an instrument,
|
||||
/// not the picture.
|
||||
peak: Option<FocusPeakPass>,
|
||||
/// TRACES: FR-CULL-3
|
||||
/// What the photographer asked the overlay to look like, or `None` for
|
||||
/// off.
|
||||
///
|
||||
/// **Interface state, not part of the edit** — the same category as
|
||||
/// `show_overlay` beside it. It changes no pixel of the photograph, it is
|
||||
/// not in the sidecar, and it is not on the undo stack: pressing undo
|
||||
/// after switching peaking on should take back the last *edit*, not the
|
||||
/// last thing looked at.
|
||||
///
|
||||
/// An `Option` rather than a bool plus a settings field, so that "off" and
|
||||
/// "on, in some configuration" cannot disagree with each other.
|
||||
peaking: Option<FocusPeaking>,
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The region map local masks select from, once it has been computed.
|
||||
@@ -889,6 +909,10 @@ impl DevelopSession {
|
||||
histogram: HistogramPass::new(ctx)
|
||||
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
|
||||
.ok(),
|
||||
peak: FocusPeakPass::new(ctx)
|
||||
.inspect_err(|e| log::warn!("no focus peaking on this device: {e}"))
|
||||
.ok(),
|
||||
peaking: None,
|
||||
segmentation: None,
|
||||
masks: None,
|
||||
subjects: None,
|
||||
@@ -2903,6 +2927,105 @@ impl DevelopSession {
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-3
|
||||
/// Whether this device could build the focus-peaking overlay.
|
||||
///
|
||||
/// Asked by the interface so that it can say the overlay is unavailable
|
||||
/// rather than offer a switch that does nothing. The same courtesy the
|
||||
/// histogram is not paid, and should be: a control that silently does
|
||||
/// nothing is worse than one that is visibly absent.
|
||||
pub fn peaking_available(&self) -> bool {
|
||||
self.peak.is_some()
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-3
|
||||
/// What the overlay is set to, or `None` when it is off.
|
||||
pub fn peaking(&self) -> Option<FocusPeaking> {
|
||||
self.peaking
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-3
|
||||
/// Switch the overlay on with these settings, or off.
|
||||
///
|
||||
/// Asking for peaking on a device that could not build the pass leaves it
|
||||
/// off, so that [`Self::peaking`] never claims something is being drawn
|
||||
/// that is not. Switching off drops the overlay textures rather than
|
||||
/// merely stopping drawing them: a resident overlay from the last frame is
|
||||
/// one interface bug away from being laid over the next photograph.
|
||||
pub fn set_peaking(&mut self, settings: Option<FocusPeaking>) {
|
||||
self.peaking = settings.filter(|_| self.peak.is_some());
|
||||
if self.peaking.is_none() {
|
||||
if let Some(pass) = self.peak.as_mut() {
|
||||
pass.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-3 | NFR-P14
|
||||
/// Mark the in-focus regions of the frame that is currently on the canvas.
|
||||
///
|
||||
/// **Reads the frame [`Self::render`] last produced**, exactly as
|
||||
/// [`Self::histogram`] does and for the same reason: the overlay has to
|
||||
/// describe what the photographer is looking at, and rendering a second
|
||||
/// time to measure it would cost a pass and admit the possibility of the
|
||||
/// two disagreeing about the picture.
|
||||
///
|
||||
/// That the frame is the displayed one is what makes the marks land where
|
||||
/// the eye is. It is at viewport resolution, cropped and zoomed as the
|
||||
/// view is, and — the point of FR-CULL-3 — descended from sensor data
|
||||
/// through the demosaic rather than from the camera's embedded JPEG, whose
|
||||
/// in-body sharpening this would otherwise be measuring at least as much
|
||||
/// as the lens.
|
||||
///
|
||||
/// **Call this only after a settled render.** See
|
||||
/// [`dr_gpu::FocusPeakPass::render`] for why a half-resolution draft frame
|
||||
/// cannot be measured for sharpness.
|
||||
///
|
||||
/// `None` where nothing has been rendered, where peaking is off, or where
|
||||
/// the device could not build the pass.
|
||||
pub fn focus_overlay(&mut self) -> Option<slint::Image> {
|
||||
let settings = self.peaking?;
|
||||
// Cloned rather than borrowed: a `wgpu::Texture` handle is an `Arc`,
|
||||
// and holding a shared borrow of `self.adjust` across the mutable
|
||||
// borrow of `self.peak` would cost a `Self { .. }` destructure to say
|
||||
// something the clone says in one word.
|
||||
let frame = self.adjust.output()?.clone();
|
||||
let pass = self.peak.as_mut()?;
|
||||
let overlay = pass
|
||||
.render(&frame, settings)
|
||||
.inspect_err(|e| log::warn!("focus peaking failed: {e}"))
|
||||
.ok()?
|
||||
.clone();
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
// A layer over the canvas rather than a tint in it, so nothing
|
||||
// here reaches the histogram or an export — see `FocusPeakPass`
|
||||
// for the whole of that argument.
|
||||
slint::Image::try_from(overlay)
|
||||
.inspect_err(|e| log::warn!("the focus overlay is not importable: {e}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
// Android draws with Skia over OpenGL and cannot sample a
|
||||
// `wgpu::Texture`, so the overlay follows the frame it belongs to back
|
||||
// through memory (technical-debt.md TD-1). The measurement still
|
||||
// happens on the GPU; only this last hop does not.
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let _ = overlay;
|
||||
let (rgba, w, h) = pass
|
||||
.read_overlay()
|
||||
.inspect_err(|e| log::warn!("reading the focus overlay back: {e}"))
|
||||
.ok()?;
|
||||
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(w, h);
|
||||
let wanted = (w as usize) * (h as usize) * 4;
|
||||
let src = &rgba[..wanted.min(rgba.len())];
|
||||
buf.make_mut_bytes()[..src.len()].copy_from_slice(src);
|
||||
Some(slint::Image::from_rgba8(buf))
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the *whole* frame for the crop overlay to be drawn over.
|
||||
///
|
||||
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! TRACES: FR-CULL-3
|
||||
//! The focus-peaking vocabulary, as the indices a chip row can carry.
|
||||
//!
|
||||
//! `dr_gpu` decides what peaking *is* — the measure, the thresholds, the
|
||||
//! marks. This decides how a menu of three sensitivities and four colours
|
||||
//! crosses the boundary into Slint, which has no notion of a Rust enum and
|
||||
//! carries the choice as an `int` into an array of labels.
|
||||
//!
|
||||
//! That translation is small and it is the kind of small that goes wrong
|
||||
//! silently. An index the interface sends that Rust reads as a different
|
||||
//! variant produces a control that changes something other than what it says,
|
||||
//! which nobody notices as a bug — they notice it as peaking behaving oddly.
|
||||
//! So the order lives in one place here, both directions are asserted to round
|
||||
//! trip, and a test checks that the labels in `ui/peaking.slint` still number
|
||||
//! the same as the vocabularies they claim to name.
|
||||
//!
|
||||
//! Free-standing functions over plain integers, deliberately, for the reason
|
||||
//! `crate::histogram` gives: none of this needs a GPU, a window or a
|
||||
//! photograph to be checked, and all of it is invisible when wrong.
|
||||
|
||||
use dr_gpu::{PeakColour, PeakSensitivity};
|
||||
|
||||
/// The sensitivities, in the order the chip row shows them.
|
||||
///
|
||||
/// Least sensitive first, so the row reads left to right as "mark less" to
|
||||
/// "mark more" — the axis the photographer is actually moving along.
|
||||
pub(crate) const SENSITIVITIES: [PeakSensitivity; 3] = [
|
||||
PeakSensitivity::Low,
|
||||
PeakSensitivity::Medium,
|
||||
PeakSensitivity::High,
|
||||
];
|
||||
|
||||
/// The mark colours, in the order the chip row shows them.
|
||||
pub(crate) const COLOURS: [PeakColour; 4] = [
|
||||
PeakColour::Red,
|
||||
PeakColour::Yellow,
|
||||
PeakColour::Cyan,
|
||||
PeakColour::Magenta,
|
||||
];
|
||||
|
||||
/// The sensitivity an index names.
|
||||
///
|
||||
/// Out of range falls back to the default rather than panicking. The index
|
||||
/// arrives from the interface, and the interface is the half of this that can
|
||||
/// be recompiled without recompiling the other — a chip row that grew an entry
|
||||
/// should degrade to a sane setting, not take the application down mid-cull.
|
||||
pub(crate) fn sensitivity(index: i32) -> PeakSensitivity {
|
||||
usize::try_from(index)
|
||||
.ok()
|
||||
.and_then(|i| SENSITIVITIES.get(i).copied())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The colour an index names, on the same terms.
|
||||
pub(crate) fn colour(index: i32) -> PeakColour {
|
||||
usize::try_from(index)
|
||||
.ok()
|
||||
.and_then(|i| COLOURS.get(i).copied())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Which chip is lit for this sensitivity.
|
||||
pub(crate) fn sensitivity_index(value: PeakSensitivity) -> i32 {
|
||||
SENSITIVITIES.iter().position(|s| *s == value).unwrap_or(0) as i32
|
||||
}
|
||||
|
||||
/// Which chip is lit for this colour.
|
||||
pub(crate) fn colour_index(value: PeakColour) -> i32 {
|
||||
COLOURS.iter().position(|c| *c == value).unwrap_or(0) as i32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_variant_appears_exactly_once_in_its_row() {
|
||||
// A variant missing from the row is a setting the photographer cannot
|
||||
// reach; one listed twice is two chips that do the same thing, of
|
||||
// which only the first can ever look selected. Both are invisible in
|
||||
// the running application until somebody presses the wrong chip.
|
||||
for s in SENSITIVITIES {
|
||||
assert_eq!(
|
||||
SENSITIVITIES.iter().filter(|x| **x == s).count(),
|
||||
1,
|
||||
"{s:?} is listed more than once"
|
||||
);
|
||||
}
|
||||
for c in COLOURS {
|
||||
assert_eq!(COLOURS.iter().filter(|x| **x == c).count(), 1);
|
||||
}
|
||||
// Named rather than counted, so adding a variant to `dr_gpu` without
|
||||
// adding it here fails to compile instead of passing quietly.
|
||||
assert!(SENSITIVITIES.contains(&PeakSensitivity::Low));
|
||||
assert!(SENSITIVITIES.contains(&PeakSensitivity::Medium));
|
||||
assert!(SENSITIVITIES.contains(&PeakSensitivity::High));
|
||||
assert!(COLOURS.contains(&PeakColour::Red));
|
||||
assert!(COLOURS.contains(&PeakColour::Yellow));
|
||||
assert!(COLOURS.contains(&PeakColour::Cyan));
|
||||
assert!(COLOURS.contains(&PeakColour::Magenta));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_index_and_its_variant_agree_in_both_directions() {
|
||||
// The failure this catches is a chip that lights up under the pointer
|
||||
// while a different setting takes effect — the two directions drifting
|
||||
// apart is exactly what one shared array is here to prevent, and the
|
||||
// only way to see it is to go round.
|
||||
for (i, s) in SENSITIVITIES.iter().enumerate() {
|
||||
assert_eq!(sensitivity(i as i32), *s);
|
||||
assert_eq!(sensitivity_index(*s), i as i32);
|
||||
}
|
||||
for (i, c) in COLOURS.iter().enumerate() {
|
||||
assert_eq!(colour(i as i32), *c);
|
||||
assert_eq!(colour_index(*c), i as i32);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_index_from_nowhere_lands_on_the_default_rather_than_panicking() {
|
||||
// Slint has no bound on the `int` it sends and Rust has no way to
|
||||
// refuse one. A panic here would be an application that closes because
|
||||
// a chip row was edited.
|
||||
assert_eq!(sensitivity(-1), PeakSensitivity::default());
|
||||
assert_eq!(sensitivity(99), PeakSensitivity::default());
|
||||
assert_eq!(colour(-1), PeakColour::default());
|
||||
assert_eq!(colour(99), PeakColour::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_offers_exactly_the_choices_this_module_knows_about() {
|
||||
// **The one seam neither compiler checks.** The labels live in
|
||||
// `ui/peaking.slint` and the meanings live here, joined only by an
|
||||
// integer; a fifth colour added to the chip row would send index 4 to
|
||||
// `colour`, which would quietly answer Red. Reading the file is
|
||||
// clumsier than a derive, and it is what there is.
|
||||
let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/peaking.slint"))
|
||||
.expect("the panel this module serves");
|
||||
|
||||
let listed = |line_start: &str| -> usize {
|
||||
let line = src
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| l.starts_with(line_start))
|
||||
.unwrap_or_else(|| panic!("no `{line_start}` row in peaking.slint"));
|
||||
line.matches('"').count() / 2
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
listed("options: [\"Low\""),
|
||||
SENSITIVITIES.len(),
|
||||
"the sensitivity chips and `SENSITIVITIES` disagree"
|
||||
);
|
||||
assert_eq!(
|
||||
listed("options: [\"Red\""),
|
||||
COLOURS.len(),
|
||||
"the colour chips and `COLOURS` disagree"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! TRACES: FR-PLAT-LIN-1 | FR-NC-6a | FR-EXP-5
|
||||
//! TRACES: FR-PLAT-LIN-1 | FR-NC-6a | FR-EXP-5 | NFR-OPS-3
|
||||
//! Reads and writes `settings.json` beside the session config.
|
||||
//!
|
||||
//! Deliberately a near-twin of [`SessionStore`](dr_sync_nextcloud::SessionStore)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, Progres
|
||||
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
||||
import { HistogramPanel, HistogramView } from "histogram.slint";
|
||||
import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
|
||||
import { FocusMarks, FocusPanel } from "peaking.slint";
|
||||
import { SettingsPage } from "settings.slint";
|
||||
import { ImportPage } from "import.slint";
|
||||
import { StatusBar, InfoPanel } from "develop.slint";
|
||||
@@ -71,6 +72,22 @@ export component AppWindow inherits Window {
|
||||
/// of a draft frame is a histogram of an image nobody is reading.
|
||||
in property <HistogramView> 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`.
|
||||
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) ---
|
||||
//
|
||||
// Zoom is a *viewing* state, not an edit: it changes the resolution the
|
||||
@@ -1669,6 +1686,18 @@ in property <bool> panel-visible: true;
|
||||
image-rendering: ImageRendering.pixelated;
|
||||
}
|
||||
|
||||
// TRACES: FR-CULL-3
|
||||
// The focus marks, over the same fitted rect. See
|
||||
// `peaking.slint` for why they are a layer over the canvas
|
||||
// rather than a tint in it.
|
||||
if root.focus-overlay-ready && root.total > 0: FocusMarks {
|
||||
x: parent.shown-x;
|
||||
y: parent.shown-y;
|
||||
width: parent.shown-w;
|
||||
height: parent.shown-h;
|
||||
marks: root.focus-overlay;
|
||||
}
|
||||
|
||||
// Where the photograph actually sits inside this box.
|
||||
//
|
||||
// `image-fit: contain` letterboxes, and Slint does not report
|
||||
@@ -2202,6 +2231,26 @@ in property <bool> panel-visible: true;
|
||||
background: Theme.rule;
|
||||
}
|
||||
|
||||
// TRACES: FR-CULL-3
|
||||
// Under the histogram, because the two are the same
|
||||
// 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); }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
|
||||
// Framing above the colour work, matching how the edit is
|
||||
// made rather than how it is applied: the frame is decided
|
||||
// by eye first and the pipeline runs it last (see
|
||||
|
||||
@@ -137,10 +137,18 @@ component MaskEntry inherits Rectangle {
|
||||
alignment: center;
|
||||
spacing: 0px;
|
||||
|
||||
// Both lines below are bounded for the reason the subject
|
||||
// row is: a mask's label and kind come from the model, and an
|
||||
// unbounded `Text` asks for its whole string at layout time
|
||||
// even when `elide` means it will never draw it. A mask *is* a
|
||||
// segmentation result, so without this the column moved when a
|
||||
// subject was clicked as well as when one was found.
|
||||
Label {
|
||||
text: root.data.label;
|
||||
emphasised: root.data.selected || touch.has-hover;
|
||||
overflow: elide;
|
||||
min-width: 0px;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
Caption {
|
||||
@@ -148,6 +156,8 @@ component MaskEntry inherits Rectangle {
|
||||
// targets in a row, and wrapping would give the rows of a
|
||||
// stack different heights for no gain.
|
||||
overflow: elide;
|
||||
min-width: 0px;
|
||||
max-width: 160px;
|
||||
// Three states worth distinguishing, and each has a
|
||||
// different remedy: stale needs the segmentation re-run,
|
||||
// unadjusted needs a slider moved, and the ordinary case
|
||||
@@ -418,6 +428,30 @@ export component MaskPanel inherits Rectangle {
|
||||
emphasised: subject-row.has-hover;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
// **`elide` is a paint-time behaviour, and this is a
|
||||
// layout-time problem.** A `Text` asks for the width of
|
||||
// its whole string whether or not it will draw all of
|
||||
// it, so without a stated maximum this row asked for
|
||||
// whatever the model happened to return, that became
|
||||
// `layout.preferred-width`, the panel publishes that as
|
||||
// its `min-width`, and the develop column takes the
|
||||
// widest minimum any panel declares. The column
|
||||
// therefore moved the instant segmentation finished —
|
||||
// a photograph the user was looking at, jumping
|
||||
// sideways because a label said "traffic light".
|
||||
//
|
||||
// Stated as a maximum for the reason `ChipGrid`
|
||||
// declares its width from its column count rather than
|
||||
// from its options: what a panel asks for must follow
|
||||
// from its structure, never from its data. Past this
|
||||
// the row elides, which is what `elide` was for.
|
||||
//
|
||||
// 160px is the same judgement as `ChipGrid`'s 88px
|
||||
// chip — comfortable for the class names this model
|
||||
// returns, and narrow enough that a subject list
|
||||
// cannot be what sets the column.
|
||||
min-width: 0px;
|
||||
max-width: 160px;
|
||||
}
|
||||
Value { text: round(subject.score * 100) + "%"; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// TRACES: FR-CULL-3
|
||||
// The focus-peaking switch, and the two choices it exposes.
|
||||
//
|
||||
// **An instrument, not an operation**, exactly as the histogram above it is:
|
||||
// it has no parameters in the edit graph, changes nothing about the
|
||||
// photograph, and answers a question rather than asking one. So it is written
|
||||
// by hand rather than generated from a descriptor, and FR-DEV-3a is untroubled
|
||||
// by it — nothing here names an operation or reads a parameter out of one.
|
||||
//
|
||||
// **Both choices are words, not swatches.** The colour picker is the obvious
|
||||
// place to draw four coloured squares, and NFR-A11Y-3 is the reason not to:
|
||||
// a control for choosing between hues, presented only as hues, is unusable by
|
||||
// the person most likely to need to change it. The chips say "Red" and "Cyan".
|
||||
//
|
||||
// **Why the two chip rows only exist while peaking is on.** They are settings
|
||||
// for something that is not happening, and the develop column is the
|
||||
// photographer's instrument panel — every row it holds is a slider pushed
|
||||
// below the fold. The panel's own height is bound to its content, so the
|
||||
// column reflows rather than leaving a gap.
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { Button, PanelHeading, Caption } from "widgets.slint";
|
||||
import { Segmented } from "controls.slint";
|
||||
|
||||
// TRACES: FR-CULL-3
|
||||
// The marks themselves, composited over the canvas.
|
||||
//
|
||||
// **A layer over the photograph and not a tint in it**, which is the same rule
|
||||
// `app.slint` states on the region map: a diagnostic "must not reach the
|
||||
// histogram, an export, or the texture the develop pass hands the compositor".
|
||||
// `HistogramPass` counts whatever the develop pass last rendered, so marks
|
||||
// painted into that frame would arrive in the histogram as a spike and in the
|
||||
// clipping figure as blown highlights. The layer is transparent everywhere
|
||||
// except where something is in focus.
|
||||
//
|
||||
// **No `source-clip` and no rotation**, unlike the region map. That is a
|
||||
// source-space picture being windowed down to the visible part; this was
|
||||
// measured on the rendered frame itself, so it is already cropped, zoomed and
|
||||
// turned exactly as the canvas is. One fewer thing that can drift out of
|
||||
// registration.
|
||||
//
|
||||
// The caller places it on `canvas-area`'s fitted rect, which is the shared
|
||||
// contract for anything that lands on the picture.
|
||||
export component FocusMarks inherits Image {
|
||||
/// The overlay `DevelopSession::focus_overlay` produced for this frame.
|
||||
in property <image> marks;
|
||||
|
||||
source: root.marks;
|
||||
image-fit: fill;
|
||||
// Nearest-neighbour: a mark is one pixel wide, and smoothing spreads it
|
||||
// into a grey haze that reads as softness — the opposite of what it is
|
||||
// reporting.
|
||||
image-rendering: ImageRendering.pixelated;
|
||||
}
|
||||
|
||||
// TRACES: FR-CULL-3
|
||||
export component FocusPanel inherits Rectangle {
|
||||
/// Whether this device could build the overlay at all.
|
||||
///
|
||||
/// A compute pass can fail to compile on a driver nobody here has, and the
|
||||
/// honest response is to say so rather than to offer a switch that does
|
||||
/// nothing when pressed. The develop view keeps working without it; only
|
||||
/// this panel changes.
|
||||
in property <bool> available: true;
|
||||
/// Whether the overlay is currently being drawn.
|
||||
///
|
||||
/// `showing` rather than the obvious `on`: Slint has no reserved word
|
||||
/// there today, and a one-word property that might become one is not worth
|
||||
/// the bet on a panel this small.
|
||||
in property <bool> showing: false;
|
||||
/// Index into `PeakSensitivity`, in the order Rust declares it.
|
||||
in property <int> sensitivity: 1;
|
||||
/// Index into `PeakColour`, likewise.
|
||||
in property <int> colour: 0;
|
||||
|
||||
callback toggled(bool);
|
||||
callback sensitivity-picked(int);
|
||||
callback colour-picked(int);
|
||||
|
||||
background: Theme.surface;
|
||||
|
||||
/// TRACES: FR-UI-2
|
||||
/// How wide this panel has to be before it clips itself. The develop
|
||||
/// column is the largest of these and nothing else; see `SpotPanel` and
|
||||
/// `HistogramPanel` for the whole protocol.
|
||||
///
|
||||
/// Both chip rows wrap at three, which is what keeps this number at the
|
||||
/// narrowest column the application supports rather than at four chips
|
||||
/// abreast — a single row of four would set the width of the entire
|
||||
/// sidebar for every other panel in it.
|
||||
out property <length> content-width: layout.preferred-width;
|
||||
min-width: root.content-width;
|
||||
|
||||
// Flat rather than nested, for the reason `SpotPanel` and `MaskPanel` both
|
||||
// give: a nested conditional layout under-reports its height here and the
|
||||
// rows below it get drawn on top of one another. Every row carries its own
|
||||
// `if`.
|
||||
layout := VerticalLayout {
|
||||
padding: Theme.gap;
|
||||
spacing: Theme.gap-sm;
|
||||
alignment: start;
|
||||
|
||||
PanelHeading { text: "FOCUS"; }
|
||||
|
||||
if !root.available: Caption {
|
||||
text: "This device could not build the overlay.";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
if root.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); }
|
||||
}
|
||||
|
||||
if root.available && root.showing: Segmented {
|
||||
label: "Sensitivity";
|
||||
hint: "lower on a noisy frame";
|
||||
options: ["Low", "Medium", "High"];
|
||||
selected: root.sensitivity;
|
||||
columns: 3;
|
||||
picked(i) => { root.sensitivity-picked(i); }
|
||||
}
|
||||
|
||||
if root.available && root.showing: Segmented {
|
||||
label: "Marks";
|
||||
hint: "pick what the subject is not";
|
||||
options: ["Red", "Yellow", "Cyan", "Magenta"];
|
||||
selected: root.colour;
|
||||
columns: 3;
|
||||
picked(i) => { root.colour-picked(i); }
|
||||
}
|
||||
|
||||
if root.available && root.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`).
|
||||
//
|
||||
// Kept to one short sentence on purpose. A wrapping Text reports
|
||||
// its *unwrapped* width as its preferred one, and this panel's
|
||||
// `content-width` is what the develop column sizes itself from —
|
||||
// a paragraph here would hold the whole sidebar open.
|
||||
text: "Marks pause while a control is dragged.";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// TRACES: FR-UI-6
|
||||
// Shared chrome primitives and the style layer.
|
||||
//
|
||||
// Before this file every button was a Rectangle + TouchArea written out where
|
||||
|
||||
Reference in New Issue
Block a user