Merge branch 'master' into android-bundled-face-models
This commit is contained in:
@@ -35,7 +35,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
|
||||
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
||||
use dr_sync_nextcloud::AppCredentials;
|
||||
use dr_thumbs::ThumbStore;
|
||||
|
||||
/// Folder under the library root holding derived state.
|
||||
@@ -117,7 +117,7 @@ pub fn spawn_sync(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
||||
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let _ = tx.send(SyncMessage::Failed(e.to_string()));
|
||||
@@ -125,7 +125,7 @@ pub fn spawn_sync(
|
||||
}
|
||||
};
|
||||
|
||||
match run(&backend, &root, &thumbs_dir, &catalog_path, &scratch, &tx).await {
|
||||
match run(&*backend, &root, &thumbs_dir, &catalog_path, &scratch, &tx).await {
|
||||
Ok(report) => {
|
||||
let _ = tx.send(SyncMessage::Finished(Box::new(report)));
|
||||
}
|
||||
@@ -140,7 +140,7 @@ pub fn spawn_sync(
|
||||
}
|
||||
|
||||
async fn run(
|
||||
backend: &NextcloudBackend,
|
||||
backend: &dyn RemoteBackend,
|
||||
root: &str,
|
||||
thumbs_dir: &Path,
|
||||
catalog_path: &Path,
|
||||
@@ -193,7 +193,7 @@ fn derived_path(root: &str) -> RemotePath {
|
||||
/// namespace, and [`ThumbStore::adopted`] then tracks what has been merged by
|
||||
/// remote name instead of by our own ids.
|
||||
async fn sync_shards(
|
||||
backend: &NextcloudBackend,
|
||||
backend: &dyn RemoteBackend,
|
||||
base: &RemotePath,
|
||||
thumbs_dir: &Path,
|
||||
scratch: &Path,
|
||||
@@ -323,7 +323,7 @@ async fn sync_shards(
|
||||
/// carrier. So this exports the catalog's new faces into the local shard store
|
||||
/// first, syncs the shards, and imports whatever arrived back into the catalog.
|
||||
async fn sync_face_shards(
|
||||
backend: &NextcloudBackend,
|
||||
backend: &dyn RemoteBackend,
|
||||
base: &RemotePath,
|
||||
catalog_path: &Path,
|
||||
scratch: &Path,
|
||||
@@ -482,7 +482,7 @@ fn legacy_upload_of_ours(store: &ThumbStore, id: u32, remote_size: u64) -> bool
|
||||
/// describes local state (folder ETags, cache paths, job rows) and importing
|
||||
/// another device's version would be actively wrong.
|
||||
async fn sync_catalog(
|
||||
backend: &NextcloudBackend,
|
||||
backend: &dyn RemoteBackend,
|
||||
base: &RemotePath,
|
||||
catalog_path: &Path,
|
||||
scratch: &Path,
|
||||
|
||||
+180
-15
@@ -686,6 +686,22 @@ pub struct DevelopSession {
|
||||
/// open. One value rather than one per operation, for the same reason
|
||||
/// `curve_samples` is one polyline: the panel draws one curve.
|
||||
curve_channel: usize,
|
||||
/// TRACES: FR-DSP-8
|
||||
/// The space the canvas is encoded into, for the display now showing it.
|
||||
///
|
||||
/// **Not part of the edit, and not interface state either.** It is a fact
|
||||
/// about the glass in front of the photographer: the same graph on the
|
||||
/// same file composes differently on a wide-gamut second monitor, and
|
||||
/// neither the sidecar nor the undo stack has any business knowing about
|
||||
/// it. That is also why it lives here rather than on the `EditGraph` —
|
||||
/// `compose_for` deliberately takes the space per call because "the same
|
||||
/// edit goes to the screen in the display's space and to a file in
|
||||
/// whatever the export asks for, and neither is more authoritative".
|
||||
///
|
||||
/// sRGB until the application says otherwise, which is the same answer
|
||||
/// `dr_plat::display`'s fallback gives and means a session constructed in
|
||||
/// a test behaves exactly as it did before this existed.
|
||||
display_space: dr_types::ColourSpace,
|
||||
}
|
||||
|
||||
impl DevelopSession {
|
||||
@@ -756,6 +772,7 @@ impl DevelopSession {
|
||||
show_overlay: false,
|
||||
active_tab: None,
|
||||
curve_channel: 0,
|
||||
display_space: dr_types::ColourSpace::Srgb,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1196,7 +1213,7 @@ fn curve_runs(op: &OpCapability, presentation: &Presentation) -> Option<Vec<Curv
|
||||
}
|
||||
|
||||
let mut runs: Vec<CurveRun> = Vec::new();
|
||||
for id in presentation.params {
|
||||
for id in &presentation.params {
|
||||
// The widget addresses points by offset from the first of its run, so
|
||||
// a run has to be contiguous in the capability list.
|
||||
let at = op.params.iter().position(|p| p.id == *id)?;
|
||||
@@ -2624,6 +2641,30 @@ impl DevelopSession {
|
||||
Some((op.id, param.id))
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-8
|
||||
/// Encode the canvas for a different display from now on.
|
||||
///
|
||||
/// Returns whether anything changed, so a caller polling for window moves
|
||||
/// can redraw only when the answer is genuinely different — the poll runs
|
||||
/// far more often than a monitor is changed, and a redraw per poll would
|
||||
/// undo the point of rendering on demand.
|
||||
///
|
||||
/// Nothing is invalidated here and nothing needs to be. The next
|
||||
/// [`Self::render`] composes against the new space, the pipeline cache
|
||||
/// distinguishes the two shaders by the structure hash the space enters,
|
||||
/// and the mask array — rasterised in source space, sampled through the
|
||||
/// framing — is unaffected because a colour space is not a geometry.
|
||||
pub fn set_display_space(&mut self, space: dr_types::ColourSpace) -> bool {
|
||||
let changed = self.display_space != space;
|
||||
self.display_space = space;
|
||||
changed
|
||||
}
|
||||
|
||||
/// The space the canvas is currently being encoded into.
|
||||
pub fn display_space(&self) -> dr_types::ColourSpace {
|
||||
self.display_space
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-1 | AC-8
|
||||
/// Render at the requested display size and hand back a Slint image.
|
||||
///
|
||||
@@ -2654,11 +2695,24 @@ impl DevelopSession {
|
||||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||||
let (w, h) = fit(fw, fh, width.max(1), height.max(1));
|
||||
|
||||
let shader = self.graph.compose();
|
||||
// TRACES: FR-DSP-8 | FR-DSP-6
|
||||
// **Composed for the display that is showing this canvas**, not for
|
||||
// sRGB. This is the whole of FR-DSP-8's second half arriving at the
|
||||
// pipeline: a display change is a *recomposition* and nothing more,
|
||||
// because the output space was always a parameter of composition and
|
||||
// always entered the structure hash. Moving the window to a P3 panel
|
||||
// therefore costs one shader compile and no pipeline change at all.
|
||||
let space = self.display_space;
|
||||
let shader = self.graph.compose_for(space);
|
||||
|
||||
// Rasterise the masks first: the shader addresses array slices by
|
||||
// index, so the array has to describe *this* stack before it is bound.
|
||||
self.render_with_masks(&shader, w, h, dr_types::ColourSpace::Srgb)?;
|
||||
//
|
||||
// The same `space` to both, necessarily: where a detail stage exists
|
||||
// it is the *last* pass that performs the output transform, and two
|
||||
// halves composed for different spaces would encode the frame twice
|
||||
// or not at all.
|
||||
self.render_with_masks(&shader, w, h, space)?;
|
||||
let texture = self.adjust.output().ok_or("nothing was rendered")?;
|
||||
|
||||
// The import is fallible on format and usage only, and both are fixed
|
||||
@@ -2702,7 +2756,11 @@ impl DevelopSession {
|
||||
/// explicit about. It is in the output colour space, which is what
|
||||
/// FR-DSP-7 asks for — the levels counted are the levels the display will
|
||||
/// show, so a clipped bin means a highlight that is actually gone rather
|
||||
/// than one the transform might still recover. And when the view is zoomed
|
||||
/// than one the transform might still recover. Since FR-DSP-8 that is the
|
||||
/// space of *this display* rather than sRGB, which makes the reading more
|
||||
/// truthful and not less: a highlight that survives on a wide-gamut panel
|
||||
/// and clips on the laptop's screen genuinely is two different facts, and
|
||||
/// the histogram now reports whichever one the photographer is looking at. And when the view is zoomed
|
||||
/// or cropped it describes the visible region, not the whole file: a
|
||||
/// photographer inspecting a highlight at 4× is asking about *that*
|
||||
/// highlight, and a histogram of the parts of the frame off screen would
|
||||
@@ -4480,15 +4538,15 @@ mod tests {
|
||||
presentation: Some(Presentation {
|
||||
// Prefers a gradient handle; this frontend has none, so it
|
||||
// falls back to the next entry, which the canvas does host.
|
||||
widgets: &[WidgetKind::GradientHandle, WidgetKind::CropOverlay],
|
||||
widgets: vec![WidgetKind::GradientHandle, WidgetKind::CropOverlay],
|
||||
demand: WidgetDemand {
|
||||
two_dimensional: true,
|
||||
precise_pointing: false,
|
||||
},
|
||||
params: &[ParamId("a"), ParamId("b")],
|
||||
params: vec![ParamId("a"), ParamId("b")],
|
||||
}),
|
||||
params: vec![param("a"), param("b")],
|
||||
attributes: &[dr_pipeline::Attribute::Tone],
|
||||
attributes: vec![dr_pipeline::Attribute::Tone],
|
||||
};
|
||||
|
||||
assert!(rows_from(&[on_canvas]).is_empty());
|
||||
@@ -4612,7 +4670,7 @@ mod tests {
|
||||
id: ParamId("method"),
|
||||
label: LocalizedKey("param.invented.method"),
|
||||
kind: ParamKind::Enum {
|
||||
variants: &[
|
||||
variants: vec![
|
||||
LocalizedKey("param.invented.method.fast"),
|
||||
LocalizedKey("param.invented.method.exact"),
|
||||
],
|
||||
@@ -4622,7 +4680,7 @@ mod tests {
|
||||
facet: None,
|
||||
},
|
||||
],
|
||||
attributes: &[dr_pipeline::Attribute::Tone],
|
||||
attributes: vec![dr_pipeline::Attribute::Tone],
|
||||
};
|
||||
|
||||
let rows = rows_from(&[invented]);
|
||||
@@ -4660,12 +4718,12 @@ mod tests {
|
||||
label: LocalizedKey("op.grading"),
|
||||
active: false,
|
||||
presentation: Some(Presentation {
|
||||
widgets: &[WidgetKind::ColourWheel],
|
||||
widgets: vec![WidgetKind::ColourWheel],
|
||||
demand: WidgetDemand {
|
||||
two_dimensional: true,
|
||||
precise_pointing: false,
|
||||
},
|
||||
params: &[ParamId("hue"), ParamId("strength")],
|
||||
params: vec![ParamId("hue"), ParamId("strength")],
|
||||
}),
|
||||
params: vec![
|
||||
ParamCapability {
|
||||
@@ -4697,7 +4755,7 @@ mod tests {
|
||||
facet: None,
|
||||
},
|
||||
],
|
||||
attributes: &[dr_pipeline::Attribute::Tone],
|
||||
attributes: vec![dr_pipeline::Attribute::Tone],
|
||||
};
|
||||
|
||||
assert!(!supported(WidgetKind::ColourWheel), "precondition");
|
||||
@@ -5146,15 +5204,15 @@ mod tests {
|
||||
label: LocalizedKey("op.invented_curve"),
|
||||
active: false,
|
||||
presentation: Some(Presentation {
|
||||
widgets: &[WidgetKind::ToneCurve],
|
||||
widgets: vec![WidgetKind::ToneCurve],
|
||||
demand: WidgetDemand {
|
||||
two_dimensional: true,
|
||||
precise_pointing: true,
|
||||
},
|
||||
params: &IDS,
|
||||
params: IDS.to_vec(),
|
||||
}),
|
||||
params: IDS.iter().map(|id| param(*id)).collect(),
|
||||
attributes: &[dr_pipeline::Attribute::Tone],
|
||||
attributes: vec![dr_pipeline::Attribute::Tone],
|
||||
};
|
||||
|
||||
let presentation = plain.presentation.as_ref().expect("declares a widget");
|
||||
@@ -5407,4 +5465,111 @@ mod tests {
|
||||
session.set_active_tab(99);
|
||||
assert_eq!(session.rows().len(), all);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Per-display colour (FR-DSP-8)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/// TRACES: FR-DSP-8 | FR-DSP-6
|
||||
/// The canvas is encoded for the display, not always for sRGB.
|
||||
///
|
||||
/// This is the assertion the requirement is actually about. Before it,
|
||||
/// `render` composed `ColourSpace::Srgb` unconditionally, and a second
|
||||
/// monitor with a different profile got sRGB pixels *labelled* as its own
|
||||
/// space by the compositor — the silent wrongness FR-DSP-8 calls a
|
||||
/// correctness defect. If someone re-hardcodes the space, these pixels
|
||||
/// stop differing and this fails.
|
||||
///
|
||||
/// A saturated red is the probe deliberately: it sits near the edge of
|
||||
/// sRGB's gamut, so re-encoding it into a wider one moves it a long way,
|
||||
/// where a mid grey would move by almost nothing in any of the four and
|
||||
/// the test would pass on a broken build.
|
||||
#[test]
|
||||
fn the_canvas_is_encoded_for_the_display_showing_it() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [230u8, 20, 20, 255]).collect();
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
|
||||
assert_eq!(
|
||||
session.display_space(),
|
||||
dr_types::ColourSpace::Srgb,
|
||||
"a session starts on the fallback, so nothing changes for a \
|
||||
desktop whose display server will not say otherwise"
|
||||
);
|
||||
let on_srgb = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||||
|
||||
assert!(session.set_display_space(dr_types::ColourSpace::AdobeRgb));
|
||||
let on_wide = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||||
|
||||
assert_ne!(
|
||||
on_srgb, on_wide,
|
||||
"the same edit rendered for two displays produced the same pixels"
|
||||
);
|
||||
|
||||
// And back again, because a photographer dragging a window between
|
||||
// two monitors expects the first one to look as it did rather than to
|
||||
// accumulate a transform.
|
||||
assert!(session.set_display_space(dr_types::ColourSpace::Srgb));
|
||||
let returned = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||||
assert_eq!(on_srgb, returned);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-8
|
||||
/// A move that changes nothing reports nothing, so nothing is redrawn.
|
||||
///
|
||||
/// The window's position is polled twice a second and two displays often
|
||||
/// share a profile. A setter that reported a change every time it was
|
||||
/// called would turn that poll into a redraw loop.
|
||||
#[test]
|
||||
fn setting_the_same_display_space_twice_is_not_a_change() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
|
||||
assert!(session.set_display_space(dr_types::ColourSpace::DisplayP3));
|
||||
assert!(!session.set_display_space(dr_types::ColourSpace::DisplayP3));
|
||||
assert_eq!(session.display_space(), dr_types::ColourSpace::DisplayP3);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-8 | FR-EXP-2
|
||||
/// The display's space is the *canvas's*, and reaches nothing else.
|
||||
///
|
||||
/// A thumbnail goes into a shard that syncs between devices and an export
|
||||
/// claims the space the export dialogue asked for. Letting the monitor in
|
||||
/// front of the photographer decide either would write a file whose
|
||||
/// profile describes the desk it was made at.
|
||||
#[test]
|
||||
fn a_wide_gamut_monitor_does_not_reach_the_thumbnail_or_the_export() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let rgba: Vec<u8> = (0..32 * 32).flat_map(|_| [230u8, 20, 20, 255]).collect();
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
|
||||
let thumb_before = session.render_thumbnail(16).expect("thumbnail");
|
||||
let export_before = session
|
||||
.render_for_export(dr_types::ColourSpace::Srgb)
|
||||
.expect("export")
|
||||
.rgba;
|
||||
|
||||
session.set_display_space(dr_types::ColourSpace::ProPhoto);
|
||||
|
||||
assert_eq!(
|
||||
session.render_thumbnail(16).expect("thumbnail"),
|
||||
thumb_before,
|
||||
"the grid's thumbnail followed the monitor"
|
||||
);
|
||||
assert_eq!(
|
||||
session
|
||||
.render_for_export(dr_types::ColourSpace::Srgb)
|
||||
.expect("export")
|
||||
.rgba,
|
||||
export_before,
|
||||
"an sRGB export followed the monitor"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
//! TRACES: FR-DSP-8
|
||||
//! Following the canvas from one display to the next.
|
||||
//!
|
||||
//! `dr_plat::display` establishes what each display *is*. This decides which
|
||||
//! of them is showing the canvas right now, keeps the develop session's output
|
||||
//! space pointed at it, and renders at that display's physical pixel size
|
||||
//! rather than its logical one.
|
||||
//!
|
||||
//! # Why a poll and not a callback
|
||||
//!
|
||||
//! Slint reports a window's size and its scale factor and does not report that
|
||||
//! it has moved — there is no `on_moved`, on any backend. What there is, is
|
||||
//! `Window::position()`, so this samples it. A sample is three integers
|
||||
//! compared against three integers and happens twice a second; a monitor is
|
||||
//! changed at human speed, and nothing here runs on the frame path.
|
||||
//!
|
||||
//! The re-survey is what costs something — a Wayland roundtrip pair, or an X11
|
||||
//! property fetch — so it is deliberately *not* done every tick. It runs when
|
||||
//! the geometry actually changed, which is also the moment a monitor could
|
||||
//! have been plugged in or unplugged: both of those move or rescale the window
|
||||
//! on every desktop that does anything sensible.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use dr_plat::DisplaySurvey;
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::AppWindow;
|
||||
|
||||
/// How often the window's geometry is sampled.
|
||||
const POLL: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
|
||||
/// The render-target size for a canvas that occupies `logical` logical pixels
|
||||
/// on a display scaled by `scale`.
|
||||
///
|
||||
/// # FR-DSP-8's scaling clause, in one multiplication
|
||||
///
|
||||
/// "Fractional and mixed DPI scaling are handled without resampling artefacts
|
||||
/// in the canvas." The canvas is a `wgpu::Texture` handed straight to the
|
||||
/// compositor (ARCH §6.1), which draws it into a box measured in *logical*
|
||||
/// pixels. Render 1600 logical pixels wide onto a display at 1.25 and the
|
||||
/// compositor has 1600 samples to fill 2000 device pixels, so every frame is
|
||||
/// resampled up by a quarter — a softness that reads like a bad demosaic
|
||||
/// rather than like a scaling bug, which is exactly why the requirement calls
|
||||
/// it out and why it is worth an explicit test.
|
||||
///
|
||||
/// Rendering the physical count instead makes the presentation 1:1. It costs
|
||||
/// what the extra pixels cost — 56% more work at 1.25 — and that cost is the
|
||||
/// requirement: the alternative is not cheaper, it is blurrier.
|
||||
///
|
||||
/// A `scale` of zero or worse is clamped rather than trusted. It arrives from
|
||||
/// the windowing system, and a zero would collapse the render target to one
|
||||
/// pixel and blank the canvas.
|
||||
pub(crate) fn physical(logical: (u32, u32), scale: f32) -> (u32, u32) {
|
||||
let scale = if scale.is_finite() && scale > 0.01 {
|
||||
scale
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let convert = |v: u32| ((v as f32) * scale).round().max(1.0) as u32;
|
||||
(convert(logical.0), convert(logical.1))
|
||||
}
|
||||
|
||||
/// The geometry the window had when the display was last resolved.
|
||||
///
|
||||
/// Position *and* scale factor, because either changing can mean a different
|
||||
/// display: dragging across a seam moves the window, and a compositor that
|
||||
/// rescales in place — a fractional-scaling change, or a monitor unplugged
|
||||
/// from under a window — changes only the second.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
struct Geometry {
|
||||
x: i32,
|
||||
y: i32,
|
||||
scale_milli: u32,
|
||||
}
|
||||
|
||||
impl Geometry {
|
||||
fn of(window: &AppWindow) -> Self {
|
||||
let position = window.window().position();
|
||||
Self {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
// Compared as thousandths rather than as a float: this is an
|
||||
// equality test running twice a second, and a scale factor that
|
||||
// differs in its last bit must not read as a display change and
|
||||
// provoke a re-survey on every tick.
|
||||
scale_milli: (window.window().scale_factor().max(0.0) * 1000.0) as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the window needs to keep the canvas on the right display.
|
||||
pub(crate) struct DisplayWatch {
|
||||
survey: RefCell<DisplaySurvey>,
|
||||
/// Which entry of the survey is showing the canvas.
|
||||
current: Cell<usize>,
|
||||
seen: Cell<Option<Geometry>>,
|
||||
/// The canvas's size in *logical* pixels, as Slint last reported it.
|
||||
///
|
||||
/// Kept so that a scale-factor change can re-derive the physical size
|
||||
/// without waiting for a resize that may never come: moving a window
|
||||
/// between a 1× and a 2× display changes what to render and not how large
|
||||
/// the box is.
|
||||
logical_canvas: Cell<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl DisplayWatch {
|
||||
/// Ask the platform once, at startup.
|
||||
pub(crate) fn probe() -> Rc<Self> {
|
||||
let survey = DisplaySurvey::probe();
|
||||
log::info!(
|
||||
"display server: {}, {} display(s)",
|
||||
survey.server.label(),
|
||||
survey.displays.len()
|
||||
);
|
||||
for display in &survey.displays {
|
||||
log::info!(" {}: {}", display.name, display.profile.describe());
|
||||
}
|
||||
Rc::new(Self {
|
||||
survey: RefCell::new(survey),
|
||||
current: Cell::new(0),
|
||||
seen: Cell::new(None),
|
||||
logical_canvas: Cell::new((1024, 768)),
|
||||
})
|
||||
}
|
||||
|
||||
/// The output space the canvas should be encoded into right now.
|
||||
pub(crate) fn space(&self) -> dr_types::ColourSpace {
|
||||
self.survey.borrow().profile(self.current.get()).space
|
||||
}
|
||||
|
||||
/// Record the canvas's logical size and return the size to render at.
|
||||
pub(crate) fn canvas_resized(&self, window: &AppWindow, logical: (u32, u32)) -> (u32, u32) {
|
||||
self.logical_canvas.set(logical);
|
||||
physical(logical, window.window().scale_factor())
|
||||
}
|
||||
|
||||
/// The size the canvas should be rendered at, from the last known logical
|
||||
/// size and the current scale factor.
|
||||
fn canvas_physical(&self, window: &AppWindow) -> (u32, u32) {
|
||||
physical(self.logical_canvas.get(), window.window().scale_factor())
|
||||
}
|
||||
|
||||
/// Push the About-page readouts.
|
||||
fn publish(&self, window: &AppWindow) {
|
||||
let Some(readouts) = readouts(&self.survey.borrow(), self.current.get()) else {
|
||||
return;
|
||||
};
|
||||
window.set_display_name(readouts.name.into());
|
||||
window.set_display_colour(readouts.colour.into());
|
||||
window.set_display_others(readouts.others.into());
|
||||
}
|
||||
|
||||
/// Re-ask the platform and re-resolve which display is showing the canvas.
|
||||
///
|
||||
/// Returns whether the output space changed, which is what decides a
|
||||
/// redraw. Nothing is pushed into the develop session from here: the
|
||||
/// session is asked for its space on the way into every render (see
|
||||
/// `render_now` in the crate root), so "the canvas is composed for the
|
||||
/// display showing it" is structural rather than something a callback has
|
||||
/// to remember to do. A session opened while the window sits on the second
|
||||
/// monitor is then right on its first frame, without this having to know
|
||||
/// that a photograph was opened at all.
|
||||
fn resolve(&self, window: &AppWindow) -> bool {
|
||||
let before = self.space();
|
||||
*self.survey.borrow_mut() = DisplaySurvey::probe();
|
||||
|
||||
// The window's *centre*, in the display server's own screen
|
||||
// coordinates. A window straddling a seam is showing more of itself on
|
||||
// one side, and its centre says which — where its top-left corner
|
||||
// would flip the transform as soon as one pixel crossed.
|
||||
let position = window.window().position();
|
||||
let size = window.window().size();
|
||||
let centre_x = position.x.saturating_add_unsigned(size.width / 2);
|
||||
let centre_y = position.y.saturating_add_unsigned(size.height / 2);
|
||||
|
||||
let index = self.survey.borrow().containing(centre_x, centre_y);
|
||||
self.current.set(index);
|
||||
self.publish(window);
|
||||
self.space() != before
|
||||
}
|
||||
}
|
||||
|
||||
/// The three strings the About page shows about the session's colour path.
|
||||
pub(crate) struct Readouts {
|
||||
pub name: String,
|
||||
pub colour: String,
|
||||
pub others: String,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-8
|
||||
/// Turn a survey into what the About page says.
|
||||
///
|
||||
/// A free function over the survey rather than a method that writes into the
|
||||
/// window, because the requirement's visibility clause is the part worth
|
||||
/// asserting: FR-DSP-8 asks for a fallback that is *defined*, and a reason
|
||||
/// that reaches the code but never the screen satisfies half of that. This is
|
||||
/// where a test can hold the strings.
|
||||
///
|
||||
/// `None` only for a survey with no displays at all, which `probe` does not
|
||||
/// produce.
|
||||
pub(crate) fn readouts(survey: &DisplaySurvey, index: usize) -> Option<Readouts> {
|
||||
// Clamped rather than indexed: a monitor unplugged between the survey and
|
||||
// this call leaves an index pointing past the end, and the page must
|
||||
// describe *a* display rather than nothing.
|
||||
let index = if index < survey.displays.len() {
|
||||
index
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let here = survey.displays.get(index)?;
|
||||
|
||||
// The other monitors, listed rather than hidden. FR-DSP-8 is a
|
||||
// multi-monitor requirement and the failure it names — the second display
|
||||
// showing wrong colours — is invisible from the first, so a page that
|
||||
// described only the display it was being read on would report nothing
|
||||
// about the case that matters.
|
||||
let others: Vec<String> = survey
|
||||
.displays
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != index)
|
||||
.map(|(_, d)| format!("{}: {}", d.name, d.profile.describe()))
|
||||
.collect();
|
||||
|
||||
Some(Readouts {
|
||||
name: format!("{} ({})", here.name, survey.server.label()),
|
||||
colour: here.profile.describe(),
|
||||
others: others.join(" · "),
|
||||
})
|
||||
}
|
||||
|
||||
/// Start following the window's display, and answer once immediately.
|
||||
///
|
||||
/// `redraw` is the window's ordinary re-render, so a display change costs
|
||||
/// exactly one recomposition and one dispatch — the property the fused design
|
||||
/// was always going to give us here (`compose_with_framing`'s output space is
|
||||
/// a parameter, and enters the structure hash).
|
||||
pub(crate) fn attach(
|
||||
window: &AppWindow,
|
||||
watch: &Rc<DisplayWatch>,
|
||||
viewport: &Rc<RefCell<(u32, u32)>>,
|
||||
redraw: Rc<dyn Fn(&AppWindow)>,
|
||||
) {
|
||||
// The first answer, before any frame is drawn. Without it the canvas's
|
||||
// first render is sRGB on every desktop and corrects itself half a second
|
||||
// later, which on a wide-gamut panel is a visible flash of the wrong
|
||||
// colour on every image opened.
|
||||
watch.seen.set(Some(Geometry::of(window)));
|
||||
watch.resolve(window);
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
let weak = window.as_weak();
|
||||
let watch = watch.clone();
|
||||
let viewport = viewport.clone();
|
||||
timer.start(slint::TimerMode::Repeated, POLL, move || {
|
||||
let Some(window) = weak.upgrade() else { return };
|
||||
let now = Geometry::of(&window);
|
||||
if watch.seen.get() == Some(now) {
|
||||
return;
|
||||
}
|
||||
watch.seen.set(Some(now));
|
||||
|
||||
// The physical size follows the scale factor, so a move onto a
|
||||
// differently-scaled display changes what to render as well as what
|
||||
// to render it into (FR-DSP-8's two halves arriving together).
|
||||
let size = watch.canvas_physical(&window);
|
||||
let resized = *viewport.borrow() != size;
|
||||
if resized {
|
||||
*viewport.borrow_mut() = size;
|
||||
}
|
||||
|
||||
if watch.resolve(&window) || resized {
|
||||
redraw(&window);
|
||||
}
|
||||
});
|
||||
// The timer stops when it is dropped, and it must outlive this function.
|
||||
// Leaked deliberately rather than threaded through the window's state:
|
||||
// it lives exactly as long as the process, and there is nothing that
|
||||
// could sensibly stop it.
|
||||
std::mem::forget(timer);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_plat::{Bounds, DisplayInfo, DisplayProfile, DisplayServer, FallbackReason};
|
||||
|
||||
fn two_monitors() -> DisplaySurvey {
|
||||
DisplaySurvey {
|
||||
server: DisplayServer::X11,
|
||||
displays: vec![
|
||||
DisplayInfo {
|
||||
name: "eDP-1".to_string(),
|
||||
bounds: Some(Bounds {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1200,
|
||||
}),
|
||||
profile: DisplayProfile::fallback(FallbackReason::NoWaylandProtocol),
|
||||
},
|
||||
DisplayInfo {
|
||||
name: "DP-2".to_string(),
|
||||
bounds: Some(Bounds {
|
||||
x: 1920,
|
||||
y: 0,
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
}),
|
||||
profile: DisplayProfile {
|
||||
space: dr_types::ColourSpace::DisplayP3,
|
||||
source: dr_plat::ProfileSource::X11RootProperty(
|
||||
"_ICC_PROFILE_1".to_string(),
|
||||
),
|
||||
described_as: Some("EIZO CG279X".to_string()),
|
||||
approximated: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-8
|
||||
/// The fallback is defined *and visible*, which is what the requirement
|
||||
/// asks for and the half that is easy to leave out.
|
||||
#[test]
|
||||
fn the_about_page_says_which_acquisition_path_the_session_is_on() {
|
||||
let survey = two_monitors();
|
||||
let shown = readouts(&survey, 0).expect("a display");
|
||||
|
||||
assert!(shown.name.contains("eDP-1"), "{}", shown.name);
|
||||
assert!(shown.name.contains("X11"), "{}", shown.name);
|
||||
// A photographer being shown sRGB because the compositor would not say
|
||||
// otherwise must be able to find that out rather than wonder.
|
||||
assert!(shown.colour.contains("sRGB"), "{}", shown.colour);
|
||||
assert!(shown.colour.contains("assumed"), "{}", shown.colour);
|
||||
assert!(
|
||||
shown.colour.contains("colour management"),
|
||||
"the reason for the fallback did not reach the page: {}",
|
||||
shown.colour
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-8
|
||||
/// The second monitor is described on the page read on the first.
|
||||
///
|
||||
/// The requirement's whole subject is the display the reader is *not*
|
||||
/// looking at: "showing wrong colours on the second display is a
|
||||
/// correctness defect". A page that listed only the current one would say
|
||||
/// nothing about the case it exists for.
|
||||
#[test]
|
||||
fn the_other_monitor_is_named_on_the_page_read_from_this_one() {
|
||||
let survey = two_monitors();
|
||||
let shown = readouts(&survey, 0).expect("a display");
|
||||
assert!(shown.others.contains("DP-2"), "{}", shown.others);
|
||||
assert!(shown.others.contains("Display P3"), "{}", shown.others);
|
||||
// Approximated, and saying so. An honest approximation the user can
|
||||
// see is the whole trade made in `dr_plat::display`.
|
||||
assert!(shown.others.contains("nearest to"), "{}", shown.others);
|
||||
assert!(shown.others.contains("EIZO CG279X"), "{}", shown.others);
|
||||
|
||||
// And from the second monitor's point of view, the first is the other.
|
||||
let shown = readouts(&survey, 1).expect("a display");
|
||||
assert!(shown.colour.contains("Display P3"), "{}", shown.colour);
|
||||
assert!(shown.others.contains("eDP-1"), "{}", shown.others);
|
||||
}
|
||||
|
||||
/// A single-monitor desktop leaves the row empty rather than repeating
|
||||
/// itself, which is what makes the row conditional in `settings.slint`.
|
||||
#[test]
|
||||
fn one_display_has_no_others_to_list() {
|
||||
let survey = DisplaySurvey::assumed_srgb(FallbackReason::NoWaylandProtocol);
|
||||
let shown = readouts(&survey, 0).expect("a display");
|
||||
assert!(shown.others.is_empty(), "{}", shown.others);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-8
|
||||
/// Fractional scaling renders more pixels, not the same pixels stretched.
|
||||
#[test]
|
||||
fn a_fractionally_scaled_canvas_is_rendered_at_its_physical_size() {
|
||||
// GNOME's fractional steps, against a canvas of a plausible size. The
|
||||
// failure being guarded is silent: at 1.25 the compositor is handed
|
||||
// 1600 samples for 2000 device pixels and the photograph goes soft.
|
||||
assert_eq!(physical((1600, 1000), 1.25), (2000, 1250));
|
||||
assert_eq!(physical((1600, 1000), 1.5), (2400, 1500));
|
||||
assert_eq!(physical((1600, 1000), 1.75), (2800, 1750));
|
||||
assert_eq!(physical((1600, 1000), 2.0), (3200, 2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unscaled_display_renders_exactly_what_it_is_asked_for() {
|
||||
// The other half of the same requirement: 1:1 must stay 1:1 rather
|
||||
// than acquiring a rounding error that resamples every frame by a
|
||||
// fraction of a pixel.
|
||||
assert_eq!(physical((1923, 1081), 1.0), (1923, 1081));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nonsense_scale_factor_does_not_blank_the_canvas() {
|
||||
// It comes from the windowing system, and a zero would collapse the
|
||||
// render target to a single pixel with no error anywhere.
|
||||
assert_eq!(physical((800, 600), 0.0), (800, 600));
|
||||
assert_eq!(physical((800, 600), f32::NAN), (800, 600));
|
||||
assert_eq!(physical((800, 600), -2.0), (800, 600));
|
||||
// And a canvas that has been collapsed to nothing by a dragged
|
||||
// splitter still has to produce a renderable target.
|
||||
assert_eq!(physical((0, 0), 2.0), (1, 1));
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,8 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use dr_export::{Encoded, NameContext};
|
||||
use dr_sync::{RemoteBackend, RemotePath};
|
||||
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
||||
use dr_sync::RemotePath;
|
||||
use dr_sync_nextcloud::AppCredentials;
|
||||
use dr_types::{ExportSettings, ExportTarget};
|
||||
|
||||
use crate::AppWindow;
|
||||
@@ -339,7 +339,7 @@ pub fn spawn_upload(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
||||
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let _ = tx.send(UploadMessage::Finished {
|
||||
|
||||
@@ -56,7 +56,7 @@ use std::sync::Arc;
|
||||
use dr_ingest::{Candidate, DupKey, Imported, Ingest, Options, Report, Shot, TransferMode};
|
||||
use dr_plat::{DirRef, LocalStorage, Storage, WritableStorage};
|
||||
use dr_sync::{RemoteBackend, RemotePath};
|
||||
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
||||
use dr_sync_nextcloud::AppCredentials;
|
||||
use dr_types::{FormatFilter, RootId};
|
||||
|
||||
/// Which root the card is granted as, and which the library is.
|
||||
@@ -385,7 +385,7 @@ fn upload_all(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match NextcloudBackend::new(&upload.credentials, &upload.user_id) {
|
||||
let backend = match crate::remote::connect(&upload.credentials, &upload.user_id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!("connecting to upload: {e}");
|
||||
@@ -423,7 +423,7 @@ fn upload_all(
|
||||
|
||||
// The same folder segments the local copy went into, so the two
|
||||
// libraries have the same shape (FR-NC-7a).
|
||||
match dr_sync::upload_original(&backend, &root, &image.folders, &image.name, bytes)
|
||||
match dr_sync::upload_original(&*backend, &root, &image.folders, &image.name, bytes)
|
||||
.await
|
||||
{
|
||||
Ok(placed) => {
|
||||
@@ -445,7 +445,7 @@ fn upload_all(
|
||||
Err(e) => log::warn!("uploading {}: {e}", image.name),
|
||||
}
|
||||
}
|
||||
let filed = file_thumbnails(&backend, &root, imported, &sent, &upload.thumbs).await;
|
||||
let filed = file_thumbnails(&*backend, &root, imported, &sent, &upload.thumbs).await;
|
||||
(sent, filed)
|
||||
})
|
||||
}
|
||||
@@ -502,7 +502,7 @@ fn make_thumbnail(bytes: &[u8]) -> Option<dr_thumbs::Thumbnail> {
|
||||
/// Best-effort throughout. A thumbnail that cannot be filed costs the grid one
|
||||
/// preview fetch later; failing the import over it would be the wrong trade.
|
||||
async fn file_thumbnails(
|
||||
backend: &NextcloudBackend,
|
||||
backend: &dyn RemoteBackend,
|
||||
library: &RemotePath,
|
||||
imported: &[Imported],
|
||||
sent: &[Sent],
|
||||
|
||||
@@ -8,8 +8,8 @@ use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use dr_plat::PlatformSecretStore;
|
||||
use dr_sync::{RemoteBackend, RemotePath};
|
||||
use dr_sync_nextcloud::{auth, NextcloudBackend, Session, SessionStore};
|
||||
use dr_sync::RemotePath;
|
||||
use dr_sync_nextcloud::{auth, Session, SessionStore};
|
||||
|
||||
use slint::ComponentHandle;
|
||||
|
||||
@@ -608,7 +608,7 @@ fn spawn_folder_list(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, pa
|
||||
return;
|
||||
};
|
||||
rt.block_on(async {
|
||||
match NextcloudBackend::new(&creds, &user_id) {
|
||||
match crate::remote::connect(&creds, &user_id) {
|
||||
Ok(b) => match b.list(&RemotePath::new(&path), None).await {
|
||||
Ok(entries) => {
|
||||
let mut dirs: Vec<String> = entries
|
||||
|
||||
+44
-1
@@ -23,6 +23,7 @@ mod activity;
|
||||
mod collections_ui;
|
||||
mod derived_sync;
|
||||
mod develop;
|
||||
mod display_ui;
|
||||
mod export;
|
||||
pub mod faces;
|
||||
mod gradient;
|
||||
@@ -39,6 +40,7 @@ mod live_style;
|
||||
mod masks_ui;
|
||||
mod net_runtime;
|
||||
mod presets;
|
||||
mod remote;
|
||||
mod segmentation;
|
||||
mod settings_store;
|
||||
mod settings_ui;
|
||||
@@ -1381,8 +1383,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let rows: Rc<slint::VecModel<ParamRow>> = Rc::new(slint::VecModel::default());
|
||||
window.set_adjust_rows(rows.clone().into());
|
||||
// Viewport size, tracked so a re-render after a slider move matches it.
|
||||
//
|
||||
// In *physical* pixels, which is what the render target wants and what
|
||||
// FR-DSP-8 means by handling fractional scaling without resampling. The
|
||||
// one caller that thinks in logical pixels is Slint's resize callback,
|
||||
// and it converts on the way in.
|
||||
let viewport = Rc::new(RefCell::new((1024u32, 768u32)));
|
||||
|
||||
// TRACES: FR-DSP-8
|
||||
// What the displays are, and which one the canvas is on. Probed once here
|
||||
// so that the About page can describe the session's colour path before any
|
||||
// photograph is opened — the acquisition path is a property of the desktop
|
||||
// and not of the image.
|
||||
let display = display_ui::DisplayWatch::probe();
|
||||
|
||||
// Re-render the current session into the canvas.
|
||||
//
|
||||
// Called on every slider change, so it must do no more than run the
|
||||
@@ -1397,10 +1411,26 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let session = session.clone();
|
||||
let viewport = viewport.clone();
|
||||
let drawn_history = drawn_history.clone();
|
||||
let display = display.clone();
|
||||
Rc::new(move |window: &AppWindow, draft: bool| {
|
||||
let mut slot = session.borrow_mut();
|
||||
let Some(s) = slot.as_mut() else { return };
|
||||
|
||||
// TRACES: FR-DSP-8 | FR-DSP-6
|
||||
// **Every canvas render is encoded for the display showing it.**
|
||||
//
|
||||
// Set here rather than pushed from the display watch, and rather
|
||||
// than set once when a photograph is opened, because this is the
|
||||
// one path every frame takes. A session opened while the window
|
||||
// sits on the second monitor is then correct on its *first* frame
|
||||
// — where a push would leave it sRGB until the next poll, which is
|
||||
// a visible flash of the wrong colour on every image opened.
|
||||
//
|
||||
// Free when nothing has changed: the field is compared before it
|
||||
// is written, and an unchanged output space composes to the same
|
||||
// structure hash and the same cached pipeline.
|
||||
s.set_display_space(display.space());
|
||||
|
||||
// TRACES: FR-DEV-5
|
||||
// Whether undo has anywhere to go, pushed from here because every
|
||||
// edit ends in a redraw and nothing else is on all of their paths:
|
||||
@@ -2574,13 +2604,22 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
|
||||
// Track the canvas size so the adjust pass renders at viewport
|
||||
// resolution rather than sensor resolution (FR-DSP-1).
|
||||
//
|
||||
// TRACES: FR-DSP-8
|
||||
// Slint reports the canvas in *logical* pixels, which is the box the
|
||||
// compositor will draw into and not the number of device pixels it will
|
||||
// fill. `display_ui::physical` converts, so that a fractionally scaled
|
||||
// desktop is presented 1:1 rather than resampled — see that function for
|
||||
// why a soft canvas is the failure being avoided here.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let viewport = viewport.clone();
|
||||
let redraw = redraw.clone();
|
||||
let display = display.clone();
|
||||
window.on_canvas_resized(move |w_px, h_px| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let size = (w_px.max(1) as u32, h_px.max(1) as u32);
|
||||
let logical = (w_px.max(1) as u32, h_px.max(1) as u32);
|
||||
let size = display.canvas_resized(&w, logical);
|
||||
if *viewport.borrow() == size {
|
||||
return;
|
||||
}
|
||||
@@ -2589,6 +2628,10 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// 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());
|
||||
|
||||
// FR-UI-1: layout class from window width. Computed here rather than in
|
||||
// Slint because a property that both derives from and feeds the layout is
|
||||
// a binding loop.
|
||||
|
||||
+22
-22
@@ -26,7 +26,7 @@ use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use dr_catalog::{Catalog, JobKind, Priority};
|
||||
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
|
||||
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
||||
use dr_sync_nextcloud::AppCredentials;
|
||||
use dr_thumbs::ThumbStore;
|
||||
|
||||
use crate::sidecar_cache::SidecarCache;
|
||||
@@ -541,11 +541,11 @@ pub fn spawn_sidecar_writes(
|
||||
let report = match rt {
|
||||
None => queue_all(),
|
||||
Some(rt) => rt.block_on(async {
|
||||
match NextcloudBackend::new(&creds, &user_id) {
|
||||
match crate::remote::connect(&creds, &user_id) {
|
||||
Ok(b) => {
|
||||
let mut report = SidecarReport::default();
|
||||
for w in &writes {
|
||||
match write_one_sidecar_online(&b, &cache, w).await {
|
||||
match write_one_sidecar_online(&*b, &cache, w).await {
|
||||
Ok(Outcome::Uploaded) => report.written += 1,
|
||||
Ok(Outcome::Queued) => report.queued += 1,
|
||||
Err(e) => {
|
||||
@@ -707,7 +707,7 @@ fn write_one_sidecar(cache: &SidecarCache, w: &SidecarWrite) -> Result<Outcome,
|
||||
/// TRACES: FR-CAT-8 | FR-CAT-9
|
||||
/// Read-modify-write one sidecar, with a server to read from and send to.
|
||||
async fn write_one_sidecar_online(
|
||||
backend: &NextcloudBackend,
|
||||
backend: &dyn RemoteBackend,
|
||||
cache: &SidecarCache,
|
||||
w: &SidecarWrite,
|
||||
) -> Result<Outcome, String> {
|
||||
@@ -810,7 +810,7 @@ pub fn spawn_outbox_drain(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
||||
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let _ = tx.send(SidecarMessage::Finished {
|
||||
@@ -825,7 +825,7 @@ pub fn spawn_outbox_drain(
|
||||
|
||||
let mut report = SidecarReport::default();
|
||||
for path_str in &queued {
|
||||
match drain_one(&backend, &cache, path_str).await {
|
||||
match drain_one(&*backend, &cache, path_str).await {
|
||||
Ok(()) => report.written += 1,
|
||||
Err(e) => {
|
||||
// Warn, for the reason the write path does: this is
|
||||
@@ -854,7 +854,7 @@ pub fn spawn_outbox_drain(
|
||||
|
||||
/// Reconcile one queued sidecar with the server and upload it.
|
||||
async fn drain_one(
|
||||
backend: &NextcloudBackend,
|
||||
backend: &dyn RemoteBackend,
|
||||
cache: &SidecarCache,
|
||||
path_str: &str,
|
||||
) -> Result<(), String> {
|
||||
@@ -1100,14 +1100,14 @@ fn run_scan(
|
||||
let rt = crate::net_runtime::build().map_err(ScanFailure::local)?;
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = NextcloudBackend::new(&creds, &user_id).map_err(ScanFailure::local)?;
|
||||
let backend = crate::remote::connect(&creds, &user_id).map_err(ScanFailure::local)?;
|
||||
|
||||
// Stored folder ETags, so an unchanged subtree is skipped whole. On a
|
||||
// first run this is empty and the walk is complete; on every run after
|
||||
// it is what keeps cost proportional to what changed (ARCH §8.4).
|
||||
let known = load_folder_etags(&catalog, &root);
|
||||
|
||||
let result = dr_sync::scan(&backend, &RemotePath::new(&root), &filter, &known, |p| {
|
||||
let result = dr_sync::scan(&*backend, &RemotePath::new(&root), &filter, &known, |p| {
|
||||
let _ = tx.send(ScanMessage::Progress {
|
||||
directories: p.directories_listed,
|
||||
pruned: p.directories_pruned,
|
||||
@@ -1446,7 +1446,7 @@ pub fn spawn_pin_fetch(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
||||
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let _ = tx.send(PinMessage::Failed {
|
||||
@@ -1635,7 +1635,7 @@ pub fn spawn_sidecar_fetch(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
||||
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::debug!("sidecar fetch backend: {e}");
|
||||
@@ -1723,7 +1723,7 @@ pub fn spawn_full_fetch(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
||||
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let _ = tx.send(Err(FetchFailure::local(e)));
|
||||
@@ -1873,7 +1873,7 @@ pub fn spawn_thumbnails(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
||||
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
for req in &to_fetch {
|
||||
@@ -1898,7 +1898,7 @@ pub fn spawn_thumbnails(
|
||||
let mut offline = false;
|
||||
|
||||
for req in to_fetch {
|
||||
let msg = fetch_one(&backend, store.as_mut(), &req, &mut found).await;
|
||||
let msg = fetch_one(&*backend, store.as_mut(), &req, &mut found).await;
|
||||
offline = matches!(msg, ThumbnailMessage::Offline { .. });
|
||||
// A closed channel means the window went away mid-fetch.
|
||||
if tx.send(msg).is_err() || offline {
|
||||
@@ -1923,7 +1923,7 @@ pub fn spawn_thumbnails(
|
||||
if tx.send(ThumbnailMessage::DateProgress).is_err() {
|
||||
break;
|
||||
}
|
||||
read_metadata_only(&backend, &req, &mut found).await;
|
||||
read_metadata_only(&*backend, &req, &mut found).await;
|
||||
|
||||
if found.len() >= FLUSH_EVERY {
|
||||
flush_metadata(&catalog_path, &mut found, &tx);
|
||||
@@ -1942,7 +1942,7 @@ pub fn spawn_thumbnails(
|
||||
}
|
||||
|
||||
async fn fetch_one(
|
||||
backend: &NextcloudBackend,
|
||||
backend: &dyn RemoteBackend,
|
||||
store: Option<&mut ThumbStore>,
|
||||
req: &ThumbnailRequest,
|
||||
found_metadata: &mut Vec<MetadataFound>,
|
||||
@@ -1999,7 +1999,7 @@ enum PreviewOutcome {
|
||||
/// the embedded JPEG partway through, and decoders render a truncated JPEG as
|
||||
/// the top fraction of the frame rather than reporting an error.
|
||||
async fn fetch_preview(
|
||||
backend: &NextcloudBackend,
|
||||
backend: &dyn RemoteBackend,
|
||||
req: &ThumbnailRequest,
|
||||
found_metadata: &mut Vec<MetadataFound>,
|
||||
) -> PreviewOutcome {
|
||||
@@ -2219,7 +2219,7 @@ fn flush_metadata(
|
||||
/// happened both leave `found` untouched, and recording the second as "this
|
||||
/// image has no date" would let one lock mark it dateless for good.
|
||||
async fn read_metadata_only(
|
||||
backend: &NextcloudBackend,
|
||||
backend: &dyn RemoteBackend,
|
||||
req: &ThumbnailRequest,
|
||||
found: &mut Vec<MetadataFound>,
|
||||
) -> bool {
|
||||
@@ -2444,7 +2444,7 @@ pub fn spawn_sweep(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let Ok(backend) = NextcloudBackend::new(&creds, &user_id) else {
|
||||
let Ok(backend) = crate::remote::connect(&creds, &user_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -2483,7 +2483,7 @@ pub fn spawn_sweep(
|
||||
.collect();
|
||||
|
||||
let results = futures_join_all(lanes.into_iter().map(|lane| {
|
||||
let backend = &backend;
|
||||
let backend: &dyn RemoteBackend = &*backend;
|
||||
async move {
|
||||
let mut found = Vec::new();
|
||||
let mut reached = Vec::new();
|
||||
@@ -3213,7 +3213,7 @@ pub fn spawn_thumbnail_sweep(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
||||
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!("thumbnail sweep: {e}");
|
||||
@@ -3235,7 +3235,7 @@ pub fn spawn_thumbnail_sweep(
|
||||
.collect();
|
||||
|
||||
let results = futures_join_all(lanes.into_iter().map(|lane| {
|
||||
let backend = &backend;
|
||||
let backend: &dyn RemoteBackend = &*backend;
|
||||
async move {
|
||||
let mut made: Vec<(u64, dr_thumbs::Thumbnail)> = Vec::new();
|
||||
let mut found = Vec::new();
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// TRACES: FR-NC-12
|
||||
//! The one place the interface names a backend.
|
||||
//!
|
||||
//! `dr-sync` defines [`RemoteBackend`] and a capability model the engine adapts
|
||||
//! to, so that a second backend can be added without touching the code that
|
||||
//! uses one (ARCH §8.1). Until this module existed that boundary was
|
||||
//! documentation: seven files in `dr-ui` constructed a `NextcloudBackend`
|
||||
//! directly and ten functions took one by concrete type, so the abstraction
|
||||
//! bought nothing it was designed for and a WebDAV or local-folder backend
|
||||
//! would have had nowhere to go.
|
||||
//!
|
||||
//! Everything above this module now works through `&dyn RemoteBackend`. Adding
|
||||
//! a backend is implementing the trait and changing [`connect`] — not editing
|
||||
//! seven files.
|
||||
//!
|
||||
//! ## What is deliberately still Nextcloud-shaped
|
||||
//!
|
||||
//! Credentials. [`AppCredentials`] is an app password obtained through Login
|
||||
//! Flow v2, which is a Nextcloud protocol rather than a general notion of
|
||||
//! "how one authenticates to a remote". Abstracting it needs a decision about
|
||||
//! what an account *is* across backends — an OAuth token, a bucket key pair
|
||||
//! and an app password have no useful common shape — and inventing one before
|
||||
//! a second backend exists would produce a wrong answer confidently. That is
|
||||
//! the remaining half of this seam, and it is a design problem rather than a
|
||||
//! mechanical one.
|
||||
|
||||
use dr_sync::{RemoteBackend, RemoteError};
|
||||
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
||||
|
||||
/// Open a connection to the configured remote.
|
||||
///
|
||||
/// Returns the trait object every caller should hold. The error type is
|
||||
/// `dr-sync`'s rather than the connector's, so a caller handles a failure
|
||||
/// without learning which backend produced it.
|
||||
pub(crate) fn connect(
|
||||
creds: &AppCredentials,
|
||||
user_id: &str,
|
||||
) -> Result<Box<dyn RemoteBackend>, RemoteError> {
|
||||
Ok(Box::new(NextcloudBackend::new(creds, user_id)?))
|
||||
}
|
||||
@@ -619,8 +619,7 @@ pub fn spawn_folder_list(
|
||||
user_id: String,
|
||||
path: String,
|
||||
) {
|
||||
use dr_sync::{RemoteBackend, RemotePath};
|
||||
use dr_sync_nextcloud::NextcloudBackend;
|
||||
use dr_sync::RemotePath;
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Result<Vec<String>, String>>();
|
||||
|
||||
@@ -638,7 +637,7 @@ pub fn spawn_folder_list(
|
||||
return;
|
||||
};
|
||||
rt.block_on(async {
|
||||
match NextcloudBackend::new(&creds, &user_id) {
|
||||
match crate::remote::connect(&creds, &user_id) {
|
||||
Ok(b) => match b.list(&RemotePath::new(&path), None).await {
|
||||
Ok(entries) => {
|
||||
let mut dirs: Vec<String> = entries
|
||||
|
||||
@@ -31,8 +31,8 @@ use std::path::PathBuf;
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
use dr_catalog::{trash, Catalog};
|
||||
use dr_sync::{RemoteBackend, RemoteError, RemoteId, RemotePath};
|
||||
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
||||
use dr_sync::{RemoteError, RemoteId, RemotePath};
|
||||
use dr_sync_nextcloud::AppCredentials;
|
||||
use dr_types::ImageId;
|
||||
|
||||
/// What a trash operation reports back to the UI.
|
||||
@@ -184,7 +184,7 @@ pub fn spawn_move(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
||||
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let _ = tx.send(TrashMessage::Done {
|
||||
@@ -297,7 +297,7 @@ pub fn spawn_purge(
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
||||
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let _ = tx.send(TrashMessage::Done {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
// TRACES: FR-DEV-3a
|
||||
//! The interface must not name an operation.
|
||||
//!
|
||||
//! This is the property the whole declarative pipeline rests on. A node in
|
||||
//! `core/dr-pipeline/ops/` is one file precisely because nothing in `ui/` has
|
||||
//! to learn about it: the panel builds controls from the parameter kinds the
|
||||
//! core publishes and never asks which operation it is drawing (ARCH §4.3a,
|
||||
//! FR-DEV-3a).
|
||||
//!
|
||||
//! Until now that was maintained by discipline alone, and the failure mode is
|
||||
//! silent and cumulative. Special-casing one operation to fix a layout problem
|
||||
//! is defensible on its own; by the fifth the panel names half the chain and
|
||||
//! "a new operation is one file" has quietly stopped being true, without any
|
||||
//! single commit having broken it.
|
||||
//!
|
||||
//! ## What counts as naming
|
||||
//!
|
||||
//! An operation id appearing as a **string literal** — `"exposure"`, in Rust
|
||||
//! or in Slint. That is the form a special case takes, because the id is how
|
||||
//! the panel would have to recognise the operation it wanted to treat
|
||||
//! differently.
|
||||
//!
|
||||
//! Bare words are deliberately not matched. Three ids — `texture`, `contrast`
|
||||
//! and `clarity` — are also ordinary graphics and English terms, and `texture`
|
||||
//! in particular appears throughout `dr-ui` meaning a GPU texture. Matching
|
||||
//! those would produce a test that fails constantly for reasons unrelated to
|
||||
//! the invariant, which is a test people delete.
|
||||
//!
|
||||
//! ## What is exempt
|
||||
//!
|
||||
//! Code under `#[cfg(test)]`. The invariant is about the interface that ships;
|
||||
//! a unit test asserting that `labels::resolve("clarity")` yields `"Clarity"`
|
||||
//! is testing the localisation table, not special-casing an operation. There is
|
||||
//! exactly one such case today, in `labels.rs`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Below this, assume the scan broke rather than that the code is clean.
|
||||
///
|
||||
/// The dangerous failure of a test like this is not a false alarm, which
|
||||
/// someone investigates — it is silently examining nothing and reporting
|
||||
/// success. An early version cut each file at the first *textual* match of
|
||||
/// `#[cfg(test)]`, which in `develop.rs` is a doc comment discussing the
|
||||
/// attribute: it read 18% of the most important file in the scan and passed.
|
||||
///
|
||||
/// `dr-ui` is currently 75% shipping code by line. The floor is set well under
|
||||
/// that so ordinary movement does not trip it, and well over the number a
|
||||
/// parsing bug produces.
|
||||
const MIN_SHIPPING_FRACTION: f64 = 0.60;
|
||||
|
||||
/// Every operation id the pipeline declares.
|
||||
///
|
||||
/// Read from the declarations rather than listed here, so a node added
|
||||
/// tomorrow is covered without anyone remembering to extend this file — the
|
||||
/// same reason `traceability` parses its denominators from `requirements.md`
|
||||
/// at run time.
|
||||
fn operation_ids(ops_dir: &Path) -> Vec<String> {
|
||||
let mut ids = Vec::new();
|
||||
|
||||
let entries =
|
||||
fs::read_dir(ops_dir).unwrap_or_else(|e| panic!("cannot read {}: {e}", ops_dir.display()));
|
||||
|
||||
for entry in entries {
|
||||
let path = entry.expect("read dir entry").path();
|
||||
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
|
||||
continue;
|
||||
}
|
||||
// `_helpers.yaml` declares the shared WGSL library, not a node, and has
|
||||
// no `id:`. The leading underscore is the documented marker.
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with('_'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let text = fs::read_to_string(&path)
|
||||
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
|
||||
|
||||
let id = text
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("id:"))
|
||||
.map(str::trim)
|
||||
.unwrap_or_else(|| panic!("{} declares no id:", path.display()));
|
||||
|
||||
ids.push(id.to_string());
|
||||
}
|
||||
|
||||
assert!(
|
||||
!ids.is_empty(),
|
||||
"found no operation declarations in {} — the path is wrong, and a scan \
|
||||
over nothing passes for the wrong reason",
|
||||
ops_dir.display()
|
||||
);
|
||||
|
||||
ids
|
||||
}
|
||||
|
||||
/// Every `.rs` and `.slint` file under a directory.
|
||||
fn sources(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let entries =
|
||||
fs::read_dir(dir).unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()));
|
||||
|
||||
for entry in entries {
|
||||
let path = entry.expect("read dir entry").path();
|
||||
if path.is_dir() {
|
||||
sources(&path, out);
|
||||
} else if matches!(
|
||||
path.extension().and_then(|e| e.to_str()),
|
||||
Some("rs") | Some("slint")
|
||||
) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blank out string literals and line comments, so a brace inside either does
|
||||
/// not move the depth count.
|
||||
///
|
||||
/// Raw strings and block comments are not handled. Neither appears around a
|
||||
/// `#[cfg(test)]` item in this crate, and [`MIN_SHIPPING_FRACTION`] is the
|
||||
/// backstop if that ever stops being true.
|
||||
fn strip_literals_and_comments(line: &str) -> String {
|
||||
let mut out = String::with_capacity(line.len());
|
||||
let mut chars = line.chars().peekable();
|
||||
let mut in_string = false;
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if in_string {
|
||||
match c {
|
||||
'\\' => {
|
||||
chars.next();
|
||||
}
|
||||
'"' => in_string = false,
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' => in_string = true,
|
||||
'/' if chars.peek() == Some(&'/') => break,
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// The lines of a file that ship, each with its 1-based number.
|
||||
///
|
||||
/// For Rust, every `#[cfg(test)]` item is dropped. The attribute must be the
|
||||
/// whole line — a doc comment *mentioning* it is prose, not an attribute, and
|
||||
/// treating the two alike is the bug [`MIN_SHIPPING_FRACTION`] describes.
|
||||
fn shipping_lines(path: &Path, text: &str) -> Vec<(usize, String)> {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
|
||||
return lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| (i + 1, (*l).to_string()))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < lines.len() {
|
||||
if lines[i].trim() != "#[cfg(test)]" {
|
||||
out.push((i + 1, lines[i].to_string()));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip the item the attribute applies to: through its brace block, or
|
||||
// to the `;` if it has none — `#[cfg(test)] use ...;` is legal.
|
||||
let mut j = i + 1;
|
||||
let mut depth: i32 = 0;
|
||||
let mut opened = false;
|
||||
|
||||
while j < lines.len() {
|
||||
let stripped = strip_literals_and_comments(lines[j]);
|
||||
depth += stripped.matches('{').count() as i32;
|
||||
depth -= stripped.matches('}').count() as i32;
|
||||
|
||||
if stripped.contains('{') {
|
||||
opened = true;
|
||||
}
|
||||
if opened && depth <= 0 {
|
||||
break;
|
||||
}
|
||||
if !opened && stripped.contains(';') {
|
||||
break;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
|
||||
i = j + 1;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_interface_names_no_operation() {
|
||||
let crate_root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let repo = crate_root
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.expect("ui/dr-ui has a grandparent");
|
||||
|
||||
let ids = operation_ids(&repo.join("core/dr-pipeline/ops"));
|
||||
|
||||
let mut files = Vec::new();
|
||||
sources(&crate_root.join("src"), &mut files);
|
||||
sources(&crate_root.join("ui"), &mut files);
|
||||
|
||||
let mut offences = Vec::new();
|
||||
let mut total = 0usize;
|
||||
let mut scanned = 0usize;
|
||||
|
||||
for path in &files {
|
||||
let text = fs::read_to_string(path)
|
||||
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
|
||||
|
||||
total += text.lines().count();
|
||||
let shipping = shipping_lines(path, &text);
|
||||
scanned += shipping.len();
|
||||
|
||||
for (n, line) in shipping {
|
||||
for id in &ids {
|
||||
if line.contains(&format!("\"{id}\"")) {
|
||||
offences.push(format!(
|
||||
" {}:{n} names \"{id}\"\n {}",
|
||||
path.strip_prefix(repo).unwrap_or(path).display(),
|
||||
line.trim()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let fraction = scanned as f64 / total as f64;
|
||||
assert!(
|
||||
fraction >= MIN_SHIPPING_FRACTION,
|
||||
"only {scanned} of {total} lines ({:.0}%) were scanned, below the {:.0}% \
|
||||
floor — `shipping_lines` is dropping code it should be reading, and \
|
||||
this test is passing without having looked. Fix the scan before \
|
||||
trusting the result.",
|
||||
fraction * 100.0,
|
||||
MIN_SHIPPING_FRACTION * 100.0
|
||||
);
|
||||
|
||||
assert!(
|
||||
offences.is_empty(),
|
||||
"\n\nFR-DEV-3a: the interface must not name an operation.\n\n{}\n\n\
|
||||
The core declares capabilities and the frontend composes them, which is \
|
||||
why adding a develop node is one file in core/dr-pipeline/ops/ and \
|
||||
touches no UI. A special case here takes that away from every node \
|
||||
added afterwards.\n\n\
|
||||
If a node needs presentation the panel cannot currently give it, the \
|
||||
answer is a `presentation:` hint in its declaration and a `WidgetKind` \
|
||||
the panel implements for every node that asks — see \
|
||||
core/dr-pipeline/ops/README.md and `develop::supported`.\n\n\
|
||||
Test code is exempt; this scans only what ships.\n",
|
||||
offences.join("\n")
|
||||
);
|
||||
}
|
||||
+18
-5
@@ -275,6 +275,13 @@ export component AppWindow inherits Window {
|
||||
in property <string> adapter: "detecting…";
|
||||
in property <string> backend: "—";
|
||||
in property <int> fps: 0;
|
||||
/// TRACES: FR-DSP-8
|
||||
/// The display showing the canvas and the colour transform it is getting.
|
||||
/// Shown under ABOUT in Settings; see `settings.slint` for why the second
|
||||
/// one is a sentence rather than a name.
|
||||
in property <string> display-name: "detecting…";
|
||||
in property <string> display-colour: "detecting…";
|
||||
in property <string> display-others: "";
|
||||
/// TRACES: FR-UI-2
|
||||
/// Set from `CARGO_PKG_VERSION`, so the About line cannot disagree with
|
||||
/// the binary it is part of.
|
||||
@@ -1267,6 +1274,9 @@ in property <bool> panel-visible: true;
|
||||
fps: root.fps;
|
||||
layout-class: root.layout-class;
|
||||
app-version: root.app-version;
|
||||
display-name: root.display-name;
|
||||
display-colour: root.display-colour;
|
||||
display-others: root.display-others;
|
||||
|
||||
original-budget: root.settings-original-budget;
|
||||
original-unlimited: root.settings-original-unlimited;
|
||||
@@ -1700,11 +1710,14 @@ in property <bool> panel-visible: true;
|
||||
// that far. Below 1:1 it stays smooth, where filtering is
|
||||
// what keeps the image from aliasing.
|
||||
//
|
||||
// This matters even at moderate zoom on a HiDPI display:
|
||||
// the canvas is rendered at *logical* size and Slint scales
|
||||
// it up by the device pixel ratio, so the buffer is
|
||||
// resampled on its way to the screen whatever the pipeline
|
||||
// did.
|
||||
// TRACES: FR-DSP-8
|
||||
// The canvas is rendered at the *physical* pixel size of
|
||||
// the box this image occupies, so `contain` presents it
|
||||
// 1:1 and neither filter is reached at all until the
|
||||
// photographer zooms. That is the point: on a fractionally
|
||||
// scaled desktop the buffer used to be a logical-sized one
|
||||
// that the compositor stretched, and no choice of filter
|
||||
// recovers detail that was never rendered.
|
||||
image-rendering: root.magnified
|
||||
? ImageRendering.pixelated
|
||||
: ImageRendering.smooth;
|
||||
|
||||
@@ -117,6 +117,25 @@ export component SettingsPage inherits Rectangle {
|
||||
in property <string> layout-class;
|
||||
in property <string> app-version;
|
||||
|
||||
/// TRACES: FR-DSP-8
|
||||
/// The display showing the canvas, and the colour it is being given.
|
||||
///
|
||||
/// These are the same kind of thing as the two above — a fact about the
|
||||
/// session, not a setting — with one difference that earns them their own
|
||||
/// row rather than a line in a log. FR-DSP-8's fallback is *defined* to be
|
||||
/// sRGB where the display server will not say otherwise, and a photographer
|
||||
/// being shown sRGB because their compositor has no colour-management
|
||||
/// protocol has no other way to find that out. `display-colour` says which
|
||||
/// of the acquisition paths this session is on, in words.
|
||||
in property <string> display-name;
|
||||
in property <string> display-colour;
|
||||
/// The *other* monitors, where there are any. Empty on a single-display
|
||||
/// desktop, which is why the row below is conditional: FR-DSP-8 is about
|
||||
/// the second display, and the failure it names is invisible from the
|
||||
/// first, so this page has to be readable about a monitor the reader is
|
||||
/// not currently looking at.
|
||||
in property <string> display-others;
|
||||
|
||||
callback original-budget-changed(string);
|
||||
callback original-unlimited-toggled(bool);
|
||||
callback thumbnail-budget-changed(string);
|
||||
@@ -709,10 +728,46 @@ export component SettingsPage inherits Rectangle {
|
||||
Value { text: root.layout-class; horizontal-stretch: 1; }
|
||||
}
|
||||
|
||||
// TRACES: FR-DSP-8
|
||||
// Which display, and what colour it is being sent.
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap;
|
||||
Label { text: "Display"; }
|
||||
Value {
|
||||
text: root.display-name;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap;
|
||||
Label { text: "Display colour"; }
|
||||
Value {
|
||||
text: root.display-colour;
|
||||
horizontal-stretch: 1;
|
||||
// Wraps rather than elides: this is the one
|
||||
// value on the page that is a sentence, and
|
||||
// eliding it would cut off the half that says
|
||||
// *why* — which is the half FR-DSP-8 asks for.
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
if root.display-others != "": HorizontalLayout {
|
||||
spacing: Theme.gap;
|
||||
Label { text: "Other displays"; }
|
||||
Value {
|
||||
text: root.display-others;
|
||||
horizontal-stretch: 1;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: "Graphics and frame rate describe this session, "
|
||||
+ "not a setting — they are here so a bug report "
|
||||
+ "can quote them.";
|
||||
text: "Graphics, frame rate and display colour "
|
||||
+ "describe this session, not a setting — they "
|
||||
+ "are here so a bug report can quote them.";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user