209ae36163b998124cfa3f596aff50ee809bad09
160
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b1d1c47261 |
Start every worker thread through the executors module
Thirty-nine spawn sites in dr-ui, and one in the Android entry point, called std::thread::spawn or a Builder of their own, and most of the threads they started were <unnamed> in a panic message or a profiler. Each now calls executors::spawn with its executor and a role, so the thread is named <executor>:<role> — net:sync, decode:thumbs, io:catalog-open — and knows which executor it is on. The three that already set a name (automation, import, prefetch) keep their name as the role. Behaviour is unchanged: each job still gets a thread of its own when it starts, and spawn panics where std::thread::spawn did. The module's documentation now says how a job is assigned: by what it spends its time on, so a sweep that fetches bytes and then decodes them is Decode, and a sidecar write that touches the catalog is Network. Left as they were: the segmentation and refine workers in masks_ui.rs, which another change is reworking, and test-only threads. |
||
|
|
7be1efff32 |
Name the executors and fail a block_on on the UI thread
architecture.md §7.1 stated five executors and their thread counts, and no code named them. dr_ui::executors now does: the Executor enum with each one's thread name and the count §7.1 gives with its reason, and spawn, which starts a thread named <executor>:<role> and marks it with the executor it belongs to. The counts are the stated budget, not yet a bound: a job still gets a thread of its own when it starts. run marks its own thread as the UI executor before it builds the window. net_runtime::build now returns a NetRuntime whose block_on asserts, in debug and test builds, that the caller is not that thread; everything else derefs to the tokio runtime. The login, folder-list and remote-folder workers built the same runtime by hand and now take it from net_runtime, so their block_on is guarded too. Tests: a block_on on a thread marked as the UI executor panics naming the UI thread; the same call on a worker returns; a spawned thread carries its name and executor. |
||
|
|
9b580c3720 |
Satisfy rustfmt and clippy on the album and folder picker changes
rustfmt over the files the albums work touched, and the album merge's incoming row as a named struct rather than an eight-field tuple, which clippy's type_complexity refused. |
||
|
|
92d4b23bed |
Give an album a folder on the tablet, through Android's folder picker
Android's only export destination was the library on the server
(ExportTarget::available), because writing to the device goes through
the Storage Access Framework and nothing did. An album's folder on the
tablet is now chosen in the system's tree picker — which has its own
"Create new folder" — and exports are written into it with
DocumentsContract.
The picker answers through onActivityResult, and the main activity is
NativeActivity, whose result is not ours. FolderPicker is a translucent
activity that only asks: it starts ACTION_OPEN_DOCUMENT_TREE, takes a
persistable grant (a folder is chosen once and exported to for months),
leaves the URI in a static, and finishes. Rust polls it from a Slint
timer — one static call, rather than a registered native method and a
thread to deliver on.
Two things the first build on the tablet got wrong, recorded where they
are fixed:
- Our classes must be loaded through Context.getClassLoader(). The
class of what ndk_context holds is a framework class from the boot
loader, which reports every class in the APK as not found.
- What ndk_context holds is the application context, not the activity,
and starting an activity from it throws without FLAG_ACTIVITY_NEW_TASK.
Saf.write creates the document (or, under Overwrite, reopens the one of
that name with "wt" so a shorter file does not keep the old tail) and
returns the name the provider actually gave it, since SAF renames on a
collision by itself; the album records that name. A tree URI reads in
the sidebar as its folder ("Pictures/Web"), not as a content:// string.
|
||
|
|
7cbcacc02e |
Export to an album instead of a folder in the settings
Export took a path typed into the settings page, or a folder inside the library on the server. The first is how exports end up somewhere nobody looks; the second put JPEGs into the tree a scan catalogues, where they came back as photographs beside the RAWs they were made from. The destination is now an album (FR-EXP-10), chosen by name in the export sheet. Albums are listed under the collections in the sidebar; "+" there, or "New album…" in the sheet, opens a sheet for its name and its folder — on this device through the platform's dialogue, or on the server through the browser with "New folder". A server folder inside the library is refused, and the sheet says why. Selecting an album narrows the grid to the photographs behind its files: library::Scope is Collection or Album, and scope_clause is the one place the two are spelled, which also retires the two copies of the collection predicate total_images_scoped and read_cells_scoped had inlined. A batch resolves the album when it starts, and refuses in words when none is chosen, it has gone, or its folder is local to another device. Each item reports the image it came from, and the files written are recorded against the album in one transaction when the batch ends. A server album lives outside the library, so its queued uploads are relative to the account root. That is a third line in the outbox's .dest record rather than a leading slash, because a record written before albums may carry a stray slash and must keep the meaning it was written with. An export folder set before albums becomes an album called "Exports" on first open, so upgrading does not lose where exports were going. The old destination fields stay in ExportSettings so older settings files still read. |
||
|
|
2eb06b1064 |
Make a folder from the server browser
The in-app browser that chooses a library folder on the server could only open folders that already existed, so a library, or an export destination, that was not on the server yet had to be made in the Nextcloud web page first. It now has "New folder": a name, then MKCOL, then the parent listed again and the new folder walked into — a folder somebody has just named is the one they mean to choose. The listing is the server's rather than the name inserted locally: the server may have normalised or refused it. A name with a slash, "..", or nothing at all is refused before any request, because a folder typed with a slash in it is a path the user did not mean. remote_folders holds the two WebDAV round trips (list, make) off the UI thread, with the answer delivered through a Slint timer, so the album sheet can use the same browser. |
||
|
|
6683c14b40 |
Choose folders in the platform's dialogue, not by typing a path
Every folder the desktop asked for was a text field: the library folder at launch, an import's source and second copy, a preset folder brought over from Lightroom. A typed path is how a destination silently becomes a new folder nobody meant — one wrong letter three levels down and the write succeeds somewhere the photographer will never look — and a field cannot make the folder that is not there yet. They now open the platform's own dialogue through rfd: the XDG desktop portal on Linux, the common item dialogue on Windows. The portal rather than GTK because it reaches the user's files from inside the Flatpak and needs no GTK in a Slint application, and it draws whichever desktop's chooser is running, "New folder" included. It is awaited on Slint's event loop (spawn_local), so the window keeps drawing while it is open, and parented to the window so it opens over it. PathRow shows what is chosen, read-only, beside the button. Android has no filesystem dialogue — only SAF, which returns document trees, not paths — so there the same rows stay typed fields (Pickers.local-paths). The launch screen keeps the folder used last on screen with "Open folder" beside it, so reopening is one press. Presets get two buttons, a folder and a single .xmp file, because no platform dialogue picks "a file or a folder" in one go. |
||
|
|
4bec01eaf1 |
Say a photograph is downloading, and how far, instead of failing
The develop view reported a remote original on its way through the error message, so it read "Could not load image" over "Downloading…". It did so on every step along the roll, including a cached frame that was ready within a tick, so each step flashed the error. Waiting is now its own state. On the step, the grid's thumbnail of the photograph stands in at once. Only when a transfer is really on the wire does it dim under "Not on this device yet", with a line like "Downloading — 12.4 of 38.0 MB" and a progress bar. The bytes come from a new RemoteBackend::get_reporting. The Nextcloud backend overrides it to read the body chunk by chunk; the default reports once at the end. Progress is kept in the in-flight registry by path, because a step usually lands on a frame the prefetcher is already fetching. The catalog's file length stands in when the server sends no Content-Length. |
||
|
|
3b97195b37 |
Keep a stepped-past download from replacing the open photograph
Opening a photograph from the library starts a download and a timer that polls for it. Every step along the roll started another, and each one put its result on screen when it landed, so a frame stepped past earlier could arrive last and replace the one whose name was showing. Each open now takes a generation number; a download that lands for an older generation is recorded in the activity list (its bytes are cached) and goes no further. The outgoing session also stayed live until the new download landed. Its sliders kept working, and a second step before the first landed saved that session's edit under the new photograph's identity. The session is now dropped as soon as its edit is saved. |
||
|
|
54aee50539 |
Count duplicate originals once the sweep has dated them
A copy becomes a duplicate only once its capture time is read, and on a fresh library that is the sweep, not the scan: the sidebar row stayed hidden until the next launch. The count is refreshed when a sweep that dated anything finishes. Also says on a group left out of the plan that nothing will move, drops an unused method, and names the review's completion callback type for clippy. |
||
|
|
220e9af222 |
Add the duplicate originals review, from the sidebar and from Settings
"Duplicate originals" appears under the trash in the collections sidebar while the catalog holds any, and Settings says how many there are beside the other whole-library passes. Both open one page: every group with its picture and paths, the copy that stays (tap another path to change it), a per-group Include box, what the survivor will gain and any flag, label or face conflict, and why a group was skipped. The summary is the dry run -- "N groups, M files to trash, K skipped" -- and nothing moves until "Check" has read the copies and "Move M copies to trash" is pressed. Both run on workers with progress on the page, in the activity register and, for the move, on the library status line; Stop ends a job between groups. When it ends the grid, the sidebar and the trash are refreshed and the survivors' judgements are written to their sidecars and XMP the way a rating keystroke writes them. The page is paginated at 30 groups, so a redraw decodes 30 thumbnails and previews 30 merges whatever the size of the library. Back and Escape leave it like its own Back button. |
||
|
|
8d4ecb75c1 |
Prove duplicate originals the same and consolidate them on workers
dr_ui::duplicates is the half of #67 that touches files. The check reads each copy's first and last megabyte by range through the backend and hashes them (or compares stored content hashes where every copy has one), keeps the probes in the catalog, and reads each copy's sidecar: a group whose bytes differ, whose copies cannot be read, or whose develop edits differ is left out and the review says why. Consolidating a group carries the one edit onto the survivor's sidecar where it has none, moves the other copies into the trash, and then commits dr_catalog::duplicates::consolidate. A failure after the first move puts the files and the sidecar back; a run that died between the moves and the commit is finished by the next one, which finds each moved file at its trash path. Tested end to end on a folder library of real files: the copies land in .darkroom-trash, the skipped groups are untouched, the edit reaches the survivor, a catalog failure moves everything back, and restore returns the copies byte for byte. |
||
|
|
a6ea6ba83f |
Let the manual's scripts find a control by its name
Every scene in tools/manual aimed at window pixels written in by hand, so a panel that gained a row moved every slider under it and the recording went on dragging where the slider used to be. The develop column has already moved that way (Compose now sits above Adjust), and nothing said. A build with the `automation` feature listens on the Unix socket named by DR_AUTOMATION and answers where an element is: by its accessible label, the name a screen reader reads, or by its markup id for the few things that are not controls (the canvas, the crop rectangle). It uses Slint's element queries, which need the compiler's debug tables, so the feature also turns those on in build.rs. It only answers questions; the input is still xdotool's real pointer. No default build has the feature, and one that has it listens only when the variable is set. drive.py gains click-on, drag-on, hold-on, wait-for, wait-gone, labels and ids. The grid's cells are now named by their file, each rating star by its value, the sidebar's + as "New collection", and the Adjust heading's reset as "Reset all adjustments" - controls a screen reader could not reach before either. |
||
|
|
a8043e6827 |
Hand Android's develop frame to the compositor as a texture again
With Skia drawing pre-rotated on wgpu's Vulkan swapchain, Android no longer needs to draw with Skia over OpenGL, which was the only reason the develop view read its frame back through memory (TD-1). So `unstable-wgpu-29` moves back to the common slint dependency. The android-activity backend then builds `SkiaRenderer::default_wgpu_29`, and `shared_gpu` loses its Android arm. The one wgpu device is handed to Slint through `BackendSelector::require_wgpu_29` on both platforms. `slint::android::init_with_event_listener` runs before `dr_ui::run`, so the selector reaches the Android adapter before its window exists. `renderer-femtovg-wgpu` stays desktop-only, since Android has no FemtoVG. The two `#[cfg(target_os = "android")]` readbacks in `develop::render` (the frame through `export_pixels` and the focus overlay through `read_overlay`) are gone. `read_overlay` stays for the tests that check what the overlay marks. Built for arm64 and release-signed. Not yet run on the tablet. |
||
|
|
d489a34190 |
Drive develop, the grid, the sidebar and People from the keyboard
An audit of every action by view against the keys the handlers bind left develop without zoom, pan, fit or a way back to the grid, the grid without select-none, thumbnail size or keywording, People with no key at all, and the export and copy sheets without Enter. It also found the reverse gap FR-UI-5 forbids: pick and reject had no route but P, X and U, and the 2026-09-19 amendment's judging in develop had not been built. Develop: Ctrl+= and Ctrl+Plus zoom in and Ctrl+- out about the middle of the view, Ctrl+0 fits and Ctrl+1 goes to 1:1, Shift and an arrow pan a magnified view, G goes back to the grid, Ctrl+Y redoes, and Enter keeps a crop that hid a mask. 0-5, P, X and U rate and flag the open photograph without moving on, with stars and Pick/Reject in the top bar as the pointer and touch route. = and - nudge the control last moved by a hundredth of its travel; the framing sliders, perspective included, now count as "last moved", so R puts them back as well. J turns the selected mask part's join chip. Grid: Ctrl+D and Ctrl+Shift+A clear the selection, = and - resize the thumbnails, Ctrl+K opens keywording, and Flag in the selection bar gives pick and reject a pointer and touch route. Sidebar: Enter commits a collection's name, and Enter or Escape hands the keyboard back to the grid, where it used to go nowhere until something was clicked. People: Up and Down walk the rail, F2 puts the name field under the keys, and Escape or Back now leave the screen the way its back button does instead of doing nothing. Sheets: Enter does what the export or copy sheet's button does. The choices follow Lightroom where it has one. No new key steals typing: the grid's and People's keys live on focus holders that are not ancestors of any text field, and the sheets' Enter comes after a focused field has had it. Every binding is tagged beside its handler, and the gate added in the previous commit holds the two to each other. |
||
|
|
352e59498b |
Open the bundled manual from Help and from Settings
The packages now carry the manual, but nothing in the application opened it: the help sheet listed gestures and stopped there. The help sheet gains a Manual button beside Done, and Settings a Manual row under About beside the version. Both go through dr_ui::manual, which finds the installed page through dr_plat::system_data_dirs (the package's share directory on Linux, the executable's directory on Windows), and a development build also in the checkout it was compiled from. A copy with no manual says so on the status line rather than doing nothing. On the desktop the page goes to the system browser. A section is a URL fragment, and xdg-open's generic mode and Windows' FileProtocolHandler both turn a file: URL into a path and drop the fragment, so a section is opened through a one-line redirect page written to the data directory: the opener gets a plain path, which every opener keeps, and the browser follows the redirect to index.html#section itself. The launcher behind the sign-in's open_in_browser is split out so both share it; the https check stays with the sign-in. Android has no path to give a browser: an asset is not a file, a copy in private storage is unreadable to other apps, a file: URI across apps is refused, and a content: URI leaves the browser resolving every picture against the provider. So ManualActivity, a WebView reading file:///android_asset/manual/index.html straight out of the APK, shows it, started by class name with the section as an extra. JavaScript is off, links off the page go to the browser, and the theme is day-night so the page's own light and dark follow the system. A test checks that the manifest, the Java class and dr_ui agree on the name and the extra. |
||
|
|
114d979397 |
Add Vertical and Horizontal perspective sliders to Compose
The keystone existed in framing but nothing in develop could reach it:
framing is presented by its own Compose panel rather than generated, so
new framing parameters get no control until the panel names them.
Compose now has Vertical and Horizontal sliders under Straighten,
mirrored from the session like the angle, recorded as parameter steps
("Vertical Perspective" in the history), cleared by the Compose reset
and by opening the next photograph. Releasing either slider refits the
crop the way releasing the straighten slider does: a keystone alone
needs no crop, but it moves the empty corners of a straightened frame,
so the crop that avoided them before may not after, or may have room
to grow back.
|
||
|
|
ade627a5d0 |
Fade the draft into the sharp frame when a drag settles
When a gesture stopped, the half-resolution draft was replaced by the full-resolution frame in one step, a visible jump from soft to sharp. FR-DSP-4 asks for a refinement that is smooth, not a jarring swap. The canvas now keeps the last draft frame (`canvas-previous`) and draws it over the sharp one, fading it out over 150 ms when the draft flag clears. The fade costs no render: the draft is a refcount on the texture it was drawn into, and the adjust pass ping-pongs between two output targets, so the sharp frame is written into the other one. While a gesture is drafting the layer is hidden and snapped opaque, so a new drag shows its draft at once; past the fade it is hidden again, and a settled canvas composites one image as before. |
||
|
|
4642c77e18 |
Dim the histogram while the canvas shows a draft
The histogram is measured on settled frames only, so during a drag it describes the frame from before the gesture while the canvas shows something newer, and nothing said so. The draft flag stopped at the render closure. It now reaches the interface: `canvas-draft` on the window and `Levels.provisional` for the readouts, both set on every canvas render from the flag that chose the frame's resolution, and cleared when a render fails. The histogram panel dims its display reading to half while a draft is up and brings it back when the frame settles. Dimmed rather than captioned, because a caption appearing on every drag would move the column; the raw reading has no frame to lag and is left alone. |
||
|
|
7d0870c3fb |
Keep a drag in draft until it stops, then render sharp once
During any drag longer than 120 ms the canvas rendered a full-resolution frame every 128 ms under the finger. The settle timer was armed by the first draft of a burst and not re-armed by later ones, so it counted from the start of the gesture rather than from its last movement, fired mid-drag, and the next coalesced event armed it again. Each of those frames is the most expensive one the canvas draws, landing where the frame budget is tightest. The draft/sharp decision now lives in `refine::Refine`, apart from the timers that carry it out. Every draft frame arms a settle timer carrying a generation token and only the newest token is honoured, so the sharp frame lands SETTLE_DELAY after the last movement. A request arriving while a settle is still owed also counts as part of the gesture, so a slow stretch of a drag (one event per frame, nothing to coalesce) no longer renders sharp between drafts. A timer that fires with a render already posted defers to it. The tests drive the state machine through simulated timelines; the long-drag case reproduced the four mid-drag sharp frames before the fix. |
||
|
|
c3d1f83b96 |
Say so when a crop leaves a mask outside the frame
Cropping tighter past a mask layer made it invisible without a word: the layer stayed in the panel and the sidecar, and its adjustment went on landing on pixels nobody would see again. When a crop is let go, develop now measures what the gesture did to the mask stack (dr_pipeline::orphan) and, if any layer is now entirely or mostly outside the frame, shows a notice over the photograph: how many layers, their names, "Undo crop" and "Keep crop". The crop is already applied and nothing waits on the answer. The crop overlay gains a release callback carrying the rect the press began from, so the measurement runs once per gesture and never on the drag's per-frame changes. Choosing a ratio is measured the same way, being a crop committed in one click. "Undo crop" is the ordinary undo, and the notice is tied to the history revision it was raised at: the redraw that follows any history move clears it, so the crop and its warning go back as one step. A second drag folded into the same step is measured from where that step began. A crop that strands nothing shows nothing. |
||
|
|
733a033274 |
Test that a stub decoder reaches the scan, the ladder and export
FR-RAW-2's "without changing callers" needs a test that would fail if a caller named the concrete decoder; passing a real RAW through rawler cannot tell the two apart, because both routes give the same answer. The decoder_seam tests hand a stub decoder, for a container no real decoder reads, to the catalog scan (read_metadata_only over a folder backend), the preview ladder (the remote two-stage fetch, an import's thumbnail and the viewer's no-GPU fallback) and export (open_for_export, skipped without an adapter). Each assertion is on something only the stub produces: its camera and date, a header fetched at its 64-byte budget rather than HEADER_BYTES, preview and sensor sizes turned by its orientation. Switching collect_metadata or make_thumbnail back to the free functions fails two of the three tests. The develop test_support module is widened to the crate so the export test shares the one headless GPU context the other tests use. The requirements note for FR-RAW-2 now records the trait as built and the second decoder as not. |
||
|
|
414094bd38 |
Route dr-ui's decoding through the Decoder trait
With the trait in place the claim still meant nothing while every caller named dr_decode's free functions: a second decoder would have had to be threaded through the scan, the thumbnail ladder, import, the viewer, export, merge and repairs at the moment it arrived. Each of those now takes a &dyn Decoder and reads headers, previews, orientation and sensor data through it, including the header budget a remote fetch asks for (header_bytes) and where it finds the embedded preview (locate_preview). Only the places that start a job name dr_decode::default(): the thumbnail, sweep and thumbnail-sweep threads, the viewer's open handlers, and the request structs a job is handed (BatchRequest, MergeRequest, the import Request, the repairs Toolkit), so a caller can be given another decoder by changing what it is handed. The default is rawler through the same free functions as before, so nothing a user sees changes. The trait gains Debug as a supertrait so request structs that derive Debug can carry one. |
||
|
|
031315bdb6 |
Run cargo fmt over the develop shortcuts and the star-range filter
Benchmarks / CPU and I/O (per commit) (push) Successful in 2m8s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 33s
Build and test / Android (aarch64) (push) Successful in 14m21s
Build and test / android-image (push) Successful in 1s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 47m57s
Build and test / windows-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 51s
Build and test / Windows (x86_64, cross) (push) Successful in 34m3s
Build and test / Publish the release (push) Successful in 1m3s
|
||
|
|
bddf3250c5 |
Add Lightroom's export and copy shortcuts to develop
Ctrl+E opens an export sheet: the export defaults on their own, over the photograph, with an Export button. Ctrl+Shift+E exports straight away on those defaults. There is no per-export copy of the settings, so what is chosen in the sheet is saved as it is on the settings page, and the next Ctrl+Shift+E uses it. To make that one set of controls in two places, the export options move out of the settings page into export.slint: an `ExportOptions` global that Rust writes once, and two panels that read it. The window no longer forwards forty `settings-*` properties to the page. Ctrl+Shift+C opens a copy sheet with the edit-kind chips the preset sheet already uses and a Copy button, which is how a paste leaves each photograph's crop and rotation alone (Compose off). A and D step along the roll beside the arrows. While either sheet is up the develop keys stand down, so A cannot change the photograph behind the form, and Escape closes it. |
||
|
|
1e472fd251 |
Move the library's routes and status onto its own Slint global
The scan/opening/status/error lines, offline mode and its retry, pinning a collection offline, the sync and thumbnail-sweep state, and the callbacks that route the grid to a rescan, a sync, another library, a panorama merge or an export — the last of what AppWindow still carried under the library- prefix — move onto the `Library` global started earlier on this branch. Rust reaches them through window.global::<Library>() rather than window.set_/get_/on_/invoke_ on the root. library-visible is the one name that stays: it is computed from active-page and active-view, the shell's own routing state, which a global cannot read. AppWindow now declares no other library- property or callback. |
||
|
|
402dcdc24c |
Move the library grid's cells and selection onto their own Slint global
AppWindow carried the grid's loaded window of cells, the keyboard cursor, drag and drop, the held-row long-press state, columns and cell size, the scroll and viewport bookkeeping, the photo roll's pick and centre-request, and the local-only/reorder/collection-filing gestures that act on a selection, as properties and callbacks on the root component. That state now lives in the `Library` global declared in library.slint, next to the structs (LibraryCell, TimelineBar, KeywordRow, PersonChip) it and the grid's other components already share; Rust reaches it through window.global::<Library>() instead of window.set_/get_/on_/invoke_ on the root, the same change collections.slint's `Collections` global made for the sidebar. library-visible stays on AppWindow: it is computed from active-page and active-view, the shell's own routing state, which a global cannot read. Everything else still prefixed library- — the timeline, the filter bar, ratings and flags, keywording, and the routes and status lines — stays on the window for now and moves in the commits that follow. |
||
|
|
38819da222 |
Replace the six view booleans with View and Page enums
app.slint carried show-launch, show-library, show-identity, show-settings,
show-import and show-merge as separate booleans, so the root component chose
what to draw with five- and six-term conjunctions and nothing stopped two of
them being true at once. Replaced with two enums: View { develop, library,
identity, launch } for which top-level screen is showing, and Page { none,
settings, import, merge } for which page, if any, is drawn over it.
Two values rather than one, because the two questions are genuinely
different. Settings, Import and Merge are reachable from more than one View
and are drawn outermost without touching it — closing one has to return to
whichever View was already current, and today that works because the
underlying property is left alone while the page sits over it. A single
View with five or more variants would need a second field remembering what
to return to; Page needs nothing to remember, since View was never
overwritten in the first place. Identity, by contrast, genuinely replaces
the window the way Launch and Library do (see the existing "like the launch
screen" comment on its `if`), so it is a View variant, not a Page.
Every `if` chain in app.slint that used to compare four, five or six
booleans now compares active-view and active-page to at most one variant
each. library-visible collapsed from a six-term conjunction to
`active-page == Page.none && active-view == View.library`.
The Rust side follows: every set_show_*/get_show_* call in library_ui.rs,
identity_ui.rs, settings_ui.rs, merge_ui.rs, import_ui.rs, launch_ui.rs and
lib.rs now reads or writes active-view or active-page instead, including
lib.rs's startup match (View.launch vs View.develop, since a Startup that
skips the launch screen used to leave both old booleans false and fall
through the chain to develop) and identity_ui's close handler, which now
writes View.library or View.develop in one call where it used to write
show-library then show-identity separately.
back_one_step needed one deliberate adjustment beyond the mechanical
rename. Identity was never represented in NavState: back had nothing to do
when Identity was opened from the library (show-library stayed true,
unread by IdentityScreen's own condition) and could only reach ToLibrary
when opened from develop, which likewise wrote a property IdentityScreen
never read — so escaping out of Identity was invisible in both cases before
this change. With a single active-view, falling into the general case
would instead overwrite the value IdentityScreen's `if` does read and close
it as an unintended side effect. back_one_step now swallows the gesture
while View.identity is current, reproducing the same "nothing visible
happens" outcome for both origins without threading identity_ui's private
came-from-library state through lib.rs for one screen.
Verified with tools/manual/drive.py against a private Xvfb and the debug
build: launch screen to library, Settings opened and closed, Identity
opened and closed (including Escape doing nothing while it is open),
develop opened from a cell and closed both by the back button and by
Escape. Screenshots under verify/.
|
||
|
|
cb7b9d71c4 |
Split lib.rs::run into named construction and wiring functions
run() was 2,724 lines that built every controller, owned the develop session and its render loop, and registered every develop-screen callback inline — the state CH-1 in docs/dev/code-health.md describes. This is the mechanical split CH-1 calls for, done in one pass rather than section by section since develop_ui.rs only compiles once lib.rs stops registering those callbacks itself. develop_ui.rs is new: a DevelopWiring struct holding the session, the rows model, the redraw/render closures and the other controllers' handles, and one wire_* function per section run() used to contain — presets, export, the adjustment panel, film, undo/redo, zoom/pan/crop, rotation/flips/ straightening, navigation and peaking — called in the order run() registered them. window travels as its own parameter throughout rather than living on the struct, because the generated AppWindow type is not Clone; every other field is an owned clone so each function's body could be pasted from run() unchanged. lib.rs::run is now under 300 lines: construction and startup only, calling named functions for the window and its diagnostics/inference wiring, the launch/library/collections/identity/settings screens, import and merge, the render loop (render_now/redraw/show), the remote open path, the window chrome (resize, layout class, panel toggles, back gesture), and develop_ui::wire for the rest. Every TRACES and GESTURE comment moved with the code it annotates. Two latent type errors surfaced while restructuring rather than being introduced by it: activity and display were being passed around as bare ActivityLog/DisplayWatch instead of the Rc<...> their constructors actually return, which only worked before because nothing needed to name the type explicitly. |
||
|
|
6b1aac477d |
Put the developer docs under docs/dev and index the folder for users first
docs/ had 26 developer documents flat beside the manual, and the two audiences are very differently sized: most readers want the manual and the gesture reference, a few want the register, the designs and the measurements. The manual and gestures.md stay at the top; everything for someone changing the code moves to docs/dev/, and the two documents that name their own successors — the v0.1 milestone and the UI-refinement plan — go to docs/dev/archive/ rather than being deleted, since both are still cited. docs/README.md is the index, users first. Every reference follows: code comments, Cargo manifests, the workflows, the pre-commit hook, the bench and traceability tools (which locate the repo root by docs/dev/requirements.md now), packaging, the Docker READMEs, CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level deeper and is regenerated. Links out of the moved documents into the tree gain a level; a link checker over every Markdown file finds none broken. |
||
|
|
2f47087223 |
Measure the white balance probe in camera RGB, where the gains multiply
Pressing "pick" and clicking a near-neutral wall on a Canon 6D frame set tint to -77 and turned the whole photograph green. The white balance operation runs first in the chain, on camera RGB, before the body's base curve and colour matrix; the probe was read off a display render after all three, and the solve treated that sRGB triple as if the gains multiplied it directly. On a JPEG the two spaces coincide, which is why the existing tests passed while the picker was broken on every raw file. The probe now reads the camera-space tap a merge stitches from, composed under the edit's own framing so a fraction of the canvas is a fraction of the probe, and puts the as-shot balance on itself - exactly the value the operation's gains are about to multiply. No operations run in the tap, so nothing has to be stripped and restored, and the display target is left alone, so a sample that found nothing usable no longer needs a redraw. A raw-frame test with the 6D's matrix and a typical as-shot balance samples a warm grey and asserts the rendered pixel comes back neutral; it fails on the previous probe. |
||
|
|
e43ae10439 |
Offer the border fill on the merge page, experimental, with every knob on it
A Border choice beside the projection — crop to the picture, or fill it — that redraws the preview filled so the invented pixels are seen before they are confirmed (FR-MRG-1), greyed with the reason when the model is not there. The job fills at half the composite's resolution in a display-ish space (white balance, matrix, gamma; invertible) and samples the result back into the linear DNG wherever no frame reached; the sidecar's merge line says border filled and with which knobs. Experimental because the fill is right in thin borders and wrong in deep corners, where the model's Places2 prior puts clouds in sky and water under grass; so its six knobs — working scale, edge erosion, coarse pass, band width, mirror depth, seam feather — are sliders under the choice, each committing a redraw, until the defaults are right. |
||
|
|
5c00942b84 |
One completeness job over a registry of repairs, and a re-index button
A library's records are never all complete at once. A face found before its quality was kept has no quality; one found before the eye models existed has no reading; one adopted from a peer's shard has no crop; an image the fast detector examined on a 1024 px proxy has boxes the current detector would not have drawn; an image the scan stat'ed has no capture date. On the reference library that is 17,762 faces under the bare w600k_mbf id with no quality, no reading and no dense landmarks, 4,144 of them without a crop, beside 12,217 images the fast detector examined and found nothing in. Every one of those gaps was its own pass — V14's measuring pass, §17.5's eye pass, the sweep's proxy repair, the sweep's detector upgrade — with its own work list, its own count and its own idea of done, and adding a per-face field meant adding a pass. There was no pass at all for the case the library is actually in: boxes and landmarks drawn by a weaker detector on a proxy, which every later per-face pass would have read from. dr_ui::repairs replaces them with one job over a registry. A Repair names one thing a record can lack — the predicate that says which images still owe it, the input its handler needs (a header, the original, or a native render), the handler, and what to record for an image that can never be done. The job unions the predicates into one work list, fetches each image once at the most any claimant asks for, renders it at most once, and runs every handler whose predicate that image still matches, checked again before each because a detection writes every field a per-face handler would fill. The registry today: face-proxy, face-quality, face-eyes, face-crop, face-detection, face-upgrade, metadata — the last there to say that this is not a face job. Adding a field is one entry. A repair's predicate is the only definition of its work: the count the settings page shows, the list the job fetches and the check before its handler run are one predicate, so the job converges. That is why the registry is cut to what the device can do rather than listing what it skips — an entry is a count and a set of originals to fetch — and why an eye reading that cannot be cut is not a criterion. The catalog side is generic to match: record_updates writes whichever fields a FaceUpdate carries and re-marks the image so the shards export it; faces_needing and count_needing answer a predicate the caller supplies, replacing the measuring pass's three special cases. Two buttons on the settings page run the job and differ in one predicate. "Index faces" converges on coverage: has anything examined this image. "Re-index every face" converges on provenance: face-detection claims every image with no marker under the chosen detector, in either of its forms (FaceDetector::model_ids, so a desktop in f32 and a tablet on the Hexagon do not re-index each other's work), and a marker saying a weaker one looked is not that. An original over the fetch budget is left exactly as it was under the re-index, where the sweep marks it examined: a re-detection with nothing found would delete the faces, and "cannot fetch" is not "no faces". |
||
|
|
05508741af |
Start the inference engine from both apps and show its choice in Settings
The desktop names where a package may have put libonnxruntime — an override variable, beside the executable, the package's own library directory, the Flatpak prefix, the system library directory — and Android points at the APK's native library directory, which is also what Qualcomm's DSP loader must be told for the Hexagon skel. Android starts the engine at the end of the model unpack rather than at launch, because the probe fingerprints the model files and a first launch has none until then. The About panel gains an Inference row beside Graphics, re-read every two seconds while the probe runs and engines land, and faces.model_id carries the detector's form: an int8 detector finds a different set of faces and is a different population (docs/inference.md §7). A low-memory signal drops every idle session with the GPU caches. The APK assembly bundles ONNX Runtime and the Qualcomm HTP libraries from Maven, fetched by tools/fetch-android-runtime.sh with their published checksums; RUNTIME_DIR=none builds the tract-only APK, which is a slower app and not a broken one. The desktop packages carry no runtime yet. Two probe fixes from the first desktop run: the floor must not be built with CPU fallback disabled, and a versioned libonnxruntime.so is a runtime too. On the reference desktop the probe now loads ONNX Runtime 1.30, measures 30 ms on the CPU provider, and selects TensorRT at 1.5 ms. |
||
|
|
2e9a1eb0f0 |
The merge job and its page: a selection to a panorama DNG, confirmed first
dr_ui::merge is the orchestration with no interface in it: decode each frame to sensor data and build its graph as a session would (orientation, lens profile); render each through the camera-space tap at proxy size and detect keypoints there, so the alignment is measured in the undistorted frame the tiles are rendered in; align; solve one gain per frame from the proxies' overlaps; draw the aligned set in colour for the page; then wait. Nothing is written until a Decision arrives (FR-MRG-1). The merge writes a linear DNG through the outbox with a destination record, so the drain puts it beside its sources on a folder library and a server alike, and the library rescans (FR-MRG-3). merge.slint is the page, on the import page's model: the alignment table with a failed frame named on its row and the button held off (FR-MRG-5), the preview, the projection choice, Stop and Back. A "Merge to panorama" button joins the grid's selection bar at two frames. Headless, the example produces the fixture's 22 993 x 5 980 DNG in 45 s on the reference desktop, exposures balanced across the stop of drift. |
||
|
|
83f4253b6a |
Filter the grid to a person with their eyes open
An "Eyes open" chip beside the people chips, offered only while someone is chosen and dropped when the last person goes, so no term narrows the grid with nothing on the bar to say so. It compiles the rule in dr_face::eyes into the person's face subquery — Anna, eyes open, whoever else is blinking beside her — and drops a frame only on a closed eye that could be read: sunglasses, eyes too small or soft to read, and faces never read all pass, so an old library shows everything under the chip until the measuring pass has run. A test drives the same readings through the SQL and through the rule and requires them to agree. The People screen badges a face "Eyes closed", "Sunglasses" or "Eyes unclear" so the reason a frame is or is not in the grid can be read off the face; the sweep loads the three models when they are beside the pair and reads eyes on the indexing and measuring passes from the native render; the coverage line counts unread faces as work to measure so an already-indexed library keeps its Index button. The term travels with the place. |
||
|
|
78cb00634e |
Fetch the photographs around the open one ahead of the step to them
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m59s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / android-image (push) Canceled after 0s
🐳 Android image / Build and push (push) Canceled after 0s
Build and test / Android (aarch64) (push) Canceled after 0s
Build and test / windows-image (push) Canceled after 0s
🐳 Windows image / Build and push (push) Canceled after 0s
Build and test / Windows (x86_64, cross) (push) Canceled after 0s
Build and test / Layer separation (push) Canceled after 0s
Build and test / Desktop (Linux) (push) Canceled after 16m26s
Traceability / Requirement traces (push) Canceled after 0s
Walking the photo roll was one download per frame: every step showed "Downloading…" over an empty canvas while tens of megabytes came down, and moving between a pair of near-identical frames paid that a dozen times. Now, once the opened photograph has landed, the ones around it are fetched into the originals cache while it is being looked at, so the next step is a disk read. A single worker serves the latest wish only, closest first and working outwards — next, previous, next-but-one, previous-but-one… — one file at a time. Each open replaces the wish, so a fast walk never leaves a trail of stale downloads competing with the one being waited on. A process-wide in-flight registry makes a click on a photograph that is still being fetched ahead wait for that transfer and read it from disk, rather than start a second download of the same file. How far each side is a setting under STORAGE — Off, 2, 5, 10 or 20, defaulting to 5 — and it is moot while "keep originals after opening" is off, since a fetch the cache would discard on arrival is transfer for nothing. Nothing is fetched ahead while offline. The transfers show in the activity list while they run and are removed when they end. |
||
|
|
33e2e277a2 |
Set the Wayland app id late enough for it to take
The launcher and the task bar have shown a generic tile for a working window since the call was written. set_xdg_app_id sat at the top of run(), on the reasoning that the app id is read when the surface is created — true, and beside the point: the call goes through Slint's global context, and there is no global context until something installs a platform. That is BackendSelector inside shared_gpu, or AppWindow::new falling back to the default, and both happen further down. Called before either, it returned NoPlatform and did nothing at all. It moves to just after the window is constructed, which is not the same as shown — run() is far below — so there is a platform to talk to and the surface does not exist yet. The failure was logged at debug, which is why a year of grey squares went unremarked: the whole symptom is invisible from inside the application. It is a warning now, naming the consequence. |
||
|
|
896188a489 |
Read the sidecars other editors write, and write them back on request
Benchmarks / CPU and I/O (per commit) (push) Successful in 10m59s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 1h33m36s
Build and test / Layer separation (push) Successful in 1m2s
Traceability / Requirement traces (push) Successful in 1m25s
🐳 Android image / Build and push (push) Successful in 9s
Build and test / android-image (push) Successful in 9s
Build and test / Android (aarch64) (push) Successful in 56m59s
FR-CAT-13 asked for standard XMP and `core/dr-xmp` answered the file: it
has read and written `dc:subject`, `xmp:Rating`, `xmp:Label` and the IPTC
core since
|
||
|
|
d3b6127db6 |
Let a photographer name the state they liked, and go back to it or look at it
FR-DEV-5 asked for named snapshots of an edit state and FR-DEV-7 for a comparison against a chosen one, and neither existed. The history stack is per sitting and forgotten with it, on purpose — the gap that mattered was an automatically saved mis-drag with no way back, and that was closed first. What was left was the other half: a state the photographer wants to keep *because* it is worth keeping, which is a different thing from a step and is not served by making the steps last longer. A snapshot is an edit state, and an edit state is exactly what a sidecar version stores, so it is stored as one: a `[version]` block carrying `snapshot-of = <uuid>`. The parameters, the masks and their parts, the repairs and the film all arrive through the blocks that already carry them, a merge keys on the uuid as it does for any version, and a build that predates the key reads the block as a named version and keeps it — the right failure. Only the pointer is new. The one reader that has to know is `default_version`, which must never answer with a snapshot: a file whose edit is missing is not a file whose edit is one of its saved moments. The snapshots of an edit are listed by that pointer, oldest first, the same on every device. Writing them back removes what this sitting deleted and puts in what it holds, and leaves standing whatever it never saw — a snapshot the other device took since the photograph was opened here is not this device's to remove by not knowing about it. That is the rule the version merge already keeps, applied one level down, and it is why the save carries the deleted ids rather than replacing the list wholesale as the masks are. Each is re-pointed at the uuid the save settled on, because the default may have been fused onto its canonical identity since the snapshot was taken. Restoring is one history step, so undo takes it back whole, as a paste is. Taking and deleting are not steps: they change nothing about the photograph, and an undo that removed a snapshot would be undoing a decision to remember. Holding the eye beside one renders the snapshot and hands the edit straight back — the same suspension "Before" uses, against a point the photographer chose rather than the file. Two sessions on the same photograph get ids that cannot collide, stamped with the second and a random word, because the merge folds equal ids into one. |
||
|
|
369eb8fbf0 |
Put the log and the crash records in one file, and show it before writing it
NFR-OPS-1 asks for a diagnostics bundle — the log, the schema version, the GPU and driver, the app version — "with an explicit preview-and-consent step before anything leaves the device". The log and the crash records have existed since August; what did not exist was any way to hand them over that was not `adb pull` and a knowledge of where the state directory is, which on the tablet the requirement was written for is nobody. Nothing here sends anything, and that is the design rather than a gap: crash.rs already says why a transport built ahead of the consent is the shape of thing that gets switched on by default. The bundle writes one text file to a place the user can find, so that they can attach it. That is the moment it leaves, and it is theirs. So the consent guards the write, not a send. Preparing gathers everything into memory and shows what would be written — each section, its size, what was taken out, and where the file would go — and only the second press puts bytes on disk. A user who reads the preview and presses the other button has changed nothing anywhere. The gathered bundle is held between the presses so what is saved is exactly what was shown, not a second gathering that differs by whatever was logged while they were reading. One text file rather than an archive, because a `.txt` opens wherever the user is sitting and pastes into an issue, and because the preview can then be the file rather than a summary of it. Every line goes through the blunter of the two redactions on the way in, whatever the sink already did to it: the log's own rule keeps paths, since a path read over `adb` is context, but a file meant to be attached to a public report by someone who may not read it first is held to the crash record's rule instead. The About page's graphics line gains the driver, which the requirement names and the adapter has always reported. And docs/outstanding.md is corrected on both OPS requirements: it said crash reporting was a log::error! hook and NFR-OPS-1 had nothing behind it, and neither had been true since 2026-08-30. |
||
|
|
9cc52fd72b |
Bind the two develop gestures that were described and not bound
FR-DEV-16's book said resetting a control and hiding a mask layer were reachable by pointer and by finger, and stopped there. The reason was honest: the generated rows have no focus, so "reset the focused control" named a thing the panel could not point at. But a photographer at the keyboard means something narrower than focus. They mean the slider they just dragged too far, and that is a thing the panel can remember. So the Adjustments global keeps the last control moved — two indices, written where the panel forwards the change and cleared when the next photograph opens, so a reset cannot reach back into the previous edit through an index that happens to be shared. R puts it back, through the same callback the track's double-click takes, and is silent until something has moved. The mask layer needs no such notion, because the panel already has a selection: the rows the edge controls point at. H hides or shows those, through the path the ring at the head of the row takes, so it is an edit and a history step exactly as the ring is. A mixed selection goes to shown, since the layer nobody can see is the one being asked about. Both tags now carry the key, and the book says so. |
||
|
|
4f31123b0c |
Let the user choose which SCRFD finds their faces
faces.md §12.3 measured what the cheapest detector costs: the small faces in every group shot, and a dog embedded a dozen times. Which trade is right depends on the machine doing the sweep — a desktop left overnight and a tablet on a battery want different answers — so the detector is now a per-device setting, Fast / Balanced / Thorough on the settings page beside the indexing button, persisted with the rest of the settings file. A detector is half of a model id. Every face, marker, shard and calibration is keyed on faces.model_id precisely so that a model change is a new id and a re-index rather than a silent change under existing data, and a detector change is a model change: it decides which faces exist and where the landmarks that align them land. So each choice names its own pipeline. 500M keeps the bare "w600k_mbf" every existing library was written under, so an upgrade disturbs nothing; the others are qualified. Choosing one restarts coverage from zero under the new id, the sweep re-detects, confirmed names carry across by box overlap, and the sync shards are keyed by the same id so a peer on another setting neither adopts nor pollutes them. The library controller carries the id into the sync the same way it carries the cache budget, because the sync starts from places that have no settings in reach. All three shape-fixed exports ship — APK, Arch, Flatpak — since a tablet has no other way to obtain the one it was not installed with; the APK grows by twenty megabytes for the choice. |
||
|
|
404fea47a8 |
Wrap the lines the merge resolution left long
Benchmarks / CPU and I/O (per commit) (push) Successful in 2m59s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 33m34s
Build and test / Layer separation (push) Successful in 55s
Traceability / Requirement traces (push) Successful in 42s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Successful in 23m43s
`cargo fmt --check` failed the desktop job, on three files and for one reason: routing the mask handlers through the `Masking` global was done by substituting the call prefix, which is a text edit rather than a Rust one. It left `window.global::<Masking>().on_part_join_picked(...)` on a line that had been short enough as `window.on_mask_part_join_picked(...)` and no longer was. Formatting only. The whitespace-stripped source is identical in the two `ui/` files; the third differs by the trailing commas rustfmt adds when it breaks a call across lines. The matrix moves with it, because the tags shift by a few lines and the check compares line numbers. |
||
|
|
2d878c2117 |
Offer the film stock in its own group, and let its list scroll itself
Two faults in one control, both reported from the tablet. The stock picker appeared in every group. It is not a parameter, so it is not a row, so the filter that hides every other control when a group is chosen never saw it — "Kodachrome" sat at the top of Light, of Colour and of Detail alike. Three places it does not belong, and the one it does no more prominent than the rest. The descriptor has said `Effect` and only `Effect` since the film moved there; nothing was asking it. So the panel now asks. It cannot ask directly — a generated panel may not know which operation a control belongs to — so the session answers, from what the operation declares it is about, and a stock re-declared as something else would move on its own. The flag is recomputed when the group changes as well as when the film does, which is the half that would have made it stale exactly when it mattered. And the open list was unbounded, so it made the develop column taller and the column scrolled as one: reaching Velvia dragged every slider below it off the screen, an answer given once pushing aside the controls used constantly. It now scrolls within a bounded height of its own. That viewport is counted rather than measured, for the reason the tool rail records a few files away: a viewport that asks a layout how tall it wants to be, while the layout takes its height from the viewport, is a cycle Slint settles by handing back the height it was given — and the content is then clipped in silence rather than scrolling. Every row here is one fixed height, so multiplying is exact. The group rule has a test. The scrolling does not, and cannot: it is a layout, and a layout fault is invisible to the compiler and to every assertion that can be written about it. |
||
|
|
0a5eab0487 |
Wire the develop panels through globals, so a second copy is one line
Every panel in the develop column declared its inputs and its callbacks and had `app.slint` bind each one to a property or a callback on the window root. That is fine while a panel is drawn once. N9 draws them a second time, in the portrait dock, and the wiring is what would have to be copied: `MaskPanel` alone ran to forty lines of forwarding, and a callback added to one copy and not the other compiles, renders, and simply does nothing on the layout nobody was looking at. So the wiring moved to Slint globals. A panel reads the global and calls the global; Rust hooks the global instead of the window; and the instantiation in the column is now the panel's name and a pair of braces — every one of the ten children of the column, with no property that differs by placement left to supply. There is a global per panel family rather than one for all of them, and the reason is an import cycle. Each panel's model struct — `ParamRow`, `MaskRow`, `HistogramView` — is declared in the panel's own file, so a single global holding `[MaskRow]` and `[ParamRow]` would have to live in a file importing `masks.slint` and `adjust.slint` while both imported the global back, which Slint rejects. Breaking that needs six model declarations relocated, which is a change to the data model and not to the plumbing this is about. A global beside the panel it serves also lets each name drop the prefix it was carrying only because the window root is one flat namespace: `root.spot-radius` is `Repair.radius`, and `root.peaking-on` is `Peaking.showing`. `session.slint` is new and holds the two facts every family needs and none of them owns: whether there is an open photograph to edit, and which mode the view is in, with the three readings of the mode derived once instead of at each of the dozen places that tested one. `ViewMode` moves there from `adjust.slint`, where it was only ever a lodger. Nothing on screen changes. What is not here: the tool rail and the status strip still take their properties at the instantiation, because they are drawn once and N9 does not copy them; the preset sheet's own state stays on the window, because the library grid opens the same sheet and a global cannot bind the window's state — which is why `Transfer.open-presets` is handled in `presets.rs`, beside the summary it already had to compute. |
||
|
|
5f0b11c1f4 |
Ask the window how tall it is, and say when the column belongs below
D-N7 puts the develop column under the photograph on a tall window, and the axis it turns on is aspect rather than width: a 960-wide portrait tablet is expanded by width and wants the dock, a 1500-wide landscape desktop is expanded by width and does not. So this cannot be folded into the layout class, and it is not remembered per class either — closing the column in landscape closes the dock in portrait, because it is the same column. `window-resized` reported width alone and now reports both, from a `shell-height` that subtracts the safe-area insets exactly as `shell-width` subtracts them: on Android the strips the status and navigation bars occupy are on the axis being measured, so the aspect of the window and the aspect of the space the interface actually gets are not the same number. `column_below` is the decision, with two thresholds rather than one. It is read on every resize event, and a single threshold means a window dragged along its own diagonal crosses it several times a second while the pointer is still down. Entering at 1.25 and leaving at 1.15 is a dead band no plausible drag re-crosses. The comment on EXPANDED_MIN_WIDTH claimed a tablet in portrait gets the compact layout. It does not — its panel is about 960 logical pixels across, which clears 820 — and that mistaken example is the one D-N2 reasoned from. Corrected in the same breath, since this is the commit that says what portrait actually changes. |
||
|
|
30468c4c69 |
Put the window-metrics doc on the function it describes
The comment explaining why both coordinate systems go on one line was written for `log_window_metrics` and sat above `window_metrics_level`, where it read as the start of that function's much longer note. Two doc blocks ran into each other and the one that prints had none. |
||
|
|
428d8c4a51 |
Say what size the window actually is, in both coordinate systems
Every figure in D-N7's table was computed at a guessed scale factor. The tablet's panel is 3000 by 1920 physical and nothing in this repository has ever recorded the density Android reports for it, so the dock's width is either 900 or 1037 and its available height is 200px either way. N6 asks for the measurement; this is the line that carries it. Beside the existing `apply_layout_class` call, because that is where the window is already being asked for its size and its scale, and again on every resize, so turning the tablet over records the other orientation in the same logcat. Both coordinate systems on one line: a logical size cannot be checked when the scale is the thing in doubt, and a physical size that does not divide by the scale printed next to it says the reading is of something other than the panel. The level is not fixed, because none of the three obvious choices works. `android_main` caps the facade at info, so debug never leaves the device and a debug-only line answers nothing. A drag emits a resize per frame and each accepted record is also appended to the on-disk log, so info on every resize is not a diagnostic. And the first reading is not the settled one: on X11 the window reports 0x0, then 360x320 at scale 1.0, then 1100x720 at scale 2.0, so reporting only the first would put a number in logcat that is not the window's. So the pair that decides is the scale factor and the orientation -- exactly what N6 is asking for, and exactly what a drag leaves alone. A window with no area is not a reading and records nothing. Every later change to either half, the scale resolving or the tablet turning over, is a new answer and goes out at info; everything else is debug. |
||
|
|
901f51e6c4 |
Point at something grey and let the pipeline work out the rest
FR-DEV-3 has asked for "white balance (temperature/tint, and picker)" since it was written, and only the first half existed. `WidgetKind::WhitePoint` was in the vocabulary and `develop::supported` answered false for it, so the node degraded to two sliders — correct behaviour that had quietly become the only behaviour. Sampling a neutral is the first move of the global tonal pass and every colour judgement afterwards is measured against where the grey was put, so guessing at two sliders until a wall stops looking green is the wrong way round. The awkward part is that a picker genuinely needs to know how far a hundred units of temperature move red against blue, and that number is declared in the node's own file. So the inversion lives in `dr_pipeline::neutral` rather than in the interface: the canvas hands over a colour, the core finds the operation that asked to be driven by a pixel and bisects its declared response until the sample comes back grey. Nothing in `ui/` names white balance, and nothing holds a second copy of a response that would be wrong the first time somebody adjusted the range. A bisection rather than a closed-form inverse because only monotonicity is part of the bargain — the expression is free to become a table tomorrow. The result is rounded to the precision the control is drawn at, which is not cosmetic: unrounded, sampling something already neutral lands a ten-thousandth off zero, and the photograph comes back modified with an undo step for a correction of nothing. On the panel side this needed one distinction the generated path was missing. `is_on_canvas` was being read as "and so the panel draws nothing for it", which is right for a crop — four edge fractions are not controls anyone drags in a list — and wrong for an eyedropper, which *writes* temperature and tint and leaves them exactly the controls a photographer reaches for next. So a sampling widget keeps its sliders and puts the affordance that arms the canvas in the group's heading, built like the reset beside it. One click, one sample, one history step: `Edit::Action` never coalesces, and there is no hover preview to fill the stack with temperatures nobody chose. Declaring the presentation also groups temperature and tint under one undo step, where they were two. That follows from what `Presentation` means and reads correctly — white balance is one decision — but it is a change, and worth saying so. |