From 2168cdd1c44bf1ca1df3f9cd624d53e0d4f218dd Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 29 Aug 2026 21:43:08 +0200 Subject: [PATCH] Mark what is in focus, so a frame can be judged without zooming to 100% FR-CULL-3's focus peaking. One compute dispatch measures local contrast in WGSL and writes an overlay texture; on desktop it reaches Slint through the same zero-copy wgpu import the canvas uses, so nothing per-pixel touches the CPU on the frame path. With peaking off the cost is zero and structurally so: focus_overlay opens with `let settings = self.peaking?;` before the frame is touched, and clearing drops both overlay textures, so no VRAM is held either. NFR-P14 is met by construction rather than by measurement -- one dispatch, no second render, no pipeline compile after session open, and a test asserting allocations stay at 2 over eight frames. The budget test asserts 50ms at 4K rather than a tight bound, deliberately: a tight bound fails on a loaded machine and gets deleted, which is worse than a loose one that still catches the regression that matters. TD-1 is amended rather than joined by a TD-6: on Android the overlay rides the readback that already exists there, roughly doubling that transfer while peaking is on, and TD-1's own "Done when" removes both because both are the same missing capability. Verified: cargo fmt clean; clippy --workspace --all-targets -D warnings green, which also compiles peaking.slint through dr-ui's build.rs; 11 focus GPU tests and 79 baseline dr-gpu tests pass; 511 dr-ui tests pass. Not verified: the cfg(target_os = "android") arm, which the host-target clippy never compiled. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-gpu/src/focus.rs | 1007 +++++++++++++++++++++++ core/dr-gpu/src/lib.rs | 2 + core/dr-gpu/src/shaders/focus_peak.wgsl | 141 ++++ docs/technical-debt.md | 21 + ui/dr-ui/src/develop.rs | 125 ++- ui/dr-ui/src/lib.rs | 127 +++ ui/dr-ui/src/peaking.rs | 160 ++++ ui/dr-ui/ui/app.slint | 49 ++ ui/dr-ui/ui/peaking.slint | 150 ++++ 9 files changed, 1781 insertions(+), 1 deletion(-) create mode 100644 core/dr-gpu/src/focus.rs create mode 100644 core/dr-gpu/src/shaders/focus_peak.wgsl create mode 100644 ui/dr-ui/src/peaking.rs create mode 100644 ui/dr-ui/ui/peaking.slint diff --git a/core/dr-gpu/src/focus.rs b/core/dr-gpu/src/focus.rs new file mode 100644 index 0000000..11818af --- /dev/null +++ b/core/dr-gpu/src/focus.rs @@ -0,0 +1,1007 @@ +//! TRACES: FR-CULL-3 | NFR-P14 +//! Focus peaking — saying what is sharp, so nobody has to zoom in to find out. +//! +//! # What this is for +//! +//! FR-CULL-3 lists three raw-truth overlays and gives the reason for this one +//! plainly: peaking "removes the largest single source of culling latency from +//! the critical path". Checking focus by zooming to 1:1 costs a render, a pan +//! to the subject's eye, and a zoom back, per frame, on a folder of three +//! thousand. D11 keeps 1:1 available for certainty; this is what makes reaching +//! for it the exception. +//! +//! It is *raw*-truth for the same reason the histogram beside it is: what this +//! measures is the frame the develop pipeline rendered from sensor data +//! through the demosaic, not the camera's embedded JPEG. A JPEG has already +//! been sharpened by the body, at a strength and radius nobody outside the +//! manufacturer knows, and peaking on one measures that sharpening at least as +//! much as it measures the lens. +//! +//! # Why a layer, and not a tint in the picture +//! +//! The first shape tried was the obvious one — read the display frame, write +//! the same frame with marked pixels replaced, hand *that* to the compositor. +//! It is wrong, and `app.slint` had already written down why, on the region +//! overlay it composites over the canvas: a diagnostic "must not reach the +//! histogram, an export, or the texture the develop pass hands the +//! compositor". +//! +//! All three would have happened. `HistogramPass` counts whatever texture the +//! session last rendered, so a red mark on every in-focus edge would have +//! arrived in the histogram as a red spike and in the clipping figure as blown +//! highlights. So this pass writes its **own** texture, transparent everywhere +//! except where something is in focus, and the interface lays it over the +//! canvas. The photograph is untouched by construction rather than by care. +//! +//! # Why it stays on the GPU +//! +//! Per-pixel work over the whole frame, every settled frame, is exactly what +//! ARCH §6.1 exists about: the measurement on this project puts a 4K readback +//! at 7.4 ms against a 0.28 ms compute pass, 96% of it transfer. The marks are +//! produced where the pixels already are and handed to the compositor as a +//! texture, and nothing in this file can read a pixel back on the display path +//! — see [`FocusPeakPass::read_overlay`] for the one transfer that exists and +//! who is allowed to call it. +//! +//! # Two textures, alternating +//! +//! For the reason [`crate::AdjustPass`] keeps two: Slint decides whether to +//! repaint by comparing the image property against its previous value, and two +//! images wrapping the same `wgpu::Texture` compare equal. A pass that always +//! wrote one texture would compute a new overlay every frame and never once be +//! asked to show it. +//! +//! # What it costs +//! +//! One dispatch, nine texture loads per pixel, no readback and no +//! reallocation at a steady viewport size. NFR-P14 allows 100 ms after the +//! preview on desktop and 150 ms on Android; this is two orders of magnitude +//! inside that, and the assertion in `overlay_is_ready_well_inside_the_budget` +//! is what keeps the claim honest rather than remembered. + +use wgpu::util::DeviceExt as _; + +use crate::readback::await_mapping; +use crate::{GpuContext, GpuError}; + +/// How much local contrast counts as focus. +/// +/// Three steps rather than a slider, because the number underneath is not one +/// a photographer can reason about and the choice being made is coarse: *this +/// frame is noisy, mark less* or *this subject is low-contrast, mark more*. +/// Every camera that offers peaking offers it this way. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] +pub enum PeakSensitivity { + /// The high-ISO setting. Marks only edges nothing but focus explains. + Low, + #[default] + Medium, + /// For a low-contrast subject — fur, fabric, distant foliage — at the + /// price of marking noise as well. + High, +} + +impl PeakSensitivity { + /// The luma difference, 0..1, at which a pixel is called in focus. + /// + /// **Where these three numbers come from.** A hard one-pixel step of + /// height *D* produces a response of `0.375 D` (see the shader), so a + /// threshold *t* marks any sharp edge whose contrast exceeds `t / 0.375`. + /// At `Medium` that is 0.107 in luma — about 27 code values — which is a + /// perfectly ordinary edge and well above what a photograph's own texture + /// produces by accident. + /// + /// **And what bounds them from below is noise, not taste.** Sensor noise + /// surviving into an 8-bit render is a few code values; call it a standard + /// deviation of 0.008. The response subtracts a mean of eight independent + /// neighbours from one sample, so its standard deviation is + /// `0.008 * sqrt(1 + 1/8)` = 0.0085. `Medium` sits 4.7 of those out, which + /// a normal tail puts at roughly one pixel in a million; `High` sits 2.4 + /// out, which is about one pixel in a hundred — visible as a dusting on a + /// noisy frame, which is the trade the setting is named for. `Low` is 9.4 + /// out and will not mark noise at all. + /// + /// Noise reduction, if the edit has any, has already run by the time this + /// pass sees the frame, so those figures are the pessimistic end. + pub fn threshold(self) -> f32 { + match self { + PeakSensitivity::Low => 0.080, + PeakSensitivity::Medium => 0.040, + PeakSensitivity::High => 0.020, + } + } +} + +/// What colour the marks are drawn in. +/// +/// A choice rather than a constant, and the reason is NFR-A11Y-3's: status +/// must not rest on hue alone. An overlay's information *is* positional — it +/// says where, not what — so the requirement is not violated by there being a +/// colour; it would be violated by there being only one, because a red mark on +/// a red jersey conveys nothing, and a photographer with a red-green +/// deficiency looking at foliage is in the same position permanently. +/// +/// Four fully saturated choices, no mixtures. A desaturated mark has to +/// compete with the photograph for the same colours, which is the one thing a +/// mark must not do. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] +pub enum PeakColour { + /// The convention, and what most cameras do. + #[default] + Red, + /// For a red or warm subject, and the most visible of the four on a dark + /// frame. + Yellow, + /// For a warm photograph as a whole, and the choice that survives a + /// red-green deficiency. + Cyan, + /// For a cool or green photograph — the hue photographs contain least. + Magenta, +} + +impl PeakColour { + /// The mark, as the encoded triple the overlay is written in. + pub fn rgb(self) -> [f32; 3] { + match self { + PeakColour::Red => [1.0, 0.0, 0.0], + PeakColour::Yellow => [1.0, 1.0, 0.0], + PeakColour::Cyan => [0.0, 1.0, 1.0], + PeakColour::Magenta => [1.0, 0.0, 1.0], + } + } +} + +/// TRACES: FR-CULL-3 +/// Everything the photographer chose about the overlay. +/// +/// One value rather than two arguments, because the two travel together +/// everywhere — into the session, into the sidecar-free view state, back out +/// to the panel — and a pair that is always passed together is a type. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct FocusPeaking { + pub sensitivity: PeakSensitivity, + pub colour: PeakColour, +} + +/// The dispatch's view of the frame. Padded to std140's 16 bytes before the +/// marker, exactly as the shader's `Params` declares it. +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct Params { + width: u32, + height: u32, + threshold: f32, + pad_0: u32, + marker: [f32; 4], +} + +/// One overlay texture. +struct Layer { + texture: wgpu::Texture, + view: wgpu::TextureView, + width: u32, + height: u32, +} + +/// TRACES: FR-CULL-3 | NFR-P14 +/// Produces the focus-peaking overlay for a rendered frame. +pub struct FocusPeakPass { + ctx: GpuContext, + pipeline: wgpu::ComputePipeline, + bind_group_layout: wgpu::BindGroupLayout, + params: wgpu::Buffer, + /// Alternating overlay textures — see the module documentation for why + /// there are two rather than one. + layers: [Option; 2], + current: usize, + /// Textures allocated since this pass was created. Exists to be asserted + /// on: an overlay reallocated per frame instead of per resize costs a + /// great deal of bandwidth and looks identical in the picture, which is + /// the shape of regression only a counter can see. + allocations: usize, + dispatches: usize, +} + +impl FocusPeakPass { + /// The overlay's format. + /// + /// The same `Rgba8Unorm` [`crate::AdjustPass`] writes, and for the same + /// non-negotiable reason: it is one of the two formats Slint's texture + /// import accepts. The alpha channel is what carries the overlay, so the + /// eight bits it has are seven more than this needs. + pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + + pub fn new(ctx: &GpuContext) -> Result { + // A validation error here is a bug in the shader beside this file + // rather than anything a user did, so it is caught in an error scope + // and returned — wgpu's default handler panics. + let scope = ctx.device.push_error_scope(wgpu::ErrorFilter::Validation); + + let module = ctx + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("focus-peak"), + source: wgpu::ShaderSource::Wgsl(include_str!("shaders/focus_peak.wgsl").into()), + }); + + let bind_group_layout = + ctx.device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("focus-peak-bgl"), + entries: &[ + // The rendered frame, read with `textureLoad` — the + // same texture the compositor is showing, so what is + // measured is what is on screen. + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: Self::FORMAT, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }, + ], + }); + + let layout = ctx + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("focus-peak-layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + + let pipeline = ctx + .device + .create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("focus-peak-pipeline"), + layout: Some(&layout), + module: &module, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }); + + if let Some(err) = pollster::block_on(scope.pop()) { + return Err(GpuError::ShaderCompilation(err.to_string())); + } + + let params = ctx + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("focus-peak-params"), + contents: bytemuck::bytes_of(&Params { + width: 0, + height: 0, + threshold: PeakSensitivity::default().threshold(), + pad_0: 0, + marker: [0.0; 4], + }), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + + Ok(Self { + ctx: ctx.clone(), + pipeline, + bind_group_layout, + params, + layers: [None, None], + current: 0, + allocations: 0, + dispatches: 0, + }) + } + + /// TRACES: FR-CULL-3 | NFR-P14 + /// Mark the in-focus regions of `frame`, returning the overlay to lay + /// over it. + /// + /// `frame` must carry `TEXTURE_BINDING`, which [`crate::AdjustPass`]'s + /// output does because the compositor samples it. + /// + /// **Give this a settled frame, not a draft one.** The measure is the + /// energy in the top octave of what it is handed, so it is a statement + /// about a particular sampling grid: at half resolution — which is what a + /// draft frame is rendered at — a defocused edge spanning four pixels + /// spans two, which is the signature of a sharp one. Peaking a draft frame + /// would mark the out-of-focus background of every photograph, briefly, + /// during every drag. The interface runs this where it runs the histogram, + /// on the settled frame, and for the same class of reason. + pub fn render( + &mut self, + frame: &wgpu::Texture, + settings: FocusPeaking, + ) -> Result<&wgpu::Texture, GpuError> { + // No zero-size guard: wgpu will not create a texture with a zero + // extent, so a frame that exists has at least one pixel in it. + let (width, height) = (frame.width(), frame.height()); + self.ensure_layer(width, height); + + let rgb = settings.colour.rgb(); + self.ctx.queue.write_buffer( + &self.params, + 0, + bytemuck::bytes_of(&Params { + width, + height, + threshold: settings.sensitivity.threshold(), + pad_0: 0, + marker: [rgb[0], rgb[1], rgb[2], 1.0], + }), + ); + + let layer = self.layers[self.current] + .as_ref() + .expect("ensure_layer just built it"); + let source = frame.create_view(&Default::default()); + let bind_group = self + .ctx + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("focus-peak-bg"), + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&source), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: self.params.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&layer.view), + }, + ], + }); + + let mut enc = self + .ctx + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("focus-peak-encoder"), + }); + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("focus-peak-pass"), + timestamp_writes: None, + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1); + } + // Submitted on its own queue entry after the render that produced + // `frame`. Submission order is the whole of the synchronisation, as it + // is between the fused pass and the detail chain: one queue, and this + // reads what that wrote. + self.ctx.queue.submit(Some(enc.finish())); + self.dispatches += 1; + + Ok(&self.layers[self.current] + .as_ref() + .expect("just written") + .texture) + } + + /// The overlay the last [`Self::render`] wrote, if there has been one. + pub fn overlay(&self) -> Option<&wgpu::Texture> { + self.layers[self.current].as_ref().map(|l| &l.texture) + } + + /// Forget the overlay, so nothing stale is composited. + /// + /// Called when the photograph changes and when peaking is switched off. + /// The alternative — leaving the last overlay resident and merely not + /// drawing it — is one interface bug away from laying one photograph's + /// focus marks over another's, which is the single worst thing an + /// instrument like this can do. + pub fn clear(&mut self) { + self.layers = [None, None]; + self.current = 0; + } + + /// Overlay textures allocated since this pass was created. + /// + /// For tests. A steady viewport must not move this number, and the only + /// evidence of that is a counter — a per-frame reallocation renders + /// identically to a cached one. + pub fn allocations(&self) -> usize { + self.allocations + } + + /// Dispatches encoded since this pass was created. + /// + /// The other half of [`Self::allocations`]: together they say that eight + /// frames cost eight dispatches and two textures, which is the shape a + /// steady viewport is supposed to have. + pub fn dispatches(&self) -> usize { + self.dispatches + } + + /// Make sure the current slot holds a texture of this size. + /// + /// Rotates first, so consecutive frames land in different textures — see + /// the module documentation. A size change drops both, because neither + /// fits any more and a stale one of the wrong size would be composited + /// stretched over the new frame. + fn ensure_layer(&mut self, width: u32, height: u32) { + self.current ^= 1; + let fits = self.layers[self.current] + .as_ref() + .is_some_and(|l| l.width == width && l.height == height); + if fits { + return; + } + let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor { + label: Some("focus-peak-overlay"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: Self::FORMAT, + // STORAGE_BINDING to be written by the dispatch and + // TEXTURE_BINDING to be sampled by the compositor. + // RENDER_ATTACHMENT is not used by anything here and is required + // anyway: Slint rejects an imported texture without it. COPY_SRC + // is for `read_overlay` and its two callers. + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&Default::default()); + self.layers[self.current] = Some(Layer { + texture, + view, + width, + height, + }); + self.allocations += 1; + } + + /// TRACES: AC-8 + /// Copy the overlay to the CPU, as RGBA8 rows with no padding. + /// + /// **Two callers, and neither is the desktop display path.** The tests + /// below are one: an overlay is a claim about which pixels are sharp, and + /// there is no way to check that claim without looking at the pixels. The + /// other is the Android develop view, which reads the *frame* back for the + /// reasons `technical-debt.md` TD-1 records — wgpu's Android swapchain + /// tears a portrait window, so Slint is not drawing with wgpu there and no + /// texture can be handed over. An overlay that stayed on the device on a + /// platform where the picture underneath it does not would simply never be + /// seen. + /// + /// On desktop nothing calls this, and ARCH §6.1 holds on the path that + /// matters: the overlay reaches the compositor as a texture. + pub fn read_overlay(&self) -> Result<(Vec, u32, u32), GpuError> { + let Some(layer) = self.layers[self.current].as_ref() else { + return Err(GpuError::Readback("no overlay has been rendered".into())); + }; + let (w, h) = (layer.width, layer.height); + + let unpadded = w * 4; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let padded = unpadded.div_ceil(align) * align; + + let buf = self.ctx.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("focus-peak-readback"), + size: (padded * h) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + let mut enc = self.ctx.device.create_command_encoder(&Default::default()); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &layer.texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + self.ctx.queue.submit(Some(enc.finish())); + + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + await_mapping(&self.ctx, &rx)?; + + let data = slice.get_mapped_range(); + let mut out = Vec::with_capacity((unpadded * h) as usize); + for row in 0..h { + let start = (row * padded) as usize; + out.extend_from_slice(&data[start..start + unpadded as usize]); + } + drop(data); + buf.unmap(); + Ok((out, w, h)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx() -> Option { + match pollster::block_on(GpuContext::new_headless()) { + Ok(c) => Some(c), + Err(e) => { + eprintln!("skipping: no GPU adapter ({e})"); + None + } + } + } + + /// Upload a greyscale frame the pass can read, the way `AdjustPass` hands + /// its output over. + fn frame(ctx: &GpuContext, grey: &[f32], width: u32, height: u32) -> wgpu::Texture { + let rgba: Vec = grey + .iter() + .flat_map(|v| { + let c = (v.clamp(0.0, 1.0) * 255.0).round() as u8; + [c, c, c, 255] + }) + .collect(); + let tex = ctx.device.create_texture(&wgpu::TextureDescriptor { + label: Some("focus-peak-test-frame"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + ctx.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(width * 4), + rows_per_image: Some(height), + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + ctx.queue.submit(std::iter::empty()); + tex + } + + /// A vertical step from `dark` to `bright`, blurred with a Gaussian of + /// this `sigma` in pixels. `sigma == 0.0` is the hard edge. + /// + /// The profile is evaluated analytically rather than by convolving a + /// sampled image, so "the same edge, out of focus" is exactly that and not + /// a second thing the test would also have to trust. + fn step(width: u32, height: u32, dark: f32, bright: f32, sigma: f32) -> Vec { + let edge = width as f32 / 2.0 - 0.5; + (0..height) + .flat_map(|_| { + (0..width).map(move |x| { + let d = x as f32 - edge; + let t = if sigma <= 0.0 { + if d < 0.0 { + 0.0 + } else { + 1.0 + } + } else { + // The error function, which is what a Gaussian blur of + // a step is, via a tanh approximation good to ~1e-4 — + // ample, since the assertions below are about orders + // of magnitude rather than about the fourth digit. + 0.5 * (1.0 + (1.202_7 * (d / sigma)).tanh()) + }; + dark + t * (bright - dark) + }) + }) + .collect() + } + + /// How many pixels of an overlay carry a mark, and what colour they are. + fn marks(pixels: &[u8]) -> (usize, Vec<[u8; 3]>) { + let mut count = 0; + let mut colours: Vec<[u8; 3]> = Vec::new(); + for px in pixels.chunks_exact(4) { + if px[3] != 0 { + count += 1; + let c = [px[0], px[1], px[2]]; + if !colours.contains(&c) { + colours.push(c); + } + } + } + (count, colours) + } + + fn peak( + ctx: &GpuContext, + pass: &mut FocusPeakPass, + grey: &[f32], + w: u32, + h: u32, + settings: FocusPeaking, + ) -> Vec { + let tex = frame(ctx, grey, w, h); + pass.render(&tex, settings).expect("peak"); + pass.read_overlay().expect("readback").0 + } + + #[test] + fn the_three_sensitivities_are_ordered_and_none_of_them_is_zero() { + // Arithmetic, so no device. A threshold of zero marks every pixel of + // every photograph — including a flat sky, where the response is + // whatever the last bit of the encoder rounded to — and an overlay + // that covers the frame says nothing at all. + let (low, med, high) = ( + PeakSensitivity::Low.threshold(), + PeakSensitivity::Medium.threshold(), + PeakSensitivity::High.threshold(), + ); + assert!(high > 0.0, "a zero threshold marks everything"); + assert!( + low > med && med > high, + "more sensitive must mean a lower bar: {low} {med} {high}" + ); + assert_eq!(PeakSensitivity::default(), PeakSensitivity::Medium); + } + + #[test] + fn every_colour_is_fully_saturated_and_distinct() { + // The overlay competes with the photograph for attention, and a + // desaturated mark loses. Each choice must also be a different colour + // from the others — two entries that render the same would be a menu + // that lies. + let mut seen: Vec<[f32; 3]> = Vec::new(); + for c in [ + PeakColour::Red, + PeakColour::Yellow, + PeakColour::Cyan, + PeakColour::Magenta, + ] { + let rgb = c.rgb(); + assert!( + rgb.iter().all(|v| *v == 0.0 || *v == 1.0), + "{c:?} is not a saturated primary: {rgb:?}" + ); + assert!(rgb.iter().any(|v| *v > 0.0), "{c:?} is black"); + assert!(!seen.contains(&rgb), "{c:?} duplicates another choice"); + seen.push(rgb); + } + } + + #[test] + fn a_flat_frame_is_marked_nowhere() { + // The floor. An overlay that marks an empty sky is one a photographer + // stops believing within a frame, and there is nothing subtle about + // the failure — it would be the whole picture. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + + let flat = vec![0.45f32; 64 * 64]; + let px = peak(&ctx, &mut pass, &flat, 64, 64, FocusPeaking::default()); + assert_eq!(marks(&px).0, 0, "flat grey produced marks"); + } + + #[test] + fn a_sharp_edge_is_marked_and_the_same_edge_defocused_is_not() { + // **The claim the whole overlay rests on**, and the one a gradient + // detector would fail: these two frames have identical contrast and + // differ only in how many pixels the transition is spread over. If + // both are marked, the overlay is an edge detector wearing focus + // peaking's name and it will light up every out-of-focus background + // ever photographed. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let (w, h) = (64u32, 16u32); + + let sharp = step(w, h, 0.25, 0.75, 0.0); + let soft = step(w, h, 0.25, 0.75, 2.0); + + let marked_sharp = marks(&peak( + &ctx, + &mut pass, + &sharp, + w, + h, + FocusPeaking::default(), + )) + .0; + let marked_soft = marks(&peak(&ctx, &mut pass, &soft, w, h, FocusPeaking::default())).0; + + // Two columns of the sharp edge respond — the pixel each side of the + // transition — so a full-height edge marks 2 per row. + assert_eq!( + marked_sharp, + (2 * h) as usize, + "a hard edge should mark the column each side of it, on every row" + ); + assert_eq!( + marked_soft, 0, + "the same edge at sigma 2 is out of focus and must not be marked" + ); + } + + #[test] + fn sensitivity_decides_how_faint_an_edge_still_counts() { + // The setting doing what its name says, from both sides. A hard edge + // of 0.08 contrast responds at `0.375 * 0.08` = 0.031 — above `High`'s + // bar of 0.020 and below `Medium`'s of 0.040 — so the same photograph + // must be marked at one setting and clean at the other. Without this, + // three menu entries that all behaved identically would pass every + // other test in this file. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let (w, h) = (64u32, 8u32); + let faint = step(w, h, 0.46, 0.54, 0.0); + + let at = |pass: &mut FocusPeakPass, sensitivity| { + marks(&peak( + &ctx, + pass, + &faint, + w, + h, + FocusPeaking { + sensitivity, + ..Default::default() + }, + )) + .0 + }; + + assert!( + at(&mut pass, PeakSensitivity::High) > 0, + "a faint but sharp edge is exactly what High is for" + ); + assert_eq!( + at(&mut pass, PeakSensitivity::Medium), + 0, + "Medium should hold its bar above an 0.08 edge" + ); + assert_eq!(at(&mut pass, PeakSensitivity::Low), 0); + } + + #[test] + fn the_mark_is_the_colour_that_was_asked_for_and_the_rest_is_transparent() { + // Both halves matter. The colour, because a menu that quietly draws + // red whatever is chosen is worse than not offering the choice; and + // the transparency, because the overlay is composited over the + // photograph and an alpha of even 1/255 across the frame is a veil + // over every judgement made through it. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let (w, h) = (32u32, 8u32); + let sharp = step(w, h, 0.2, 0.8, 0.0); + + for colour in [ + PeakColour::Red, + PeakColour::Yellow, + PeakColour::Cyan, + PeakColour::Magenta, + ] { + let px = peak( + &ctx, + &mut pass, + &sharp, + w, + h, + FocusPeaking { + colour, + ..Default::default() + }, + ); + let (count, colours) = marks(&px); + assert!(count > 0, "{colour:?} marked nothing"); + let expected: [u8; 3] = colour.rgb().map(|v| (v * 255.0).round() as u8); + assert_eq!(colours, vec![expected], "{colour:?} drew the wrong ink"); + + // Everything unmarked is fully transparent *and* black, which is + // what makes the layer correct whether the compositor treats it as + // premultiplied or not. + for texel in px.chunks_exact(4) { + if texel[3] == 0 { + assert!( + texel[..3].iter().all(|c| *c == 0), + "an unmarked texel carried colour under a zero alpha" + ); + } else { + assert_eq!(texel[3], 255, "a mark was drawn part-way transparent"); + } + } + } + } + + #[test] + fn the_edge_of_the_frame_is_not_marked_by_its_own_edge() { + // The clamp in `neighbour`. Wrapping instead would fold the right-hand + // column of the picture into the left-hand one's neighbourhood, and a + // photograph whose two sides differ — which is most of them — would be + // marked down both borders regardless of focus. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let (w, h) = (33u32, 17u32); + + // Dark on the left, bright on the right, with the transition a long + // way from either border. + let split = step(w, h, 0.1, 0.9, 0.0); + let px = peak(&ctx, &mut pass, &split, w, h, FocusPeaking::default()); + + for y in 0..h { + for x in [0u32, w - 1] { + let a = px[(((y * w + x) * 4) + 3) as usize]; + assert_eq!(a, 0, "the frame border at ({x}, {y}) was marked"); + } + } + // A size that is not a multiple of the 8x8 workgroup, so the edge + // groups run off the image: every texel must still have been written. + assert_eq!(px.len(), (w * h * 4) as usize); + } + + #[test] + fn consecutive_overlays_are_different_textures() { + // Slint compares the image property by value to decide whether to + // repaint, so two frames wrapping one texture compare equal and the + // second is never drawn. `AdjustPass` keeps two targets for this + // reason; the overlay beside it has to do the same or it freezes on + // whatever it first showed. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let grey = vec![0.5f32; 16 * 16]; + + let tex = frame(&ctx, &grey, 16, 16); + let first = + std::ptr::from_ref(pass.render(&tex, FocusPeaking::default()).expect("first")) as usize; + let second = std::ptr::from_ref(pass.render(&tex, FocusPeaking::default()).expect("second")) + as usize; + assert_ne!( + first, second, + "two consecutive overlays landed in the same texture" + ); + } + + #[test] + fn a_steady_viewport_allocates_nothing_after_the_first_two_frames() { + // Reallocating a viewport-sized texture per frame costs a great deal + // of bandwidth and renders identically, which is why this is a counter + // and not an assertion about the picture. Two allocations, not one: + // the pair alternates, so the second frame builds the other slot. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let grey = vec![0.5f32; 64 * 64]; + let tex = frame(&ctx, &grey, 64, 64); + + for _ in 0..8 { + pass.render(&tex, FocusPeaking::default()).expect("render"); + } + assert_eq!(pass.dispatches(), 8); + assert_eq!( + pass.allocations(), + 2, + "the overlay was reallocated per frame" + ); + + // A resize is the one thing that must reallocate: a stale overlay of + // the wrong size would be composited stretched over the new frame. + let small = vec![0.5f32; 32 * 32]; + let smaller = frame(&ctx, &small, 32, 32); + pass.render(&smaller, FocusPeaking::default()) + .expect("render"); + assert_eq!(pass.allocations(), 3); + } + + #[test] + fn a_cleared_pass_has_no_overlay_to_composite() { + // Switching peaking off, or opening a different photograph, must leave + // nothing behind. One interface bug away from laying one frame's focus + // marks over another's, which is the worst thing an instrument can do: + // be confidently about the wrong picture. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let grey = vec![0.5f32; 16 * 16]; + let tex = frame(&ctx, &grey, 16, 16); + pass.render(&tex, FocusPeaking::default()).expect("render"); + assert!(pass.overlay().is_some()); + + pass.clear(); + assert!(pass.overlay().is_none()); + assert!( + pass.read_overlay().is_err(), + "a cleared pass must not hand out a stale overlay" + ); + } + + #[test] + fn the_overlay_is_ready_well_inside_its_budget() { + // NFR-P14: ready within 100 ms of the preview on desktop, 150 ms on + // Android. The bound asserted here is 50 ms at 4K, which is half the + // desktop budget and a third of Android's, and is still around two + // orders of magnitude above what the dispatch actually costs — chosen + // so that an unrelated machine under load does not fail the suite, + // while a change that made this a multi-pass or readback-bound + // operation could not possibly stay under it. + // + // The readback is deliberately outside the timed region. It is a + // property of this test rather than of the overlay, and at 4K it is + // the 7 ms transfer ARCH §6.1 exists to keep off the frame path. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let (w, h) = (3840u32, 2160u32); + let tex = frame(&ctx, &step(w, h, 0.2, 0.8, 0.0), w, h); + + // One frame to allocate the layer and warm the pipeline; a resize is + // not what the budget is about. + pass.render(&tex, FocusPeaking::default()).expect("warm"); + ctx.device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("idle"); + + let start = std::time::Instant::now(); + pass.render(&tex, FocusPeaking::default()).expect("render"); + ctx.device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("idle"); + let elapsed = start.elapsed(); + + assert!( + elapsed.as_millis() < 50, + "the overlay took {elapsed:?} at {w}x{h}; NFR-P14 allows 100 ms" + ); + } +} diff --git a/core/dr-gpu/src/lib.rs b/core/dr-gpu/src/lib.rs index 705ceb1..785b055 100644 --- a/core/dr-gpu/src/lib.rs +++ b/core/dr-gpu/src/lib.rs @@ -21,6 +21,7 @@ mod adjust; mod demosaic; mod detail; mod error; +mod focus; mod histogram; mod mask; mod readback; @@ -33,6 +34,7 @@ pub use adjust::AdjustPass; pub use demosaic::{DemosaicedImage, Demosaicer}; pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT; pub use error::GpuError; +pub use focus::{FocusPeakPass, FocusPeaking, PeakColour, PeakSensitivity}; // Renamed on the way out: `BINS` says enough inside `histogram`, and nothing // at all at a crate root shared with demosaic and segmentation. pub use histogram::{Histogram, HistogramPass, BINS as HISTOGRAM_BINS}; diff --git a/core/dr-gpu/src/shaders/focus_peak.wgsl b/core/dr-gpu/src/shaders/focus_peak.wgsl new file mode 100644 index 0000000..098cba2 --- /dev/null +++ b/core/dr-gpu/src/shaders/focus_peak.wgsl @@ -0,0 +1,141 @@ +// TRACES: FR-CULL-3 | NFR-P14 +// Marking what is sharp, in a layer laid over the frame rather than into it. +// +// # Why the top octave, and not a gradient +// +// The obvious detector is a gradient magnitude — Sobel, or a central +// difference — and it is the wrong one, for a reason that decides whether the +// overlay is useful at all. A gradient answers "is there an edge here", and a +// defocused edge is still an edge: blur a 100-code step with a two-pixel +// Gaussian and the peak gradient is still around 20 codes per pixel, larger +// than a genuinely sharp edge across a low-contrast texture. Peaking built on +// gradients lights up the out-of-focus background of every portrait ever +// taken, which is the frame it exists to reject. +// +// What separates sharp from soft is *scale*, not amplitude. Defocus is a +// low-pass: it removes the top octave and leaves everything below it intact. +// So the detector is a high-pass — this pixel against the mean of its eight +// neighbours, a discrete Laplacian — which by construction responds only to +// the frequencies defocus destroys. +// +// The arithmetic, on a one-dimensional step of height D: +// +// | profile | abs(centre - mean of 8) | +// |--------------------------|-------------------------| +// | hard step, 1 px | 0.375 D | +// | Gaussian blur, sigma 1 | ~0.10 D | +// | Gaussian blur, sigma 2 | ~0.03 D | +// | linear ramp, any slope | 0 | +// +// The ramp row is the property being bought: the smooth luminance falloff +// across an out-of-focus highlight scores zero however bright it is. +// +// # Why luma, and why the histogram's luma +// +// One channel rather than three, because a colour edge carrying no luminance +// difference is both rare and, at the acuity an overlay is read at, invisible. +// The weights are `histogram.wgsl`'s 54/183/19 over 256 — the same Rec.709 +// weighting on the same encoded values — so the two instruments in this +// application agree about what "luma" means. Two definitions of brightness in +// one panel is the kind of disagreement nobody finds until it has already +// misled someone. +// +// # Why the frame is read where it is encoded, and not in linear light +// +// This runs on the output of the display transform, on encoded values, and +// that is deliberate: a fixed difference in sRGB code values is roughly +// equally visible wherever it sits in the range, which is what a transfer +// curve is for. Measured in linear light the same detector would need a +// threshold that varied with exposure, and a shadow texture the photographer +// can plainly see would score a hundredth of the identical texture in the +// highlights. The encoding has already done the normalisation, so the +// threshold is one number. +// +// # Why this writes a layer and not the picture +// +// The frame the compositor is handed is also what the histogram counts and +// what an export renders (`app.slint`, on the region overlay: a diagnostic +// "must not reach the histogram, an export, or the texture the develop pass +// hands the compositor"). So the marks go in their own texture — transparent +// everywhere except where something is in focus — and the compositor blends +// them. Nothing about the photograph changes, and the peaking overlay cannot +// leak into a measurement or a file. +// +// Alpha is written as exactly 0 or exactly 1, never between. The importing +// compositor's convention for whether colour arrives premultiplied is not +// something this shader can see, and at those two values the two conventions +// agree — which is a cheaper guarantee than being right about which one it is. + +struct Params { + width: u32, + height: u32, + // Luma difference at which a pixel is called in focus. See + // `PeakSensitivity::threshold` for where the three values come from. + threshold: f32, + // std140 rounds the scalar block up to 16 bytes before the vec4; named so + // the Rust struct's padding is visibly the same shape. + pad_0: u32, + // The mark's colour, fully saturated. Its alpha is ignored — see above. + marker: vec4, +} + +@group(0) @binding(0) var frame: texture_2d; +@group(0) @binding(1) var params: Params; +@group(0) @binding(2) var marks: texture_storage_2d; + +/// Rec.709 luma of an encoded triple, weighted exactly as `histogram.wgsl` +/// weights it. 54 + 183 + 19 is 256, so the weights sum to unity. +fn luma(c: vec3) -> f32 { + return dot(c, vec3(54.0, 183.0, 19.0) / 256.0); +} + +/// A neighbour, with the frame edge held rather than wrapped. +/// +/// Clamping duplicates the edge pixel into the missing half of the +/// neighbourhood, which pulls the mean towards the centre and so biases the +/// response *down* on the outermost row and column. That is the right +/// direction to be wrong in: the failure is a missing mark at the frame edge, +/// where nobody is judging focus, rather than a false mark produced by +/// folding the opposite side of the picture into the kernel. +fn neighbour(x: i32, y: i32) -> f32 { + let cx = clamp(x, 0i, i32(params.width) - 1i); + let cy = clamp(y, 0i, i32(params.height) - 1i); + return luma(textureLoad(frame, vec2(cx, cy), 0).rgb); +} + +// 8x8, matching the detail stage's dispatch. Each texel is loaded by nine +// invocations and no workgroup-memory tile is built to avoid that: at viewport +// resolution the reads are perfectly coherent and the texture cache serves +// eight of the nine. The budget is NFR-P14's 100 ms against a dispatch +// measured in tenths of a millisecond, so there is nothing here worth the +// complexity of a tiled load. +@compute @workgroup_size(8, 8, 1) +fn main(@builtin(global_invocation_id) gid: vec3) { + if (gid.x >= params.width || gid.y >= params.height) { + return; + } + let x = i32(gid.x); + let y = i32(gid.y); + + // The eight neighbours, centre excluded. Excluded rather than folded in + // because it makes the response readable: `abs(c - mean8)` is the height + // of this pixel above its surroundings in the same units as the step it + // sits on, so the threshold can be quoted as a luma difference rather than + // as eight-ninths of one. + var sum = 0.0; + for (var dy = -1; dy <= 1; dy = dy + 1) { + for (var dx = -1; dx <= 1; dx = dx + 1) { + if (dx != 0 || dy != 0) { + sum = sum + neighbour(x + dx, y + dy); + } + } + } + let centre = luma(textureLoad(frame, vec2(x, y), 0).rgb); + let response = abs(centre - sum / 8.0); + + if (response >= params.threshold) { + textureStore(marks, vec2(x, y), vec4(params.marker.rgb, 1.0)); + } else { + textureStore(marks, vec2(x, y), vec4(0.0, 0.0, 0.0, 0.0)); + } +} diff --git a/docs/technical-debt.md b/docs/technical-debt.md index 057157c..fb972b7 100644 --- a/docs/technical-debt.md +++ b/docs/technical-debt.md @@ -55,6 +55,27 @@ readback is at viewport resolution, not sensor resolution. The 7.43 ms at 4K in not the bill. **It has not been measured on the device**, which is the first thing to do if the develop view feels heavy on the tablet; do not assume this is the cause without a number. +### And a second transfer, while focus peaking is on + +Added 2026-08-29 with FR-CULL-3. The focus-peaking overlay is a compute pass writing its own +`Rgba8Unorm` texture, which on desktop reaches the compositor with no copy — but on Android there is +no more a path for *that* texture than for the frame it belongs to, and an overlay that stayed on +the device while the picture underneath it did not would simply never be seen. So +`FocusPeakPass::read_overlay` follows the frame back through memory, and the Android frame path +carries **two** full-resolution `copy_texture_to_buffer` transfers instead of one. + +This is recorded under TD-1 rather than as its own entry because it is not an independent choice. +It exists only because TD-1 exists, it is bounded by the same thing — `render` fits the pass to the +canvas, so both transfers are at viewport resolution — and TD-1's "Done when" already covers it: +whichever of the three fixes above lands removes the readback for the frame and the overlay +together, because both are the same missing capability. + +Two things worth saying plainly. The doubling is **reasoned, not measured on the device** — the same +gap TD-1 admits about its own cost, and the reason neither number should be quoted as a measurement. +And it is paid only while the photographer has the overlay switched on: `DevelopSession::focus_overlay` +returns on its first line when peaking is off, so with it off there is no dispatch and no transfer, +and the Android frame path is exactly what it was before this feature existed. + ### Paying it off Any one of these removes it: diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 199dac9..d900dba 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -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, + /// 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, + /// 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, /// 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 { + 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) { + 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 { + 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::::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 diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 70fd502..361a9c2 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -39,6 +39,7 @@ mod library_ui; mod live_style; mod masks_ui; mod net_runtime; +mod peaking; mod presets; mod remote; mod segmentation; @@ -316,6 +317,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 @@ -1464,11 +1471,24 @@ pub fn run(paths: Vec) -> Result<()> { // is redrawn, and those are very different rates. let drawn_history: Rc>> = 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>> = 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 }; @@ -1529,6 +1549,16 @@ pub fn run(paths: Vec) -> 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.** @@ -1591,6 +1621,34 @@ pub fn run(paths: Vec) -> 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}"); @@ -1598,6 +1656,11 @@ pub fn run(paths: Vec) -> 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); } } }) @@ -2818,6 +2881,70 @@ pub fn run(paths: Vec) -> 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()); diff --git a/ui/dr-ui/src/peaking.rs b/ui/dr-ui/src/peaking.rs new file mode 100644 index 0000000..70666d6 --- /dev/null +++ b/ui/dr-ui/src/peaking.rs @@ -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" + ); + } +} diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index cc693c5..b160981 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -10,6 +10,7 @@ import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChi 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 { FocusMarks, FocusPanel } from "peaking.slint"; import { SettingsPage } from "settings.slint"; import { ImportPage } from "import.slint"; import { StatusBar, InfoPanel } from "develop.slint"; @@ -70,6 +71,22 @@ export component AppWindow inherits Window { /// of a draft frame is a histogram of an image nobody is reading. in property 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 focus-overlay; + in property focus-overlay-ready: false; + in property peaking-on: false; + in property peaking-available: true; + in property peaking-sensitivity: 1; + in property 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 @@ -1617,6 +1634,18 @@ in property 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 @@ -2150,6 +2179,26 @@ in property 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 diff --git a/ui/dr-ui/ui/peaking.slint b/ui/dr-ui/ui/peaking.slint new file mode 100644 index 0000000..3821e55 --- /dev/null +++ b/ui/dr-ui/ui/peaking.slint @@ -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 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 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 showing: false; + /// Index into `PeakSensitivity`, in the order Rust declares it. + in property sensitivity: 1; + /// Index into `PeakColour`, likewise. + in property 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 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; + } + } +}