Choose the export folder by walking the server, not by typing it

The destination for a Nextcloud export was a text field. Nobody recalls the
exact spelling of a path three levels down, and getting it wrong does not
fail — `create_dir` makes whatever was typed, so a misremembered folder
becomes a new one at the root and the exports are somewhere nobody looks.

So it is picked the way the library root is picked, using the same
`FolderBrowser` model the launch screen drives: up, into, and "use this
folder", confirming the folder currently *shown* rather than one selected in
the list. Same rule in both places, so the phrase means one thing.

The model is shared; the worker is not. `settings_ui::spawn_folder_list` is a
near-twin of the launch screen's, because that one reaches into the
`LaunchController` for its session and reports onto the launch screen's error
line, while this one is handed credentials and writes to the settings page.
Factoring them together needs a function taking both controllers or a trait
implemented twice to abstract two call sites — more machinery than the twenty
lines it saves. What matters is shared already: navigation behaves identically
because both drive the same model.

The callbacks are wired in `lib.rs` rather than in `settings_ui::wire`,
because listing a remote folder needs credentials and the settings page holds
no session on purpose — it is reachable before a library is opened and must
not depend on one existing. With no account the picker says to sign in first,
rather than showing an empty list that reads as a server with no folders.

Details that are decisions rather than accidents: the picker opens at the
library root rather than at whatever half-typed path is in the field, which
would list nothing and look broken. The listing area is a fixed 180px, since a
folder with sixty children would otherwise push the rest of the settings page
off the bottom. "Up" is disabled at the root rather than hidden, so the row
does not jump as the user navigates. A failed listing leaves the picker open
on the folder it was showing — where the user had got to is not something to
discard over a dropped request. And the chosen folder saves immediately like
every other setting on a page that has no Save button.

The poll timer lives on the controller for the reason `LaunchController` keeps
its own there: a `slint::Timer` stops when dropped, so one local to the
function that starts it would be collected before the listing arrived.

Carries in-flight work from a parallel session — a segmentation pass in
dr-gpu, a sidecar cache, and the develop panel's continuing changes.

1020 tests pass, fmt clean. One clippy warning remains and is not mine:
`sidecar_cache::dir` is unused while that work is in progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 07:12:01 +02:00
co-authored by Claude Opus 5
parent e00c99b864
commit cb1d2be240
16 changed files with 3292 additions and 140 deletions
+256
View File
@@ -0,0 +1,256 @@
// Watershed segmentation — the passes behind arm A of S15 (docs/segmentation.md).
//
// Five entry points forming one chain:
//
// features source texture -> perceptual triple, box-downscaled to proxy size
// blur pre-smoothing, without which every noise grain becomes a basin
// gradient Sobel magnitude — the surface the watershed floods
// flow each pixel points downhill to its steepest neighbour
// jump pointer-jumping, until every pixel points at its basin root
//
// Everything after `features` works in storage buffers rather than textures.
// That is deliberate: the flow and jump passes need read-write access to the
// same array across dispatches, which storage textures do not give portably,
// and a buffer reads back without the 256-byte row padding a texture copy
// imposes.
struct Params {
// Proxy dimensions — what every pass but `features` iterates over.
width: u32,
height: u32,
// Source dimensions, for the box downscale in `features`.
src_width: u32,
src_height: u32,
// Half-width of the pre-smoothing kernel, in proxy pixels. 0 disables it.
blur_radius: i32,
// 1 when the source is already display-encoded (the JPEG path), 0 for
// linear scene-referred data out of the demosaicer.
non_linear: u32,
// How much luma and chroma each contribute to the gradient. Chroma is
// weighted lower because it carries most of the sensor noise and few of
// the boundaries a person would draw.
w_luma: f32,
w_chroma: f32,
}
// Binding slots are unique across the whole module, not reused per entry
// point: WGSL resource variables share one namespace, so two globals at the
// same (group, binding) is a module-level validation error even when no
// single entry point uses both. Each pass therefore gets its own pair, and
// each pipeline a layout declaring only the slots it touches.
@group(0) @binding(0) var<uniform> u: Params;
// ---------------------------------------------------------------- features
@group(0) @binding(1) var src: texture_2d<f32>;
@group(0) @binding(2) var<storage, read_write> feat_out: array<vec4<f32>>;
// Linear or display-encoded RGB to a roughly perceptual opponent triple.
//
// Perceptual rather than linear because the gradient has to agree with what
// a person calls an edge. In linear light a highlight rolloff swamps the
// boundary between two midtones, and the watershed would put its strongest
// walls where nobody sees one.
//
// The two chroma axes are opponent differences rather than a real Lab
// transform: they cost three subtractions instead of a matrix and a cube
// root, and the watershed only needs the *magnitude* of colour change, not a
// colorimetrically defensible value for it.
fn perceptual(c_in: vec3<f32>) -> vec3<f32> {
var c = max(c_in, vec3<f32>(0.0));
if (u.non_linear == 0u) {
c = pow(c, vec3<f32>(1.0 / 2.4));
}
let l = dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
let a = c.r - c.g;
let b = c.b - 0.5 * (c.r + c.g);
return vec3<f32>(l, a, b);
}
// Source -> proxy, averaging every source pixel that falls in the proxy
// pixel's footprint.
//
// A box average rather than point sampling because the proxy is where the
// segmentation happens: point sampling a 24 MP sensor down to 2 MP aliases
// fine texture into false gradient, and the watershed would faithfully find
// basins in the aliasing.
@compute @workgroup_size(8, 8, 1)
fn features(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= u.width || gid.y >= u.height) {
return;
}
let sx0 = (gid.x * u.src_width) / u.width;
let sy0 = (gid.y * u.src_height) / u.height;
let sx1 = max(sx0 + 1u, ((gid.x + 1u) * u.src_width) / u.width);
let sy1 = max(sy0 + 1u, ((gid.y + 1u) * u.src_height) / u.height);
var acc = vec3<f32>(0.0);
var n = 0.0;
for (var sy = sy0; sy < sy1; sy = sy + 1u) {
for (var sx = sx0; sx < sx1; sx = sx + 1u) {
let c = textureLoad(src, vec2<i32>(i32(sx), i32(sy)), 0).rgb;
acc = acc + perceptual(c);
n = n + 1.0;
}
}
feat_out[gid.y * u.width + gid.x] = vec4<f32>(acc / max(n, 1.0), 0.0);
}
// -------------------------------------------------------------------- blur
@group(0) @binding(3) var<storage, read> blur_in: array<vec4<f32>>;
@group(0) @binding(4) var<storage, read_write> blur_out: array<vec4<f32>>;
fn clamp_coord(v: i32, hi: u32) -> u32 {
return u32(clamp(v, 0, i32(hi) - 1));
}
// Pre-smoothing. Not a refinement — without it the watershed is unusable.
//
// A raw gradient over sensor data has a local minimum at every noise grain,
// and one basin per local minimum means a 2 MP frame segments into hundreds
// of thousands of regions that correspond to nothing. The radius is the
// caller's to set from ISO.
@compute @workgroup_size(8, 8, 1)
fn blur(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= u.width || gid.y >= u.height) {
return;
}
let idx = gid.y * u.width + gid.x;
if (u.blur_radius <= 0) {
blur_out[idx] = blur_in[idx];
return;
}
var acc = vec3<f32>(0.0);
var wsum = 0.0;
let r = u.blur_radius;
for (var dy = -r; dy <= r; dy = dy + 1) {
for (var dx = -r; dx <= r; dx = dx + 1) {
let sx = clamp_coord(i32(gid.x) + dx, u.width);
let sy = clamp_coord(i32(gid.y) + dy, u.height);
let d2 = f32(dx * dx + dy * dy);
let w = exp(-d2 / (2.0 * f32(r) * f32(r)));
acc = acc + blur_in[sy * u.width + sx].rgb * w;
wsum = wsum + w;
}
}
blur_out[idx] = vec4<f32>(acc / wsum, 0.0);
}
// ---------------------------------------------------------------- gradient
@group(0) @binding(5) var<storage, read> grad_in: array<vec4<f32>>;
@group(0) @binding(6) var<storage, read_write> grad_out: array<f32>;
fn feat_at(x: i32, y: i32) -> vec3<f32> {
let sx = clamp_coord(x, u.width);
let sy = clamp_coord(y, u.height);
return grad_in[sy * u.width + sx].rgb;
}
// Sobel magnitude over the weighted opponent triple.
//
// This is the surface the watershed floods, so its units matter for nothing
// except ordering — only the *relative* height of one boundary against
// another decides which regions merge first.
@compute @workgroup_size(8, 8, 1)
fn gradient(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= u.width || gid.y >= u.height) {
return;
}
let x = i32(gid.x);
let y = i32(gid.y);
let tl = feat_at(x - 1, y - 1);
let tc = feat_at(x, y - 1);
let tr = feat_at(x + 1, y - 1);
let ml = feat_at(x - 1, y);
let mr = feat_at(x + 1, y);
let bl = feat_at(x - 1, y + 1);
let bc = feat_at(x, y + 1);
let br = feat_at(x + 1, y + 1);
let gx = (tr + 2.0 * mr + br) - (tl + 2.0 * ml + bl);
let gy = (bl + 2.0 * bc + br) - (tl + 2.0 * tc + tr);
let w = vec3<f32>(u.w_luma, u.w_chroma, u.w_chroma);
let wx = gx * w;
let wy = gy * w;
grad_out[gid.y * u.width + gid.x] = sqrt(dot(wx, wx) + dot(wy, wy));
}
// -------------------------------------------------------------------- flow
@group(0) @binding(7) var<storage, read> flow_grad: array<f32>;
@group(0) @binding(8) var<storage, read_write> flow_out: array<u32>;
// Each pixel points at the steepest-descent neighbour among its 8, or at
// itself if it is a local minimum — a basin seed.
//
// **The tie-break is load-bearing, twice over.** Comparing on (value, index)
// rather than value alone gives a strict total order, so the pointer graph
// descends monotonically and cannot contain a cycle — plateaux, which are
// everywhere in a smoothed image, would otherwise make two equal pixels point
// at each other and hang the pointer-jumping below.
//
// It is also what makes the result reproducible. S15's M5 asks whether a
// label field is stable enough across GPU vendors to be a cache key
// (ARCH §6.13); an arbitrary tie-break would answer no before the question
// was asked.
@compute @workgroup_size(8, 8, 1)
fn flow(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= u.width || gid.y >= u.height) {
return;
}
let idx = gid.y * u.width + gid.x;
var best_val = flow_grad[idx];
var best_idx = idx;
for (var dy = -1; dy <= 1; dy = dy + 1) {
for (var dx = -1; dx <= 1; dx = dx + 1) {
if (dx == 0 && dy == 0) {
continue;
}
let nx = i32(gid.x) + dx;
let ny = i32(gid.y) + dy;
if (nx < 0 || ny < 0 || nx >= i32(u.width) || ny >= i32(u.height)) {
continue;
}
let ni = u32(ny) * u.width + u32(nx);
let nv = flow_grad[ni];
if (nv < best_val || (nv == best_val && ni < best_idx)) {
best_val = nv;
best_idx = ni;
}
}
}
flow_out[idx] = best_idx;
}
// -------------------------------------------------------------------- jump
@group(0) @binding(9) var<storage, read> jump_in: array<u32>;
@group(0) @binding(10) var<storage, read_write> jump_out: array<u32>;
// Pointer jumping: parent = parent[parent].
//
// Halves every path length per dispatch, so ceil(log2(longest path)) passes
// resolve every pixel to its basin root. The host runs a fixed count bounded
// by log2(pixel count) rather than testing for convergence, because a
// convergence test costs a readback per iteration and the bound is ~21
// dispatches of a trivial kernel.
@compute @workgroup_size(8, 8, 1)
fn jump(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= u.width || gid.y >= u.height) {
return;
}
let idx = gid.y * u.width + gid.x;
jump_out[idx] = jump_in[jump_in[idx]];
}