Compare commits

...
Author SHA1 Message Date
Claude 47fe7f70be Batch 26: cargo fmt over the merged PRs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 11:42:57 +00:00
Claude e2a38c26e0 Masking: keep mask_overlay's doc comment on mask_overlay (#957 put the eyedropper between them)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 11:40:59 +00:00
squareSphere29 761a91c233 fix: EXIF values under histogram run together (#913)
- Filter empty EXIF values and join with ' · ' separator (matches grid.rs pattern)
- Single centered text element — no overlap possible
- Fixes 'ISO 1000200mm' visual collision when focal length or aperture missing

(cherry picked from commit 44a29d7fc7)
2026-10-11 11:40:54 +00:00
squareSphere29 a33dc70a3c fix: EXIF values under histogram run together (#913)
- Filter empty EXIF values instead of rendering empty strings at fixed positions
- Distribute remaining items evenly across available width using (i + 0.5) / count
- Apply left/center/right alignment dynamically based on item position
- Fixes 'ISO 1000200mm' visual collision when focal length or aperture missing

(cherry picked from commit 28ab23e633)
2026-10-11 11:40:54 +00:00
Claude bb4cdeed2a Merge PR #957 2026-10-11 11:40:42 +00:00
Claude 805a15ae9b Merge PR #943 2026-10-11 11:40:42 +00:00
Claude 69b90ee7c4 LR-META-EXIF-FULL: tags.rs back to LF line endings (#831 mixed in CRLF)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 11:40:41 +00:00
Claude 170f3956a1 Merge PR #831 2026-10-11 11:40:41 +00:00
ClaudeandVikram Bhaskaran 2bc33268d2 Issue #793: the last two mentions of target/web point at target/web-site (from #888)
Co-Authored-By: Vikram Bhaskaran <vikram.bhaskaran@outlook.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
2026-10-11 11:40:34 +00:00
Claude 0e4b52e29b Merge PR #746
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	docs/parity-checklist.md
2026-10-11 11:40:26 +00:00
Claude dba63bc9d5 Merge PR #747
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	docs/parity-checklist.md
2026-10-11 11:40:17 +00:00
Claude 69f117632b Merge PR #754
# Conflicts:
#	crates/engine/src/media.rs
2026-10-11 11:39:46 +00:00
Claude 5350a8ed70 Merge PR #773
# Conflicts:
#	crates/ui-egui/src/panels/detail.rs
2026-10-11 11:39:40 +00:00
Claude aa48f6f6e7 Merge PR #935 2026-10-11 11:39:28 +00:00
Claude f9f3fbe4db Merge PR #774 2026-10-11 11:39:28 +00:00
Claude f4d8a428ac Merge PR #848 2026-10-11 11:39:28 +00:00
Claude f9919acc7f Merge PR #785 2026-10-11 11:39:28 +00:00
Claude f8c01f70fb Merge PR #748 2026-10-11 11:39:27 +00:00
Claude 8f42f4a96d Merge PR #753 2026-10-11 11:39:27 +00:00
Claude d5d9daa555 Merge PR #759 2026-10-11 11:39:27 +00:00
Claude bda8569f67 Merge PR #760 2026-10-11 11:39:27 +00:00
Claude 0a46f615dc Merge PR #767 2026-10-11 11:39:26 +00:00
Claude b23b20638e Merge PR #772 2026-10-11 11:39:26 +00:00
Claude 9ecd3309b2 Merge PR #779 2026-10-11 11:39:26 +00:00
Claude 5abc39ae7d Merge PR #799 2026-10-11 11:39:26 +00:00
Claude 11dd3bd6b3 Merge PR #803 2026-10-11 11:39:26 +00:00
Claude 68f75cc07a Merge PR #809 2026-10-11 11:39:26 +00:00
Claude 72b02c65b7 Merge PR #817 2026-10-11 11:39:25 +00:00
Claude 5bbce8b9fd Merge PR #855 2026-10-11 11:39:25 +00:00
Claude f30ad32d93 Merge PR #858 2026-10-11 11:39:25 +00:00
Claude 09461d04c7 Merge PR #873 2026-10-11 11:39:25 +00:00
Claude f98749343f Merge PR #877 2026-10-11 11:39:25 +00:00
Claude 95662cfff5 Merge PR #883 2026-10-11 11:39:25 +00:00
Claude 14679de823 Merge PR #885 2026-10-11 11:39:25 +00:00
Claude b038c018c2 Merge PR #829 2026-10-11 11:39:24 +00:00
Claude 72a7cd80b8 Merge PR #792 2026-10-11 11:39:24 +00:00
Andrew FrahnandClaude Opus 5.5 a7382aacfb Masking: Color Range "Pick" samples the photo
With the Masking panel open the loupe routes clicks to mask_overlay, so the
colorRange eyedropper armed by the Pick button (handled only in
general_interaction) never ran and no sample was taken. mask_overlay now
handles the eyedropper (crosshair cursor, click samples, shift-click adds)
through a shared color_range_pick helper.

Fixes #954

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 22:20:03 +11:00
Lucas LepskiandGPT-6.1 Sol 353c1cf566 UI-PILL: retain padding fix with upstream toolbar layout
Preserve upstream compact-toolbar layout and keyboard regressions alongside the rating and flag padding regression.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
2026-10-11 13:07:05 +02:00
MarekSpodBiedry 2c8520371c Dropping photos with XMP sidecars no longer imports the sidecars as presets (#936) 2026-10-11 11:37:46 +02:00
ingolfteetzandClaude Opus 5.5 ad3c7315bb Tests: hold the MCP export-cancel test's export after its first photo until cancelled (#674)
export_progress_and_cancel failed every time on a fast machine (M5 Max, 4
export lanes): the 8 demo photos exported within the 100 ms progress
throttle, so the first progress above 0 was the final 8/8 and the cancel
arrived after the export had finished (a reply to the cancelled request,
or all 8 files on disk).

The test now wraps Headless in a test-only backend that, at the first
photo boundary, keeps polling the server's real progress hook (which
answers pings and reads cancels) until the cancel is read or the test
releases it, bounded at 20 s so a broken cancel fails rather than hangs.
The first export is released once the mid-batch ping is answered (that
was the same race), the second only ends by the cancel. That makes the
outcome exact, so the check tightens from "1..7 files" to "exactly the
one completed photo", which also proves the photos the other lanes had
already rendered are never written. No production code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:52:59 +02:00
PiMinusMaxandClaude Opus 5.5 385348b258 LR-PROF-CAMERACOLOR: the camera-look guard blends in smoothly instead of switching (#820)
Between a kept luminance share of 0.15 and 0.25 the table colour is now blended in with a smoothstep,
so noisy shadows show no step between pixels that get the table colour and pixels that keep theirs.
At 0.25 and above pixels render exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:24:09 +02:00
Nigel Ihza Firdausy 38756b89d0 fix(query): saturate pagination values in 32-bit web builds 2026-10-11 11:04:36 +07:00
Nigel Ihza Firdausy d9d1a5a730 fix(ui): show completion for library maintenance and Auto Sync (#875) 2026-10-11 10:59:36 +07:00
robmdunnandClaude 21d71feb04 LR-HDR-EXPORT: HDR renders keep the camera tone curve in the midtones
Raws with a fitted camera look (e.g. Nikon NEF) rendered HDR with the
generic tone curve instead, more than a stop darker through the midtones
than the SDR render: PQ JPEG XL / AVIF exports looked dark everywhere,
and gain map JPEGs carried negative gains, so they went dark on HDR
displays. ToneMap::camera_hdr keeps the camera curve up to 1.5 EV over
grey and lifts highlights as ToneMap::hdr does.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-10 22:15:17 -04:00
THEtheChadandGPT-6 Astra aec298c116 test(engine): LRC-LIB-LIBFOLDERS cover mixed Windows separators
Exercise both separator directions for folder and individual-file imports.
Verify duplicate detection, stable photo IDs, added/deleted file detection,
and reuse of a confirmed scan when the folder spelling changes.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
2026-10-11 01:59:03 +00:00
THEtheChadandGPT-6 Astra dac0d4c6e2 test(engine): LRC-LIB-LIBFOLDERS normalize sync fixture paths
Rebuild fixture path components with native separators so expected paths
match directory scans on Windows, including files that have been removed.
This is a prerequisite for Windows validation and overlaps the path-helper
fix in upstream PR #660; it can be dropped once that change lands.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
2026-10-11 01:59:03 +00:00
skylar taylor-barrick 0ba118de92 Merge pull request #869 from skylartaylor/codex/ui-suite-integration-20261010
Add shared docking while preserving photo tools and catalog drops
2026-10-10 19:57:59 -06:00
skylar taylor-barrick a7e8f1cc26 UI.9: reconcile upstream before shared UI landing 2026-10-10 19:07:51 -06:00
PiMinusMaxandClaude Opus 5.5 d76a4121e6 LR-PROF-CAMERACOLOR: near-black pixels no longer turn white in the camera look (#820)
HueSat::apply restores luminance with before / after. Near black, where noise makes some channels
negative, the hue/saturation table can bring after close to 0, and the gain turned single pixels
white (isolated white dots in dark areas, most visible at fit). Luminance is now restored only if
the table kept at least 25 % of it; such colours keep their colour, like black and non-finite ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:32:34 +02:00
john01px2022andClaude Opus 5.5 7d27cf8bfb LR-CROP-STRAIGHTEN: test the rotate pointer off all four crop corners
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:18:57 -07:00
john01px2022andClaude Opus 5.5 6807421f64 LR-CROP-STRAIGHTEN: the rotate pointer faces away from its side of the crop box
The crop tool's rotate pointer always bulged up, which only reads right
above the box. It now turns to bulge away from the nearest point of the box
outline: right beside the right edge, down below it, left beside the left
edge, diagonally off a corner, and following a box that is already rotated.
icons::paint_turned draws an icon turned about its centre; icons::paint is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:09:44 -07:00
skylar taylor-barrick 9bfabc98b1 UI.9: integrate shared docking and preserve panel interactions 2026-10-10 16:22:21 -06:00
PiMinusMaxandClaude Opus 5.5 40d7b86ecb LR-VIEW-BEFOREAFTER: click tools work in the Before view of side by side / top-bottom
In the side-by-side and top/bottom Before/After layouts a click in the
Before view was mapped to image coordinates through the After view's
rectangle, so the White Balance Selector sampled the wrong point (at Fit,
the photo's edge). The click tools in general_interaction now use the
Before view's image rectangle when the pointer is in the Before view's
pane; the pane decides, since zoomed in an image reaches past its pane.

New headless test tests_before_after.rs clicks the same image point in
the After and Before views (side by side, top/bottom; Fit and 100 %) and
expects the same white balance. Without the fix: Before 8202 K / +116,
After 2710 K / +33.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:11:49 +02:00
radialmonster 8f3f168802 LR-META-EXIF-FULL: raw sensor IFD Compression 7 reads Lossless JPEG (#830) 2026-10-10 17:59:11 -04:00
PiMinusMaxandClaude Opus 5.5 a67d571656 LR-VIEW-COMPARE: a filmstrip click shows the photo in Compare
In Compare a filmstrip click made the clicked photo active without
showing it, so the Edit panel and the rating keys acted on a photo on
neither side (a headless test rated the hidden photo 2 -> 3 stars). The
click now puts the photo into the active side (the candidate after
entering Compare, the select after clicking the select's photo); a photo
already on the other side swaps the two. The active photo is always one
of the two shown. Other views keep the usual selection clicks.

New UI command compare.set {id, side?: "select" | "candidate"} for the
same through the control channel and MCP.

New headless tests in tests_compare.rs: the active photo stays on screen
and no hidden photo is rated (Fit and 100 %), and replace / swap /
explicit side / unknown side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:51:43 +02:00
radialmonster 7ce13cfd07 LR-META-EXIF-FULL: All Metadata shows the raw image's Compression, NEFCompression in words (#830) 2026-10-10 17:51:27 -04:00
eliets2 a940dab727 web: stop the release zip shipping Cargo build artifacts
lightcraft-web-<version>.zip carried Cargo host build output next to the site: deps/ (81 files, 90.6 MB), build/ (149 files, 22.4 MB) and .fingerprint/ (297 files), ~113 MB that the app never references and that leaked build paths and crate names to anyone hosting the zip.

Cause: xtask wrote the bundle to <target>/web (SITE in packaging/web/package.sh) while building with --profile web. Cross-compiling with --target wasm32-unknown-unknown makes Cargo write the selected profile host artifacts (build scripts, proc-macros, deps/, build/, .fingerprint/) to <target>/<profile>/, so they landed in the bundle directory and package.sh copied the whole tree into the zip.

Fix: write the bundle to <target>/web-site, a directory that is not a Cargo profile name, and point SITE at it. The wasm32/web/ target artifacts and the web profile are unchanged, so the rust-cache keys and the CI web job keep working.

Guard: package.sh now fails if deps/, build/ or .fingerprint/ exists in $SITE, and a unit test asserts the bundle directory is not a Cargo profile name, so the collision cannot come back silently.

Reference (#793)
2026-10-11 00:46:10 +03:00
PiMinusMaxandClaude Opus 5.5 8e9c5aff7e LR-EDIT-COLOR-WB-PICKER: the selector button does what W does
The White Balance Selector button in the Edit panel only armed the tool,
so in Compare (which doesn't handle on-image tools) it did nothing while
W opened the photo in Detail with the selector armed. The button now runs
tool.wbPicker like W; a second click still disarms it.

New headless test tests_wb_selector.rs clicks the button in Compare and
expects Detail with the selector armed on the photo that was active; it
fails without the fix (the view stays Compare).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:32:08 +02:00
radialmonster 5a975d0d75 LR-IMP-FORMATS: CR3 CRX quantization 44 (QP 168) decodes, continuing the step sequence
Canon EOS R50 C-RAW files reach QP 168 (quant 44, step 102, measured about 100). The quantization step and the adaptive QP map range accept it; anything above stays rejected. Bumps RENDER_CACHE_VERSION since such files change from the embedded preview to a raw decode.
2026-10-10 17:20:31 -04:00
PiMinusMaxandClaude Opus 5.5 0067879a58 KEY-ESC: Esc leaves the crop tool before leaving Detail, and a text field's Esc stays its own
view.back (Esc) closed dialogs, the keyword painter, full screen and an
armed on-image tool, but not the crop tool (a right panel, not
app.ui.tool), so Esc with the crop tool open went straight to the grid.
It now closes the crop tool like C does (the crop stays); the next Esc
goes back to the grid.

Esc that ends an edit in a text field (the crop tool's Angle field) also
ran Back: egui drops the field's focus as Esc arrives, before
shortcuts::handle checks egui_wants_keyboard_input(). The app now
records whether a text field had the keyboard at the end of the last
frame (LightcraftApp::text_focus); that Esc is then left to the field,
like one that closes a popup.

New headless tests in tests_crop_tool.rs; both fail without the fix
(the existing escape_in_the_angle_field_keeps_the_angle fails with only
the crop step).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:15:13 +02:00
PiMinusMaxandClaude Opus 5.5 0adcb21166 LR-CROP-FLIP: Flip H / Flip V show in the crop tool
The frame dropped the flips together with the crop (flip_h: apply_crop &&
...), so the crop tool's uncropped view (render and screen <-> image
mapping) ignored Flip H / Flip V until the tool was closed. A flip isn't
part of the crop: the frame now keeps it without the crop. Cropped
renders and exports are unchanged.

The crop overlay's dimming paired the photo's corners with the crop
box's corners in data order; flipped, the box reaches the screen
mirrored and the photo was dimmed inside the box. It now takes the box's
corners in screen order (flipped_corners). Box, handles, drags and guides
already go through the frame's mapping.

Tests: geometry uncropped_frame_keeps_the_flips (fails without the fix)
and detail flipped_crop_box_corners_come_in_screen_order; headless
screenshots for H, V and H+V checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:03:29 +02:00
PiMinusMaxandClaude Opus 5.5 bc5c561dd8 LR-VIEW-BEFOREAFTER: zoomed side by side / stacked views stay in their panes
Zoomed in, each view's photo reaches past its pane. The whole-frame renders
were drawn unclipped (only the window renders were clipped to their pane),
so the After's magnified whole-frame render covered the Before pane's margins
and the gap between the panes with a blurred picture, and the Before side
looked like it didn't fill its pane. Both whole-frame renders are now clipped
to their pane, like the windows over them; the Before / After labels stay
under the pane instead of moving off screen with the photo's bottom edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:47:43 +02:00
Lucas LepskiandGPT-6.1 Sol fe92e844d0 UI-PILL: keep rating and flag buttons inside their pill
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
2026-10-10 22:06:52 +02:00
Lucas LepskiandGPT-6.1 Sol e1267a7139 UI-MODAL: block background input while application dialogs are open
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
2026-10-10 21:55:57 +02:00
Jason Baldwin a3c33658bb Merge origin/main into brush-hold-hardens (clean merge; tracker edit followed the parity-checklist.md rename) 2026-10-10 21:49:33 +02:00
Jason Baldwin dd73753723 Merge origin/main into info-dialogs-close (no conflicts; MENU-HELP-SYSINFO note now in docs/parity-checklist.md) 2026-10-10 21:16:30 +02:00
PierrunoYTandClaude Sonnet 5.5 3cd471b32d Smooth the plain mouse-wheel zoom like Ctrl+wheel
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 21:08:25 +02:00
Jason Baldwin 1064e9847d Merge origin/main into enter-applies-copy-paste (parity rows applied to docs/parity-checklist.md) 2026-10-10 21:07:39 +02:00
PierrunoYTandClaude Sonnet 5.5 732a2f04fd Test: wheel zoom in the loupe with and without Ctrl
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 20:59:29 +02:00
PierrunoYTandClaude Sonnet 5.5 7f966965fc Fix #714: mouse wheel zooms the loupe around the pointer
A notched wheel (line/page units, no modifiers) zoomed nothing; only
double-click and pinch/Ctrl+wheel did. Trackpad scrolls still pan.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 20:56:23 +02:00
radialmonster 9da719c6ed LR-EDIT-RESET: Reset Edits returns a raw to its import defaults, sharpening and colour NR included
Reset Edits rebuilt a photo without a default-preset look from DevelopSettings::default(),
so a raw imported with capture sharpening 40 and colour noise reduction 25
(DevelopSettings::for_raw) came back with both at 0, and with its embedded lens
corrections off. It now starts from the photo's own built-in defaults
(Photo::camera_defaults, the same as import) and keeps the white balance of its
current as-shot reference, as before. Rendered files are unchanged (their defaults
are DevelopSettings::default()). Reported in #763's notes.
2026-10-10 14:01:26 -04:00
Jason Baldwin 19833e56e3 MENU-HELP-SYSINFO: System Info, All Metadata and What's New end with one Close button (#782)
Their confirm does nothing, so they join About, Shortcuts and Settings in the
footer's informational list: one Close, no Cancel / OK. System Info keeps Copy
to Clipboard; Esc still closes them.
2026-10-10 18:50:12 +02:00
radialmonster 0bd3f3715d LR-BEHAV-GPU: no indirect-call validation pipeline, which Intel Metal fails to compile (issue #250)
On a 2015 MacBook Pro (Intel Iris/Metal), 0.5.0 never opened its window, even with
LIGHTCRAFT_GPU=0: wgpu's indirect-call validation builds an internal compute pipeline when the
window's device is created, Metal's compiler fails on it ("Compiler encountered an internal
error"), the device is lost and eframe exits. That validation only guards indirect draws and
dispatches, which neither LightCraft nor egui issue.

backend::instance_flags clears InstanceFlags::VALIDATION_INDIRECT_CALL for every instance
LightCraft creates (the window's, Gpu::new and the denoise device), unless
WGPU_VALIDATION_INDIRECT_CALL asks for it. Tests: the flag rule, and that the window's instance
descriptor goes without it.
2026-10-10 12:11:41 -04:00
Alex ChambersandClaude Opus 5.5 6dca631482 LR-IMP-CAMERA-COVERAGE: SRF and DSLR-A100 raws open again (#702)
Since #281, probe takes a Sony file as a raw only with raw evidence, and
two layouts have none of the kinds has_raw_ifd looks for: SRF (DSC-F828,
an RGB-looking IFD0 of 14-bit samples) and the DSLR-A100 (raw data
addressed by SubIFDs). Both were "not a readable photo". For Sony, IFD0
samples of 12 or 14 bits, or model DSLR-A100 with an old-style JPEG IFD0
(compression 6) and SubIFDs, now count too; an 8- or 16-bit camera TIFF,
or an exported A100 TIFF with a SubIFDs thumbnail, stays an image. The public F828 and
A100 samples open through their previews as on main 5436951; 133 other Sony files unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:50:29 +02:00
Jason Baldwin 61df5d304f LR-MASK-BRUSH: holding the brush still adds no coverage; flow builds up by passes (#518)
A stationary hold recorded one pointer sample per frame and each sample laid
another dab at the same spot, so flow accumulated with hold time and the
feathered edge hardened. brush_dabs now lays no dab for a sample that hasn't
moved from the last one, and the canvas no longer records repeated samples at
the same position. Moving strokes and passes back over an area are unchanged.
2026-10-10 17:21:23 +02:00
Jason Baldwin bbcbbebf8e LR-EDIT-COPY-PASTE: Enter applies the Copy and Paste Settings dialogs (#735)
Return presses OK in Choose Edit Settings to Copy and Paste Selected
Settings when no widget has the keyboard focus (a Tab-focused button or
checkbox takes Return itself, as egui does). Esc already cancelled them;
tests now cover both keys.
2026-10-10 16:49:39 +02:00
radialmonster e7f8faf77d LR-IMP-FORMATS: EOS R5 Mark II crop-mode CR3s develop without a black band
The R5 Mark II's APS-C crop-mode files (5376 x 3574) state an IAD1 active area of x 132..5243,
but columns 132..~256 sit at the black level and the image runs to the right edge (measured on
raw.pixls.us 7883/7884), while their recommended crop, x 272..5359 (5088 x 3392, the camera JPEG's
size), lies on the image. The recommended crop didn't fit inside the stated active area, so the
whole active area was kept: a black band down the left and ~130 image columns lost.

When the recommended crop doesn't fit inside the stated active area, it is now the valid area.
On 7883 the render's high-pass correlation with the camera JPEG goes from -0.13 to 0.99. The
active area and crop of the other 108 decodable CC0 CR3s on raw.pixls.us are unchanged.

RENDER_CACHE_VERSION 49.
2026-10-10 10:14:32 -04:00
TicT4xandClaude Opus 5.5 b59ffcc4c9 raw: find the saturation plateau despite stray samples above it
white_from_data anchored the plateau search on the absolute maximum, so a
handful of stray samples above saturation (Canon EOS 70D CR2: 83 of 20.6M,
up to 14426 vs. a plateau at 13583) made it fall back to 2^bits-1. The
image rendered ~0.3 EV dark and clipped highlights were never seen as
clipped. Skip up to half a plateau's worth of strays when locating the top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:07:47 +02:00
UnemarsandClaude Opus 5.5 5bcedbe6fc NEF: measured image area and white level for the Nikon D4
The D4 records no CropArea. Its 4992 x 3292 data keeps 2 columns of optical
black left of the image and 42 right of it (then 8 empty ones), which showed
as purple stripes on both sides; the camera's own 4928 x 3280 JPEG sits at
(8, 6). Green saturates at 15546-15672 while red and blue run on to 16383,
so the data's own top left clipped highlights magenta; one level of 15520
keeps them white without moving the white balance.

Measured on 148 uncompressed 14-bit NEFs from one body by aligning the
sensor data with the embedded camera JPEG.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 15:38:18 +02:00
81 changed files with 3985 additions and 453 deletions

No files matched your search

Generated
+103
View File
@@ -988,6 +988,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.59.0",
]
[[package]]
name = "combine"
version = "4.6.8"
@@ -1062,6 +1072,15 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1202,6 +1221,19 @@ dependencies = [
"thousands",
]
[[package]]
name = "dify"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
dependencies = [
"anyhow",
"colored",
"getopts",
"image",
"rayon",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -1447,6 +1479,27 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_kittest"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
dependencies = [
"dify",
"eframe",
"egui",
"egui-wgpu",
"image",
"kittest",
"log",
"open",
"pollster 1.0.1",
"serde",
"tempfile",
"toml",
"wgpu",
]
[[package]]
name = "either"
version = "1.18.0"
@@ -2230,6 +2283,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -2588,6 +2650,25 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -2909,6 +2990,16 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
]
[[package]]
name = "kurbo"
version = "0.13.1"
@@ -3343,7 +3434,9 @@ dependencies = [
name = "lightcraft-ui-egui"
version = "0.6.0"
dependencies = [
"craft-ui",
"egui",
"egui_kittest",
"lightcraft-catalog",
"lightcraft-codecs",
"lightcraft-color",
@@ -4169,6 +4262,16 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
dependencies = [
"is-wsl",
"libc",
]
[[package]]
name = "orbclient"
version = "0.3.55"
+1
View File
@@ -10,6 +10,7 @@ rust-version = "1.90"
repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# internal
lightcraft-denoise-core = { path = "crates/denoise-core" }
lightcraft-denoise = { path = "crates/denoise", default-features = false }
+4
View File
@@ -343,6 +343,8 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
## Quick start
Workspace builds, including the desktop, web and CLI applications, require Rust 1.95 or newer.
```sh
git clone https://github.com/storytold/lightcraft && cd lightcraft
cargo run --release -p lightcraft # opens your library (~/Pictures/LightCraft Library; a new one starts with demo photos)
@@ -571,3 +573,5 @@ Forks and modified versions must remove them.
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
+1
View File
@@ -114,6 +114,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
## Progress log
- **2026-10-10 (UI docking):** Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Widget state styling uses the shared `craft-ui` theme helper, retaining the dark palette, typography, spacing and per-state borders. No readiness percentage change.
- 2026-10-10 (RAW): RAW measured model by model against Adobe's 1,446-model list (234 verified, 943
unverified, 233 preview-only, 36 unsupported; ~84% of photographers' cameras decode, ~40% on a
verified body). Full ~61%, mainstream ~49%, essentials ~64%; beta ~14 points away.
+1 -1
View File
@@ -1,6 +1,6 @@
//! LightCraft in the browser: the same egui UI as the desktop app, compiled to WASM.
//!
//! Build with `cargo xtask web` (→ `target/web/`), then serve that folder over HTTP (see
//! Build with `cargo xtask web` (→ `target/web-site/`), then serve that folder over HTTP (see
//! `docs/web.md`). Differences from the desktop host:
//! - the library lives in browser storage (OPFS, or IndexedDB where OPFS can't be written from the
//! main thread): catalog journal, presets, view state and prefs are mirrored in memory and
+115 -3
View File
@@ -98,6 +98,8 @@ fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
if let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = &mut c.wgpu_setup {
n.instance_descriptor.backends = lightcraft_engine::gpu::backend::window_backends();
n.instance_descriptor.backend_options = lightcraft_engine::gpu::backend::backend_options();
// no indirect-call validation pipeline: Intel Metal fails to compile it and the window never opens (#250)
n.instance_descriptor.flags = lightcraft_engine::gpu::backend::instance_flags_from_env(n.instance_descriptor.flags);
}
c
}
@@ -183,6 +185,23 @@ fn load_prefs(in_memory: bool) -> (Option<UiState>, Option<String>, bool) {
load_prefs_at(&path, !in_memory)
}
/// Compatibility recovery is limited to persisted docking, not runtime UiState commands.
fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> {
let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?;
let fields = saved.as_object_mut().ok_or("settings must be an object")?;
let recovered =
fields.get("docking").is_some_and(|value| match serde_json::from_value::<Option<lightcraft_ui_egui::docking::Workspace>>(value.clone()) {
Ok(Some(workspace)) => workspace.validate().is_err(),
Ok(None) => false,
Err(_) => true,
});
if recovered {
fields.remove("docking");
}
let ui = serde_json::from_value(saved).map_err(|error| error.to_string())?;
Ok((ui, recovered))
}
fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiState>, Option<String>, bool) {
let bytes = match std::fs::read(path) {
Ok(b) => b,
@@ -192,8 +211,15 @@ fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiS
return (None, Some(msg), true);
}
};
match serde_json::from_slice::<UiState>(&bytes) {
Ok(ui) => (Some(ui.sanitized()), None, false),
match decode_saved_ui(&bytes) {
Ok((ui, false)) => (Some(ui.sanitized()), None, false),
Ok((ui, true)) => {
let msg = format!(
"The saved panel layout in {} couldn't be restored. Other settings were loaded; the original file is preserved and settings won't be saved this session.",
path.display()
);
(Some(ui.sanitized()), Some(msg), true)
}
Err(e) if !set_aside_damaged => {
let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display());
(None, Some(msg), true)
@@ -270,6 +296,9 @@ impl PrefsWriter {
return Ok(());
}
let Some(path) = self.path.clone() else { return Ok(()) };
if let Some(workspace) = &ui.docking {
workspace.validate()?;
}
let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?;
if bytes == self.written {
return Ok(());
@@ -608,6 +637,7 @@ OPTIONS:
--version, --help
ENVIRONMENT:
LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder)
LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS,
Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering.
LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: <settings folder>/models/sam3;
@@ -663,10 +693,16 @@ fn control_port_from(what: &str, value: Option<String>) -> Option<u16> {
port
}
/// An isolated profile keeps the emergency crash log with its other app data. With no
/// override (or an empty override), retain the historical system-temp location.
fn panic_log_path_with(env: impl Fn(&str) -> Option<std::ffi::OsString>, temp: std::path::PathBuf) -> std::path::PathBuf {
env("LIGHTCRAFT_CONFIG_DIR").filter(|value| !value.is_empty()).map(std::path::PathBuf::from).unwrap_or(temp).join("lightcraft-panics.log")
}
fn main() -> eframe::Result {
// First, so every start-up record is kept for the log file (`logging`).
let logger = logging::install();
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
lightcraft_engine::guard::install_hook(panic_log_path_with(|name| std::env::var_os(name), std::env::temp_dir()));
if let Some(logger) = logger {
logging::record_panics(logger);
}
@@ -904,6 +940,59 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn docking_survives_native_settings_file_reload() {
use lightcraft_ui_egui::docking::Panel;
let d = dir("docking-reload");
let path = d.join("ui.json");
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
app.run("ui.dock", serde_json::json!({"operation":"float", "panel":"tools", "rect":[70,80,310,500]})).unwrap();
app.run("ui.dock", serde_json::json!({"operation":"close", "panel":"tools"})).unwrap();
let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() };
writer.save_ui(&app.ui, false).unwrap();
let (restored, warning, keep) = load_prefs_at(&path, false);
assert!(warning.is_none());
assert!(!keep);
let mut restored_app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
restored_app.ui = restored.unwrap();
restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap();
assert_eq!(
restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect,
[70.0, 80.0, 310.0, 500.0]
);
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn future_docking_preserves_library_settings_and_the_original_preferences_file() {
let d = dir("future-docking");
let path = d.join("ui.json");
let mut ui = UiState { thumb_size: 210.0, filmstrip: false, ..Default::default() };
ui.settings.library_path = "/Volumes/Photos/Synthetic Library".into();
for docking in [serde_json::json!({"layout":{"root":{"FutureLayout":{}}}}), serde_json::json!(false)] {
let mut saved = serde_json::to_value(&ui).unwrap();
saved["docking"] = docking;
assert!(serde_json::from_value::<UiState>(saved.clone()).is_err(), "runtime state parsing stays strict");
let bytes = serde_json::to_vec(&saved).unwrap();
for set_aside in [false, true] {
std::fs::write(&path, &bytes).unwrap();
let (loaded, warning, keep) = load_prefs_at(&path, set_aside);
let loaded = loaded.unwrap();
assert_eq!(loaded.settings.library_path, ui.settings.library_path);
assert_eq!(loaded.thumb_size, ui.thumb_size);
assert!(!loaded.filmstrip);
assert!(loaded.docking.is_none());
assert!(warning.unwrap().contains("Other settings were loaded"));
assert!(keep);
let mut writer = PrefsWriter { path: Some(path.clone()), keep_file: keep, ..Default::default() };
writer.save_ui(&loaded, false).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), bytes);
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "a future layout does not rename or replace the settings file");
}
}
std::fs::remove_dir_all(d).unwrap();
}
/// Issue #164: the writer saves changed settings to its file — and nothing at all without one
/// (`--memory`) or while the session is the temporary one offered when the library can't open.
#[test]
@@ -999,6 +1088,16 @@ mod tests {
}
}
/// Issue #250: the window's device is created without wgpu's indirect-call validation pipeline,
/// which Intel Macs' Metal compiler fails to build (the device was lost, the window never opened).
#[test]
fn window_has_no_indirect_call_validation_pipeline() {
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
assert!(!n.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
}
}
/// Issue #234: Windows opens links with the URL protocol handler (the default browser), not
/// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument.
#[test]
@@ -1056,3 +1155,16 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
}
#[cfg(test)]
mod config_path_tests {
use super::*;
#[test]
fn config_override_keeps_panic_log_in_isolated_profile() {
let temp = std::path::PathBuf::from("temp");
assert_eq!(panic_log_path_with(|_| Some("isolated".into()), temp.clone()), std::path::PathBuf::from("isolated/lightcraft-panics.log"));
assert_eq!(panic_log_path_with(|_| None, temp.clone()), temp.join("lightcraft-panics.log"));
assert_eq!(panic_log_path_with(|_| Some("".into()), temp.clone()), temp.join("lightcraft-panics.log"));
}
}
+1 -1
View File
@@ -363,7 +363,7 @@ pub fn rule_date(s: &str) -> Result<String, Issue> {
_ => false,
}
};
if !shaped(&s) && !(s.get(..19).is_some_and(shaped) && s.get(19..).is_some_and(tail_ok)) {
if !(shaped(&s) || s.get(..19).is_some_and(shaped) && s.get(19..).is_some_and(tail_ok)) {
return Err(Issue::NeedsDate);
}
let part = |a: usize, b: usize| s.get(a..b).and_then(|t| t.parse::<u32>().ok());
+1 -1
View File
@@ -411,7 +411,7 @@ pub fn compute(
if n == 0 || sdr.len() < n || hdr.len() < n {
return Err(Error::Encode("gain map: SDR and HDR renditions must match the image size".into()));
}
if !matches!(o.scale, 1 | 2 | 4) || !(o.gamma > 0.0 && o.gamma.is_finite()) || !(o.offset > 0.0 && o.offset.is_finite()) {
if !(matches!(o.scale, 1 | 2 | 4) && o.gamma > 0.0 && o.gamma.is_finite() && o.offset > 0.0 && o.offset.is_finite()) {
return Err(Error::Encode("gain map: scale must be 1, 2 or 4, gamma and offset positive".into()));
}
let s = o.scale;
+84 -1
View File
@@ -833,6 +833,22 @@ fn to_prophoto() -> Mat3 {
PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz())
}
/// [`HueSat::apply`] restores the luminance in full only if the table kept at least this share of it. A colour
/// the table darkens to [`NO_LUMINANCE_KEPT`] or less (only near-black noise, whose channels go negative) keeps
/// its colour; in between the two are blended, so noisy shadows show no step.
const MIN_LUMINANCE_KEPT: f32 = 0.25;
const NO_LUMINANCE_KEPT: f32 = 0.15;
/// How much of the table's colour a pixel gets when the table kept `share` of its luminance: 0 up to
/// [`NO_LUMINANCE_KEPT`], 1 from [`MIN_LUMINANCE_KEPT`], a smoothstep between.
fn restore_weight(share: f32) -> f32 {
if !share.is_finite() {
return 0.0;
}
let t = ((share - NO_LUMINANCE_KEPT) / (MIN_LUMINANCE_KEPT - NO_LUMINANCE_KEPT)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// A fitted [`CameraLook::hue_sat`] table, ready to apply to linear Rec.2020 pixels. It changes
/// hue and saturation only: luminance is restored, the camera tone curve owns it.
pub(crate) struct HueSat<'a> {
@@ -852,7 +868,19 @@ impl<'a> HueSat<'a> {
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
let out = mul(&self.from, self.table.apply(mul(&self.to, rgb)));
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
if before > 0.0 && after > 0.0 && out.iter().all(|v| v.is_finite()) { out.map(|v| v * before / after) } else { rgb }
// restoring the luminance divides by the table's: near black, where noise makes some channels
// negative, the table can bring it close to 0, and the gain would turn the pixel white (#820)
if before <= 0.0 || after <= 0.0 || !out.iter().all(|v| v.is_finite()) {
return rgb;
}
let w = restore_weight(after / before);
if w >= 1.0 {
return out.map(|v| v * before / after);
}
if w <= 0.0 {
return rgb;
}
std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w)
}
}
@@ -1439,6 +1467,61 @@ mod tests {
assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples");
}
#[test]
fn hue_sat_never_turns_near_black_noise_white() {
// a saturating table, as fitted to high-ISO Sony files; near-black noise with mixed-sign channels
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
let hue_sat = HueSat::new(&table).unwrap();
let mut seed = 0x9e37_79b9_7f4a_7c15u64;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f32 / (1u64 << 53) as f32 * 2.0 - 1.0
};
let mut worst = 0.0f32;
for _ in 0..200_000 {
let rgb = [0.002 + 0.004 * next(), 0.002 + 0.004 * next(), 0.002 + 0.004 * next()];
if luminance_2020(rgb) <= 0.0 {
continue;
}
let out = hue_sat.apply(rgb);
let scale = rgb.iter().fold(0.0f32, |m, v| m.max(v.abs()));
worst = worst.max(out.iter().fold(0.0f32, |m, v| m.max(v.abs())) / scale);
}
assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel");
}
#[test]
fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() {
assert_eq!(restore_weight(0.0), 0.0);
assert_eq!(restore_weight(NO_LUMINANCE_KEPT), 0.0);
assert_eq!(restore_weight(MIN_LUMINANCE_KEPT), 1.0);
assert_eq!(restore_weight(1.0), 1.0);
assert_eq!(restore_weight(f32::NAN), 0.0);
// monotonic and without a step: neighbouring shares 0.001 apart differ by little
let mut last = 0.0f32;
for i in 0..=400 {
let w = restore_weight(i as f32 * 0.001);
assert!(w >= last && w - last < 0.02, "step at {}: {last} → {w}", i as f32 * 0.001);
last = w;
}
}
#[test]
fn hue_sat_keeps_colours_the_table_leaves_bright_exactly_as_before() {
// the blend only touches colours whose luminance the table cut below MIN_LUMINANCE_KEPT
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
let hue_sat = HueSat::new(&table).unwrap();
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
for rgb in [[0.4, 0.2, 0.1], [0.05, 0.3, 0.2], [0.18, 0.18, 0.18], [0.6, 0.1, 0.5]] {
let out = mul(&hue_sat.from, table.apply(mul(&hue_sat.to, rgb)));
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
assert!(after >= MIN_LUMINANCE_KEPT * before);
assert_eq!(hue_sat.apply(rgb), out.map(|v| v * before / after));
}
}
fn raw_of(format: RawFormat, make: &str, model: &str, color: lightcraft_raw::ColorData) -> RawImage {
let area = lightcraft_raw::Rect::new(0, 0, 2, 2);
RawImage {
+5 -1
View File
@@ -239,10 +239,14 @@ pub fn specs() -> Vec<CommandSpec> {
let fresh = match look {
Some(l) => (*l).clone(),
None => {
// the photo's built-in defaults (raws: capture sharpening and colour noise
// reduction, embedded lens corrections), as at import, with the white balance
// of its current as-shot reference
let info = s.source_info(id);
let defaults = s.catalog.photo(id).map(|p| p.camera_defaults()).unwrap_or_default();
DevelopSettings {
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint },
..Default::default()
..defaults
}
}
};
+22 -2
View File
@@ -59,6 +59,12 @@ fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result<PhotoId> {
}
}
// Catalog queries also run in the 32-bit web build. A narrowing cast wraps
// an out-of-range offset to zero, returning the first page instead of no photos.
fn pagination_usize(value: u64) -> usize {
usize::try_from(value).unwrap_or(usize::MAX)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "catalog.query", "Query Photos", [], None, "{filter?: Filter, sort?: Sort, offset?, limit?} — omit filter to list the current view", always, |s, p| {
@@ -69,8 +75,8 @@ pub fn specs() -> Vec<CommandSpec> {
} else {
s.visible_cloned()
};
let off = p.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
let lim = p.get("limit").and_then(Value::as_u64).unwrap_or(200) as usize;
let off = pagination_usize(p.get("offset").and_then(Value::as_u64).unwrap_or(0));
let lim = pagination_usize(p.get("limit").and_then(Value::as_u64).unwrap_or(200));
let items: Vec<Value> = ids.iter().skip(off).take(lim).filter_map(|id| s.catalog.photo(*id)).map(|p| photo_summary(p)).collect();
Ok(json!({"total": ids.len(), "photos": items}))
}),
@@ -237,3 +243,17 @@ pub fn specs() -> Vec<CommandSpec> {
}),
]
}
#[cfg(test)]
mod pagination_tests {
use super::*;
#[test]
fn offsets_and_limits_saturate_instead_of_wrapping_on_wasm() {
assert_eq!(pagination_usize(0), 0);
assert_eq!(pagination_usize(200), 200);
assert_eq!(pagination_usize(u64::MAX), usize::MAX);
#[cfg(target_pointer_width = "32")]
assert_eq!(pagination_usize(u64::from(u32::MAX) + 1), usize::MAX);
}
}
+82 -8
View File
@@ -6,21 +6,42 @@
//! face- or denoise-models folder until a host asks for one ([`Session::with_default_face_models`],
//! [`Session::with_default_denoise_models`]), so tests stay hermetic.
use std::ffi::OsString;
use std::path::PathBuf;
use crate::Session;
/// LightCraft's per-user config folder (`…/LightCraft`), if the platform tells us where.
#[derive(Clone, Copy)]
enum Platform {
Mac,
Windows,
Other,
}
/// LightCraft's settings folder. A nonempty `$LIGHTCRAFT_CONFIG_DIR` overrides the platform default.
/// The override is the complete directory; no application-name suffix is appended. An explicitly
/// empty value disables the settings directory instead of falling back to a personal profile.
pub fn config_dir() -> Option<PathBuf> {
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft"))
let platform = if cfg!(target_os = "macos") {
Platform::Mac
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("LightCraft"))
Platform::Windows
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.map(|c| c.join("lightcraft"))
Platform::Other
};
config_dir_with(platform, |name| std::env::var_os(name))
}
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
if let Some(dir) = env("LIGHTCRAFT_CONFIG_DIR") {
return (!dir.is_empty()).then(|| PathBuf::from(dir));
}
match platform {
Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft")),
Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("LightCraft")),
Platform::Other => {
env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config"))).map(|c| c.join("lightcraft"))
}
}
}
@@ -56,3 +77,56 @@ impl Session {
self.denoise.touch();
}
}
#[cfg(test)]
mod config_tests {
use super::*;
fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option<PathBuf> {
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))))
}
#[test]
fn config_override_wins_without_reading_user_profile() {
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
let actual = config_dir_with(platform, |name| {
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
Some(OsString::from("isolated/settings"))
});
assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
}
}
#[test]
fn config_empty_override_disables_profile_without_reading_defaults() {
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
assert_eq!(
config_dir_with(platform, |name| {
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
Some(OsString::new())
}),
None
);
}
}
#[test]
fn config_unset_override_keeps_platform_defaults() {
let vars = [("HOME", "/user"), ("APPDATA", "/roaming"), ("XDG_CONFIG_HOME", "/xdg")];
assert_eq!(resolve(Platform::Mac, &vars), Some(PathBuf::from("/user/Library/Application Support/LightCraft")));
assert_eq!(resolve(Platform::Windows, &vars), Some(PathBuf::from("/roaming/LightCraft")));
assert_eq!(resolve(Platform::Other, &vars), Some(PathBuf::from("/xdg/lightcraft")));
assert_eq!(resolve(Platform::Other, &[("HOME", "/user")]), Some(PathBuf::from("/user/.config/lightcraft")));
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
assert_eq!(resolve(platform, &[]), None);
}
}
#[cfg(unix)]
#[test]
fn config_override_preserves_non_unicode_paths() {
use std::os::unix::ffi::OsStringExt as _;
let value = OsString::from_vec(b"isolated/\xff".to_vec());
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value)));
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 42;
pub const RENDER_CACHE_VERSION: u64 = 50;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
+27
View File
@@ -37,6 +37,33 @@ fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
}
}
/// Reset Edits on a raw goes back to its import defaults, capture sharpening and colour noise
/// reduction included (#763: reset left both at 0 while import sets 40 / 25); a rendered file
/// keeps none.
#[test]
fn reset_edits_restores_a_raws_import_defaults() {
use lightcraft_catalog::{Photo, PhotoId, Source};
use lightcraft_develop::DevelopSettings;
let mut s = demo();
for (id, format, raw) in [(200u64, "NEF", true), (201, "JPG", false)] {
let name = format!("p{id}.{format}");
let mut p = Photo::new(PhotoId(id), Source::File { path: name.clone() }, &name, format, 16, 16, "");
if raw {
p.as_shot_wb = Some((5200.0, 4.0));
p.develop = std::sync::Arc::new(DevelopSettings::for_raw(5200.0, 4.0));
}
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
for (id, want) in [(200, (40.0, 25.0)), (201, (0.0, 0.0))] {
s.execute("library.select", &json!({"ids": [id], "active": id})).unwrap();
s.execute("develop.set", &json!({"control": "detail.sharpenAmount", "value": 90})).unwrap();
s.execute("develop.set", &json!({"control": "detail.nrColor", "value": 70})).unwrap();
s.execute("develop.reset", &json!({})).unwrap();
let d = active_dev(&s);
assert_eq!((d.detail.sharpen_amount, d.detail.nr_color), want, "photo {id}");
}
}
#[test]
fn demo_library_loads() {
let mut s = demo();
+65 -1
View File
@@ -32,7 +32,9 @@ impl Scratch {
Scratch(dir)
}
fn path(&self, rel: &str) -> String {
self.0.join(rel).to_string_lossy().to_string()
// Fixtures use `/`; rebuild components so expected paths match the native separators
// returned by directory scans, including for files that have already been removed.
self.0.join(rel).components().collect::<PathBuf>().to_string_lossy().into_owned()
}
}
@@ -103,6 +105,68 @@ fn an_unchanged_folder_has_no_changes() {
assert!(paths(&r, "new").is_empty() && paths(&r, "missing").is_empty() && paths(&r, "metadata").is_empty(), "{r}");
}
#[cfg(windows)]
#[test]
fn importing_and_synchronizing_with_different_windows_separators_keeps_photo_identity() {
for forward_import in [true, false] {
for import_folder in [true, false] {
let dir = Scratch::new(&format!("separators-{forward_import}-{import_folder}"));
let folder = dir.path("trip");
let a = dir.path("trip/a.png");
let b = dir.path("trip/day1/b.png");
write_png(&a, 1);
write_png(&b, 2);
let spelling = |path: &str, forward: bool| if forward { path.replace('\\', "/") } else { path.to_string() };
let import_path = spelling(&folder, forward_import);
let sync_path = spelling(&folder, !forward_import);
assert_ne!(import_path, sync_path, "exercise distinct Windows path spellings");
let inputs = if import_folder { vec![import_path.clone()] } else { vec![spelling(&a, forward_import), spelling(&b, forward_import)] };
let mut s = Session::new().with_fs();
let imported = s.execute("library.import", &json!({"paths": inputs})).unwrap();
assert_eq!(imported["imported"].as_array().unwrap().len(), 2, "{imported}");
let before = s.execute("catalog.query", &json!({})).unwrap();
let photos = before["photos"].as_array().unwrap();
let a_id = photos.iter().find(|p| p["fileName"] == "a.png").unwrap()["id"].clone();
let b_id = photos.iter().find(|p| p["fileName"] == "b.png").unwrap()["id"].clone();
let duplicate = s.execute("library.import", &json!({"paths": [sync_path]})).unwrap();
assert!(duplicate["imported"].as_array().unwrap().is_empty(), "{duplicate}");
assert_eq!(duplicate["duplicates"].as_array().unwrap().len(), 2, "{duplicate}");
assert_eq!(s.execute("catalog.query", &json!({})).unwrap(), before, "reimport preserves the photos and their ids");
std::fs::remove_file(&b).unwrap();
let c = dir.path("trip/day2/c.png");
write_png(&c, 3);
let changes = scan(&mut s, &sync_path);
assert_eq!(changes["new"].as_array().unwrap().len(), 1, "{changes}");
assert_eq!(Path::new(changes["new"][0]["path"].as_str().unwrap()), Path::new(&c));
assert_eq!(changes["missing"].as_array().unwrap().len(), 1, "{changes}");
assert_eq!(changes["missing"][0]["id"], b_id, "the missing photo keeps its id");
assert!(changes["moved"].as_array().unwrap().is_empty(), "{changes}");
// Switching spelling again must consume the confirmed scan of this same folder.
let synced = s.execute("folder.synchronize", &json!({"path": import_path, "scanned": true, "removeMissing": true})).unwrap();
assert_eq!(
(synced["imported"].as_u64(), synced["removed"].as_u64(), synced["relinked"].as_u64()),
(Some(1), Some(1), Some(0)),
"{synced}"
);
assert!(synced["failed"].as_array().unwrap().is_empty(), "{synced}");
let after = s.execute("catalog.query", &json!({})).unwrap();
let photos = after["photos"].as_array().unwrap();
assert_eq!(photos.len(), 2, "{after}");
assert!(photos.iter().any(|p| p["id"] == a_id && p["fileName"] == "a.png"), "{after}");
assert!(photos.iter().any(|p| p["fileName"] == "c.png" && p["id"] != a_id && p["id"] != b_id), "{after}");
for path in [&import_path, &sync_path] {
let changes = scan(&mut s, path);
for kind in ["new", "missing", "moved", "metadata"] {
assert!(changes[kind].as_array().unwrap().is_empty(), "{path}: {changes}");
}
}
}
}
}
#[test]
fn files_added_to_the_folder_or_a_folder_inside_it_are_new() {
let dir = Scratch::new("new");
+30
View File
@@ -145,6 +145,25 @@ pub fn backend_options() -> wgpu::BackendOptions {
o
}
/// wgpu's instance flags for every instance LightCraft creates, from `flags` (wgpu's defaults with its
/// environment variables applied) without `VALIDATION_INDIRECT_CALL`, unless
/// `WGPU_VALIDATION_INDIRECT_CALL` asks for it (any value but `0`). That validation builds an internal
/// compute pipeline when each device is created; Intel Macs' Metal compiler fails on it ("Compiler
/// encountered an internal error"), the device is lost and the window never opens (#250). It only
/// guards indirect draws and dispatches, which neither LightCraft nor egui issue.
pub fn instance_flags(flags: wgpu::InstanceFlags, env: Option<&str>) -> wgpu::InstanceFlags {
if env.is_some_and(|v| v != "0") {
flags | wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL
} else {
flags - wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL
}
}
/// [`instance_flags`] with `WGPU_VALIDATION_INDIRECT_CALL` read from the environment.
pub fn instance_flags_from_env(flags: wgpu::InstanceFlags) -> wgpu::InstanceFlags {
instance_flags(flags, std::env::var("WGPU_VALIDATION_INDIRECT_CALL").ok().as_deref())
}
/// `LIGHTCRAFT_GPU_BACKEND=off`.
pub(crate) fn env_off() -> bool {
env_choice() == BackendChoice::Off
@@ -195,6 +214,17 @@ pub fn read_init_marker(path: &std::path::Path) -> Option<String> {
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
/// #250: the indirect-call validation pipeline is off unless asked for; other flags are kept.
#[test]
fn indirect_call_validation_is_off_unless_asked_for() {
use wgpu::InstanceFlags as F;
let base = F::VALIDATION_INDIRECT_CALL | F::DEBUG;
assert_eq!(instance_flags(base, None), F::DEBUG);
assert_eq!(instance_flags(base, Some("0")), F::DEBUG);
assert_eq!(instance_flags(F::empty(), Some("1")), F::VALIDATION_INDIRECT_CALL);
assert_eq!(instance_flags(F::empty(), None), F::empty());
}
use super::*;
#[test]
+2
View File
@@ -284,6 +284,8 @@ impl Gpu {
desc.backends = backends;
// DX12 shaders compile with FXC (issue #471)
desc.backend_options = crate::backend::backend_options();
// no indirect-call validation pipeline: Intel Metal fails to compile it (#250)
desc.flags = crate::backend::instance_flags_from_env(desc.flags);
let instance = wgpu::Instance::new(desc);
let adapter = pollster::block_on(
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
+2
View File
@@ -86,6 +86,8 @@ fn make_device(backends: wgpu::Backends) -> Result<Dev, String> {
desc.backends = backends;
// DX12 shaders compile with FXC (issue #471)
desc.backend_options = crate::backend::backend_options();
// no indirect-call validation pipeline: Intel Metal fails to compile it (#250)
desc.flags = crate::backend::instance_flags_from_env(desc.flags);
let instance = wgpu::Instance::new(desc);
let wanted = std::env::var("LIGHTCRAFT_GPU_ADAPTER").ok().map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty());
let adapter = match &wanted {
+55 -4
View File
@@ -4,8 +4,11 @@
use std::io::{BufRead, BufReader, Cursor, Write};
use std::net::TcpListener;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use lightcraft_mcp::{Backend, Headless, Remote, Server, base64_decode};
use lightcraft_mcp::{Backend, Headless, ProgressHook, Remote, Server, base64_decode};
use serde_json::{Value, json};
/// Feed a whole session through `Server::serve` and return the replies by id.
@@ -154,8 +157,50 @@ fn remote_unreachable_is_a_tool_error() {
assert!(r[0]["result"]["content"][0]["text"].as_str().unwrap().contains("not reachable"));
}
/// [`Headless`] whose batch export, while `hold` is set, waits once its first photo is written:
/// it keeps asking the server's progress hook at that photo boundary (which answers pings and
/// reads cancels meanwhile) until the request is cancelled or the test clears `hold`. A ping or
/// cancel sent after the first progress then lands mid-batch, however fast the machine exports.
struct HoldAfterFirst {
inner: Headless,
hold: Arc<AtomicBool>,
}
impl Backend for HoldAfterFirst {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.inner.call(method, params)
}
fn has_ui(&self) -> bool {
self.inner.has_ui()
}
fn describe(&self) -> String {
self.inner.describe()
}
fn set_progress(&mut self, hook: Option<ProgressHook>) {
let hold = self.hold.clone();
self.inner.set_progress(hook.map(|mut hook| -> ProgressHook {
Box::new(move |done, total, name| {
// bounded, so a cancel that is never read fails the test instead of hanging it
let until = Instant::now() + Duration::from_secs(20);
while done == 1 && done < total && hold.load(Ordering::SeqCst) && Instant::now() < until {
if !hook(done, total, name) {
return false;
}
std::thread::sleep(Duration::from_millis(5));
}
hook(done, total, name)
})
}));
}
}
/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation
/// between complete photos, unrelated output preservation, and a usable session afterwards.
/// Both exports wait after their first photo ([`HoldAfterFirst`]) until the ping is answered or
/// the cancel is read, so neither can finish first on a fast machine.
#[test]
fn export_progress_and_cancel() {
let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id()));
@@ -163,7 +208,9 @@ fn export_progress_and_cancel() {
std::fs::create_dir_all(&dir).unwrap();
let (input, mut send) = std::io::pipe().unwrap();
let (output, write) = std::io::pipe().unwrap();
let server = std::thread::spawn(move || Server::new(Box::new(Headless::demo())).serve(BufReader::new(input), write).unwrap());
let hold = Arc::new(AtomicBool::new(true));
let backend = HoldAfterFirst { inner: Headless::demo(), hold: hold.clone() };
let server = std::thread::spawn(move || Server::new(Box::new(backend)).serve(BufReader::new(input), write).unwrap());
let (tx, rx) = std::sync::mpsc::channel();
let reader = std::thread::spawn(move || {
for line in BufReader::new(output).lines() {
@@ -173,7 +220,7 @@ fn export_progress_and_cancel() {
}
}
});
let next = || rx.recv_timeout(std::time::Duration::from_secs(30)).expect("MCP reply/progress");
let next = || rx.recv_timeout(Duration::from_secs(30)).expect("MCP reply/progress");
writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap();
let query = next();
let ids: Vec<_> = query["result"]["structuredContent"]["photos"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect();
@@ -197,6 +244,8 @@ fn export_progress_and_cancel() {
}
} else if value["id"] == 3 {
answered = true;
// the ping was answered mid-batch: let the export run to the end
hold.store(false, Ordering::SeqCst);
} else {
assert_eq!(value["id"], 2, "{value}");
assert_eq!(value["result"]["isError"], false, "{value}");
@@ -210,6 +259,8 @@ fn export_progress_and_cancel() {
std::fs::create_dir_all(&out).unwrap();
let decoy = out.join("photo999.png");
std::fs::write(&decoy, b"unrelated existing output").unwrap();
// this export waits after its first photo until the cancel below is read
hold.store(true, Ordering::SeqCst);
let mut request = call(4, "command_run", json!({"id":"app.export","params":{"ids":ids,"dir":out,"longEdge":1200,"format":"png"}}));
request["params"]["_meta"] = json!({"progressToken":44});
writeln!(send, "{request}").unwrap();
@@ -234,7 +285,7 @@ fn export_progress_and_cancel() {
}
assert_eq!(std::fs::read(&decoy).unwrap(), b"unrelated existing output");
let photos: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).filter(|p| *p != decoy).collect();
assert!(!photos.is_empty() && photos.len() < ids.len(), "only completed photos remain: {photos:?}");
assert_eq!(photos.len(), 1, "only the photo completed before the cancel remains: {photos:?}");
for path in &photos {
lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file");
}
+135 -2
View File
@@ -170,6 +170,9 @@ fn enumerated(group: &str, tag: u16, v: i64) -> Option<&'static str> {
(0x0103, 8) => "Deflate",
(0x0103, 32773) => "PackBits",
(0x0103, 34892) => "Lossy JPEG",
(0x0103, 34713) => "Nikon NEF Compressed",
(0x0103, 32767) => "Sony ARW Compressed",
(0x0103, 52546) => "JPEG XL",
(0x0106, 0) => "WhiteIsZero",
(0x0106, 1) => "BlackIsZero",
(0x0106, 2) => "RGB",
@@ -269,12 +272,56 @@ fn rows_of(group: &'static str, ifd: &Ifd, out: &mut Vec<TagRow>) {
}
}
/// The IFD holding a raw file's sensor data when it is not IFD0 (a colour-filter-array or linear-raw
/// image in a sub-IFD). IFD0 of such files describes a thumbnail, so its Compression is not the raw's.
fn raw_image_ifd(ifd0: &Ifd) -> Option<&Ifd> {
ifd0.sub_ifds.iter().find(|s| matches!(s.u32(0x0106), Some(32803 | 34892)))
}
/// Nikon maker note `NEFCompression` (0x0093) spelled out, per the public Nikon tag documentation.
fn nef_compression(v: u32) -> Option<&'static str> {
Some(match v {
1 => "Lossy (type 1)",
2 => "Uncompressed",
3 => "Lossless",
4 => "Lossy (type 2)",
5 => "Striped packed 12 bits",
6 => "Uncompressed (reduced to 12 bit)",
7 => "Unpacked 12 bits",
8 => "Small",
9 => "Packed 12 bits",
10 => "Packed 14 bits",
13 => "High Efficiency",
14 => "High Efficiency*",
_ => return None,
})
}
/// Nikon maker note NEFCompression of a raw file, spelled out; `None` for other files or values.
fn nikon_nef_compression(tiff: &Tiff, bytes: &[u8]) -> Option<&'static str> {
let ifd0 = tiff.ifds.first()?;
let make = ifd0.string(0x010f)?;
if !make.to_ascii_uppercase().starts_with("NIKON") {
return None;
}
let e = ifd0.exif.as_ref()?.get(0x927c)?;
let mn = lightcraft_tiff::makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)?;
nef_compression(mn.ifd.u32(0x0093)?)
}
/// Every tag of a parsed TIFF structure (a TIFF-based raw, a TIFF, or an EXIF block): IFD0,
/// then the EXIF, GPS and interoperability IFDs.
/// then the EXIF, GPS and interoperability IFDs. A raw file's Compression row is the sensor
/// image's, not IFD0's thumbnail's.
pub fn tag_rows(tiff: &Tiff) -> Vec<TagRow> {
let mut out = Vec::new();
if let Some(ifd0) = tiff.ifds.first() {
rows_of("TIFF", ifd0, &mut out);
if let Some(c) = raw_image_ifd(ifd0).and_then(|r| r.value(0x0103))
&& let Some(row) = out.iter_mut().find(|r| r.tag == 0x0103)
{
// lossless JPEG (DNG, CR2) on the sensor image; plain "JPEG" would read as lossy
row.value = if c.get_u64(0) == Some(7) { "Lossless JPEG".to_string() } else { value_text("TIFF", 0x0103, c) };
}
if let Some(e) = &ifd0.exif {
rows_of("EXIF", e, &mut out);
if let Some(i) = &e.interop {
@@ -294,7 +341,13 @@ pub fn file_tag_rows(bytes: &[u8]) -> Vec<TagRow> {
if Tiff::sniff(bytes).is_some()
&& let Ok(t) = Tiff::parse(bytes)
{
return tag_rows(&t);
let mut rows = tag_rows(&t);
if let Some(words) = nikon_nef_compression(&t, bytes)
&& let Some(row) = rows.iter_mut().find(|r| r.group == "TIFF" && r.tag == 0x0103)
{
row.value = words.to_string();
}
return rows;
}
let Some(exif) = crate::embedded(bytes).exif else { return Vec::new() };
Tiff::parse(crate::strip_exif_header(&exif)).map(|t| tag_rows(&t)).unwrap_or_default()
@@ -328,4 +381,84 @@ mod tests {
assert!(rows.iter().any(|r| r.group == "GPS" && r.name == "GPS Latitude"), "{rows:?}");
assert!(!rows.iter().any(|r| r.tag == 0x8769), "IFD pointers are left out");
}
fn entry(tag: u16, ty: u16, count: u32, val: u32) -> Vec<u8> {
let mut e = Vec::new();
e.extend(tag.to_le_bytes());
e.extend(ty.to_le_bytes());
e.extend(count.to_le_bytes());
e.extend(val.to_le_bytes());
e
}
fn ifd(entries: &[Vec<u8>]) -> Vec<u8> {
let mut b = (entries.len() as u16).to_le_bytes().to_vec();
entries.iter().for_each(|e| b.extend(e));
b.extend(0u32.to_le_bytes());
b
}
/// A little-endian raw with a thumbnail IFD0 (`Compression` 1), a CFA sub-IFD (`Compression`
/// 34713) and, when `nef` is set, a Nikon maker note whose NEFCompression is that value.
fn synthetic_raw(nef: Option<u16>) -> Vec<u8> {
let make = b"NIKON CORPORATION";
let mut note = b"NikonII*".to_vec();
note.extend(ifd(&[entry(0x0093, 3, 1, nef.unwrap_or(0) as u32)]));
let ifd0_len = 2 + 4 * 12 + 4;
let sub_at = 8 + ifd0_len;
let sub = ifd(&[entry(0x0103, 3, 1, 34713), entry(0x0106, 3, 1, 32803)]);
let exif_at = sub_at + sub.len();
let exif_len = 2 + 12 + 4;
let make_at = exif_at + exif_len;
let note_at = make_at + make.len();
let mut b = b"II*".to_vec();
b.extend(ifd(&[
entry(0x0103, 3, 1, 1),
entry(0x010f, 2, make.len() as u32, make_at as u32),
entry(0x014a, 4, 1, sub_at as u32),
entry(0x8769, 4, 1, exif_at as u32),
]));
b.extend(sub);
b.extend(ifd(&[entry(0x927c, 7, note.len() as u32, if nef.is_some() { note_at as u32 } else { 0 })]));
b.extend(make);
b.extend(note);
b
}
fn compression(rows: &[TagRow]) -> Vec<&str> {
rows.iter().filter(|r| r.group == "TIFF" && r.tag == 0x0103).map(|r| r.value.as_str()).collect()
}
#[test]
fn raw_compression_is_the_sensor_images_not_the_thumbnails() {
let b = synthetic_raw(None);
assert_eq!(compression(&file_tag_rows(&b)), ["Nikon NEF Compressed"]);
}
#[test]
fn nef_compression_is_spelled_out_from_the_maker_note() {
for (v, words) in [(3, "Lossless"), (10, "Packed 14 bits"), (2, "Uncompressed"), (14, "High Efficiency*")] {
let b = synthetic_raw(Some(v));
assert_eq!(compression(&file_tag_rows(&b)), [words]);
}
}
#[test]
fn dng_raw_sub_ifd_jpeg_is_lossless_but_a_plain_jpeg_tiff_is_not() {
let b = synthetic_raw(None);
// switch the sensor sub-IFD's Compression from 34713 to 7
let pos = b.windows(12).position(|w| w == entry(0x0103, 3, 1, 34713).as_slice()).unwrap();
let mut dng = b.clone();
dng[pos + 8] = 7;
dng[pos + 9] = 0;
assert_eq!(compression(&file_tag_rows(&dng)), ["Lossless JPEG"]);
let plain = [b"II*".to_vec(), ifd(&[entry(0x0103, 3, 1, 7)])].concat();
assert_eq!(compression(&file_tag_rows(&plain)), ["JPEG"]);
}
#[test]
fn plain_tiff_keeps_its_own_compression() {
let b = [b"II*".to_vec(), ifd(&[entry(0x0103, 3, 1, 8)])].concat();
assert_eq!(compression(&file_tag_rows(&b)), ["Deflate"]);
}
}
+4 -3
View File
@@ -275,12 +275,13 @@ impl FinishParams {
/// #146) is a new [`Process`] with its own arm here, and V1's arm stays as it is, so photos edited
/// under V1 keep their look (`docs/process-versions.md`). CPU and GPU both take it from here.
/// `peak` is the HDR render's peak relative to SDR white ([`lightcraft_develop::Hdr::peak`]); at 1.0 (SDR)
/// every arm is the SDR tone map. The camera's look is bounded at white, so HDR renders use the HDR tone map.
/// every arm is the SDR tone map. An HDR render keeps the camera's look through the midtones and
/// lifts its highlights past white ([`ToneMap::camera_hdr`]), so it matches the SDR render below them.
pub fn base_tone(s: &DevelopSettings, info: &SourceInfo, peak: f32) -> ToneMap {
let l = &s.light;
match s.process.process() {
Process::V1 => match info.camera_tone.as_ref().filter(|_| info.raw && peak <= 1.0) {
Some(curve) => ToneMap::camera(curve, l.contrast, l.whites, l.blacks),
Process::V1 => match info.camera_tone.as_ref().filter(|_| info.raw) {
Some(curve) => ToneMap::camera_hdr(curve, l.contrast, l.whites, l.blacks, peak),
None if info.raw => ToneMap::hdr(l.contrast, l.whites, l.blacks, peak),
None => ToneMap::display_hdr(l.contrast, l.whites, l.blacks, peak),
},
+21 -2
View File
@@ -71,8 +71,9 @@ impl Frame {
ow,
oh,
crop,
flip_h: apply_crop && s.crop.flip_h,
flip_v: apply_crop && s.crop.flip_v,
// a flip is not part of the crop: the crop tool's uncropped view shows it too
flip_h: s.crop.flip_h,
flip_v: s.crop.flip_v,
warp: (!warp.is_identity()).then_some(warp),
view: None,
};
@@ -387,4 +388,22 @@ mod tests {
assert!((out.get(0, 0)[0] - 3.0).abs() < 1e-5);
assert!((out.get(3, 0)[0] - 0.0).abs() < 1e-5);
}
/// The crop tool's uncropped frame keeps the flips: Flip H / Flip V show while cropping, and
/// screen points map back through the mirror (the crop box is drawn and dragged right).
#[test]
fn uncropped_frame_keeps_the_flips() {
let mut s = DevelopSettings::default();
s.crop.flip_h = true;
s.crop.flip_v = true;
s.crop.geometry.rect = Rect::new(0.1, 0.2, 0.6, 0.9);
let src = Rgb32f::from_fn(4, 2, |x, y| [x as f32, y as f32, 0.0]);
let f = Frame::new(4, 2, &s, false);
let out = f.sample(&src, 4, 2);
assert!((out.get(0, 0)[0] - 3.0).abs() < 1e-5 && (out.get(0, 0)[1] - 1.0).abs() < 1e-5, "{:?}", out.get(0, 0));
assert!((out.get(3, 1)[0] - 0.0).abs() < 1e-5 && (out.get(3, 1)[1] - 0.0).abs() < 1e-5, "{:?}", out.get(3, 1));
// the whole image, mirrored: output top-left is the image's bottom-right
let p = f.out_to_norm(400, 200).apply(Point::new(0.0, 0.0));
assert!((p.x - 1.0).abs() < 1e-9 && (p.y - 1.0).abs() < 1e-9, "{p:?}");
}
}
+66 -4
View File
@@ -440,25 +440,35 @@ pub struct BrushDabs {
pub auto: bool,
}
/// Output pixels a brush sample must move from the last dab to count as movement: below this the
/// pointer is held still (the same screen position mapped again, up to float rounding).
const STATIONARY_PX: f64 = 1e-6;
/// The dabs of stroke `s` in a `w × h` output.
pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDabs {
let to_out = frame.norm_to_out(w, h);
let ppl = frame.px_per_long(w);
let r = (s.size * ppl).max(0.5);
let hard = r * (1.0 - (s.feather / 100.0).clamp(0.0, 1.0));
// Densify the path so dabs overlap (spacing r/4).
// Densify the path so dabs overlap (spacing r/4). Coverage builds up by passes, not by how
// long the button is held: a sample where the pointer hasn't moved (a stationary hold records
// one per frame) lays no dab, so waiting in place adds nothing (#518).
let mut dabs: Vec<Point> = Vec::new();
for (i, p) in s.points.iter().enumerate() {
let mut prev: Option<Point> = None;
for p in &s.points {
let q = to_out.apply(*p);
if i > 0 {
let prev = to_out.apply(s.points[i - 1]);
if let Some(prev) = prev {
let dist = prev.dist(q);
if dist.is_nan() || dist <= STATIONARY_PX {
continue;
}
let n = (dist / (r / 4.0).max(0.5)).ceil() as usize;
for k in 1..n {
dabs.push(prev.lerp(q, k as f64 / n as f64));
}
}
dabs.push(q);
prev = Some(q);
}
BrushDabs {
dabs,
@@ -714,6 +724,58 @@ mod tests {
assert!(a.get(40, 5) < 0.01);
}
/// #518: holding the brush still adds no coverage. A press plus any number of stationary
/// pointer samples paints exactly the single dab of the press, whatever the frame rate.
#[test]
fn holding_the_brush_still_adds_no_coverage() {
let (w, h) = (200, 100);
let f = frame(w, h);
let img = Rgb32f::new(w, h);
let l = Plane::new(w, h);
let p = Point::new(0.5, 0.5);
let spot = |samples: usize, flow: f64| {
let s = BrushStroke { points: vec![p; samples], size: 0.1, feather: 80.0, flow, ..Default::default() };
shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None)
};
for flow in [100.0, 40.0] {
let press = spot(1, flow);
// the feathered edge is soft to begin with
let edge = press.get(100 + 14, 50);
assert!(edge > 0.05 && edge < 0.6, "flow {flow}: edge {edge}");
for n in [2, 5, 60, 600] {
let held = spot(n, flow);
assert!(held.data == press.data, "flow {flow}: {n} stationary samples hardened the edge {edge} → {}", held.get(100 + 14, 50));
}
}
let s = BrushStroke { points: vec![p; 60], size: 0.1, ..Default::default() };
assert_eq!(brush_dabs(&s, &f, w, h).dabs.len(), 1, "one dab for the press");
// a stroke that pauses partway lays the same dabs as one that doesn't
let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5));
let paused = BrushStroke { points: vec![a, p, p, p, p, b], flow: 30.0, ..Default::default() };
let smooth = BrushStroke { points: vec![a, p, b], flow: 30.0, ..Default::default() };
assert_eq!(brush_dabs(&paused, &f, w, h).dabs, brush_dabs(&smooth, &f, w, h).dabs);
}
/// Flow builds up by passes: going back over an area in the same stroke adds coverage.
#[test]
fn a_second_pass_builds_up_with_low_flow() {
let (w, h) = (200, 100);
let f = frame(w, h);
let img = Rgb32f::new(w, h);
let l = Plane::new(w, h);
let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5));
let paint = |points: Vec<Point>| {
let s = BrushStroke { points, size: 0.05, feather: 50.0, flow: 20.0, ..Default::default() };
shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None)
};
let once = paint(vec![a, b]);
let twice = paint(vec![a, b, a]);
let thrice = paint(vec![a, b, a, b]);
let (o, t, th) = (once.get(100, 50), twice.get(100, 50), thrice.get(100, 50));
assert!(o > 0.05 && o < 0.99, "one pass {o}");
assert!(t > o + 0.02 && th > t + 0.01, "passes build up: {o} → {t} → {th}");
}
#[test]
fn auto_mask_stops_at_edges() {
// dark left half, bright right half; a stroke along the boundary, centred on the dark side
+32
View File
@@ -61,6 +61,38 @@ fn hdr_midtones_match_the_sdr_render() {
assert!(n > 100, "the scene should have midtones and shadows ({n})");
}
#[test]
fn hdr_keeps_the_camera_tone_curve_in_the_midtones() {
// A raw with a fitted camera look (brighter midtones than the generic curve): its HDR render
// used to drop the look and come out more than a stop darker than the SDR render.
let knots = std::array::from_fn(|i| {
let x = 0.004 * 1.18f32.powi(i as i32);
[x, 1.0 - (-2.0 * x).exp()]
});
let info = SourceInfo { camera_tone: crate::tone::CameraTone::new(knots), ..raw() };
let src = sunset();
let render_at = |s: &DevelopSettings, depth| {
let req = RenderRequest { depth, ..RenderRequest::fit(240, 240) };
match render(&src, &info, s, &req).deep.map(|d| d.samples) {
Some(DeepSamples::F32(v)) => v,
other => panic!("expected float samples, got {other:?}"),
}
};
let hdr = render_at(&hdr_on(3.0), OutputDepth::F32Hdr);
let sdr = render_at(&DevelopSettings::default(), OutputDepth::F32Linear);
let mut n = 0;
for (h, s) in hdr.as_chunks::<3>().0.iter().zip(sdr.as_chunks::<3>().0) {
if s.iter().all(|v| *v < 0.4) {
for (a, b) in h.iter().zip(s) {
assert!((a - b).abs() < 2e-3, "{h:?} vs {s:?}");
}
n += 1;
}
}
assert!(n > 100, "the scene should have midtones and shadows ({n})");
assert!(hdr.iter().any(|v| *v > 1.0), "highlights go past SDR white");
}
#[test]
fn f32hdr_without_hdr_is_the_sdr_float_render() {
let src = sunset();
+39 -1
View File
@@ -103,13 +103,24 @@ pub struct ToneMap {
impl ToneMap {
pub fn camera(curve: &CameraTone, contrast: f64, whites: f64, blacks: f64) -> ToneMap {
ToneMap::camera_hdr(curve, contrast, whites, blacks, 1.0)
}
/// [`ToneMap::camera`] for an HDR render reaching `peak` (linear, SDR white = 1; 1 = SDR
/// exactly): the camera's look up to 1.5 EV over grey, so midtones match the SDR render (and a
/// gain map only lifts highlights), then the highlights gain up to `peak − 1` more as in
/// [`ToneMap::hdr`].
pub fn camera_hdr(curve: &CameraTone, contrast: f64, whites: f64, blacks: f64, peak: f32) -> ToneMap {
let peak = if peak.is_finite() { peak.max(1.0) } else { 1.0 };
let adjustment = Self::display(contrast, whites, blacks);
let neutral = contrast == 0.0 && whites == 0.0 && blacks == 0.0;
let top = (2.9 - 1.6 * (whites as f32 / 100.0) + peak.log2() + 1.0).max(HDR_FROM_EV + 0.5);
let lut = (0..LUT_N)
.map(|i| {
let ev = LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32;
let y = curve.apply(GREY * 2f32.powf(ev));
if neutral { y } else { adjustment.apply(y) }
let o = if neutral { y } else { adjustment.apply(y) };
if peak > 1.0 { (o + (peak - 1.0) * smooth(HDR_FROM_EV, top, ev)).clamp(0.0, peak) } else { o }
})
.collect();
ToneMap { lut, chroma: curve.chroma }
@@ -353,6 +364,33 @@ mod tests {
assert_eq!(ToneMap::hdr(0.0, 0.0, 0.0, f32::NAN).lut(), ToneMap::new(0.0, 0.0, 0.0).lut());
}
#[test]
fn camera_hdr_keeps_the_camera_look_and_lifts_highlights() {
let curve = CameraTone::new(knots()).unwrap().with_chroma([1.2, 1.1, 1.0, 1.0, 0.9, 0.8, 0.7, 0.6]).unwrap();
for (c, w, b) in [(0.0, 0.0, 0.0), (40.0, -30.0, -20.0)] {
let sdr = ToneMap::camera(&curve, c, w, b);
assert_eq!(ToneMap::camera_hdr(&curve, c, w, b, 1.0).lut(), sdr.lut());
assert_eq!(ToneMap::camera_hdr(&curve, c, w, b, f32::NAN).lut(), sdr.lut());
for peak in [2.0f32, 8.0] {
let t = ToneMap::camera_hdr(&curve, c, w, b, peak);
assert_eq!(t.chroma_lut(), sdr.chroma_lut());
let mut prev = -1.0;
for i in 0..2400 {
let y = 1e-5 * 1.01f32.powi(i);
let o = t.apply(y);
assert!((0.0..=peak).contains(&o), "{peak} at {y}: {o}");
assert!(o >= prev - 1e-6, "{peak} at {y}: {o} < {prev}");
prev = o;
// the camera's midtones, not the generic HDR curve's (issue: dark HDR exports)
if y <= GREY * 2f32.powf(HDR_FROM_EV - 0.01) {
assert!((o - sdr.apply(y)).abs() < 1e-6, "midtones differ at {y}: {o} vs {}", sdr.apply(y));
}
}
assert!(t.apply(4.0) > 1.0 && t.apply(4.0) > sdr.apply(4.0) + 0.5, "{peak}: highlights lifted");
}
}
}
#[test]
fn display_hdr_passes_hdr_sources_and_keeps_sdr() {
assert_eq!(ToneMap::display_hdr(20.0, 10.0, -5.0, 1.0).lut(), ToneMap::display(20.0, 10.0, -5.0).lut());
+38 -1
View File
@@ -167,13 +167,21 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
let pentax_raw = has_cfa || t.exif().is_some_and(|e| e.contains(vendor::EXIF_CFA_PATTERN));
// Samsung's raw IFD uses private compressions 32769..=32773 (`vendor::srw`), 32773 being PackBits elsewhere
let samsung_raw = has_cfa || t.all_ifds().iter().any(|i| i.u16(lightcraft_tiff::tags::COMPRESSION).is_some_and(|c| (32769..=32773).contains(&c)));
// Sony raws without a CFA IFD (#702): SRF (DSC-F828), whose IFD0 looks like RGB but holds 14-bit samples (12 or
// 14 count: raw sensor depths; the raw is in Sony's SRF blocks), and the DSLR-A100, whose IFD0 is an old-style
// JPEG thumbnail (compression 6, which today's TIFF writers don't produce) and whose `SubIFDs` tag addresses its
// raw data. A camera-authored or exported TIFF has 8- or 16-bit samples, and its SubIFDs hold ordinary images
let model = t.find(lightcraft_tiff::tags::MODEL).and_then(|e| e.value.as_str()).unwrap_or_default().trim().to_ascii_uppercase();
let sony_raw = has_cfa
|| ifd0.u64s(lightcraft_tiff::tags::BITS_PER_SAMPLE).is_some_and(|b| b.iter().any(|v| matches!(v, 12 | 14)))
|| (model == "DSLR-A100" && ifd0.u16(lightcraft_tiff::tags::COMPRESSION) == Some(6) && ifd0.contains(lightcraft_tiff::tags::SUB_IFDS));
if make.starts_with("CANON") && t.ifds.len() >= 4 && t.ifds[3].u16(lightcraft_tiff::tags::COMPRESSION) == Some(6) {
return Some(RawFormat::Cr2);
}
if make.starts_with("NIKON") && has_cfa {
return Some(if t.all_ifds().len() > 1 { RawFormat::Nef } else { RawFormat::Nrw });
}
if make.starts_with("SONY") && has_cfa {
if make.starts_with("SONY") && sony_raw {
return Some(RawFormat::Arw);
}
if (make.starts_with("PENTAX") || make.starts_with("RICOH")) && pentax_raw {
@@ -943,6 +951,35 @@ mod tests {
}
}
/// #702: two Sony layouts carry no CFA IFD, so the raw evidence #281 asks of Sony files has to
/// include them: SRF (an RGB-looking IFD0 of 14-bit samples) and the DSLR-A100 (raw data addressed
/// by `SubIFDs`). An 8-bit TIFF from the same bodies, or a SubIFDs TIFF from another Sony model,
/// stays an ordinary image.
#[test]
fn sony_raws_without_a_cfa_ifd_are_still_raws() {
let sony = |model: &str, bits: u16, compression: u16, sub_ifd: Option<IfdBuilder>| {
let mut ifd = rgb_ifd(400, 300);
ifd.set(t::MAKE, Value::Ascii("SONY".into()));
ifd.set(t::MODEL, Value::Ascii(model.into()));
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; 3]));
ifd.set(t::COMPRESSION, Value::Short(vec![compression]));
if let Some(child) = sub_ifd {
ifd.set_child(t::SUB_IFDS, child);
}
probe(&write(&[ifd]))
};
let raw_block = || Some(rgb_ifd(64, 48)); // stands in for the A100's raw-data pointer
assert_eq!(sony("DSC-F828", 14, 1, None), Some(RawFormat::Arw), "SRF");
assert_eq!(sony("DSC-F828", 12, 1, None), Some(RawFormat::Arw), "12-bit samples count too");
assert_eq!(sony("DSLR-A100", 8, 6, raw_block()), Some(RawFormat::Arw), "A100: old-JPEG IFD0 and SubIFDs");
assert_eq!(sony("DSC-F828", 8, 1, None), None, "an 8-bit TIFF");
assert_eq!(sony("DSC-F828", 16, 1, None), None, "a 16-bit TIFF");
// TIFFs exported from A100 photos, with a thumbnail in SubIFDs (Codex review of #702)
assert_eq!(sony("DSLR-A100", 8, 1, Some(rgb_ifd(64, 48))), None, "an 8-bit A100 TIFF with a SubIFDs thumbnail");
assert_eq!(sony("DSLR-A100", 16, 8, Some(rgb_ifd(64, 48))), None, "a 16-bit A100 TIFF with a SubIFDs thumbnail");
assert_eq!(sony("ILCE-7M4", 8, 6, raw_block()), None, "the A100's layout under another model");
}
// --- containers that are recognised but not decoded, with a preview ---
#[test]
+22 -8
View File
@@ -108,10 +108,20 @@ fn aspect_crop(maker: Option<&Ifd>, image: Rect) -> Option<Rect> {
fn geometry(area: Option<&Cr3ImageArea>, maker: Option<&Ifd>, width: usize, height: usize) -> (Rect, Rect) {
let area = area.filter(|a| usize::from(a.width) == width && usize::from(a.height) == height);
let sensor = sensor_crop(maker, width, height);
let active = area.and_then(|a| a.active).and_then(|b| inclusive_rect(b, width, height)).or(sensor).unwrap_or(Rect::new(0, 0, width, height));
// The recommended crop, when the file gives a consistent one. `AspectInfo` is measured from its origin, so it
// is only applied on top of that; without it (the crop doesn't fit the valid area) the whole active area stays.
let recommended = area.and_then(|a| inclusive_rect(a.crop, width, height)).or(sensor).and_then(|c| relative_crop(c, active));
let mut active = area.and_then(|a| a.active).and_then(|b| inclusive_rect(b, width, height)).or(sensor).unwrap_or(Rect::new(0, 0, width, height));
// The recommended crop. `AspectInfo` is measured from its origin, so it is applied on top of it.
let stated = area.and_then(|a| inclusive_rect(a.crop, width, height)).or(sensor);
let mut recommended = stated.and_then(|c| relative_crop(c, active));
// Some crop-mode files (EOS R5 Mark II APS-C, 5376 x 3574) state an active area that starts in the dark
// columns and stops short of the image, while their recommended crop lies on the image (measured: columns
// 132..~256 sit at the black level, the image runs to the right edge). The recommended crop is then the
// valid area.
if recommended.is_none()
&& let Some(c) = stated.filter(|c| c.width >= 2 && c.height >= 2)
{
active = c;
recommended = Some(Rect::new(0, 0, c.width, c.height));
}
let crop = recommended.map(|c| aspect_crop(maker, c).unwrap_or(c)).unwrap_or(Rect::new(0, 0, active.width, active.height));
(active, crop)
}
@@ -424,8 +434,9 @@ mod tests {
}
}
/// Some crop-mode files (EOS R5 Mark II 7883) record a recommended crop that reaches past their valid area. The
/// whole active area stays then, and `AspectInfo`, which is measured from the recommended crop, is not applied.
/// Some crop-mode files (EOS R5 Mark II 7883) record a recommended crop that reaches past their stated active
/// area, which starts in dark columns. The recommended crop is then the valid area (no black band on the left),
/// and `AspectInfo` applies from its origin.
#[test]
fn aspect_info_needs_a_consistent_recommended_crop() {
let aspect = lightcraft_tiff::IfdBuilder::new().with(ASPECT_INFO, Value::Long(vec![13, 5088, 3392, 0, 0]));
@@ -433,8 +444,11 @@ mod tests {
let maker = Tiff::parse(&bytes).unwrap().ifds.remove(0);
let area =
|crop| Cr3ImageArea { width: 5376, height: 3574, crop, active: Some([132, 160, 5243, 3567]), masked_left: [0; 4], masked_top: None };
// crop right edge 5359 > active right edge 5243: not a crop of the active area
assert_eq!(geometry(Some(&area([272, 172, 5359, 3563])), Some(&maker), 5376, 3574).1, Rect::new(0, 0, 5112, 3408));
// crop right edge 5359 > active right edge 5243: the recommended crop becomes the valid area
assert_eq!(
geometry(Some(&area([272, 172, 5359, 3563])), Some(&maker), 5376, 3574),
(Rect::new(272, 172, 5088, 3392), Rect::new(0, 0, 5088, 3392))
);
// consistent crop: AspectInfo applies from its origin
assert_eq!(geometry(Some(&area([140, 172, 5227, 3563])), Some(&maker), 5376, 3574).1, Rect::new(8, 12, 5088, 3392));
}
+13 -5
View File
@@ -330,8 +330,10 @@ fn tile_qp(sample: &[u8], tile: &Tile) -> Result<Option<QpMap>> {
fn quant_step(value: u8) -> Result<i32> {
// Measured independently with an external decoder after modifying only the
// quantValue field: all forty values 4..43 follow this integer sequence.
if !(4..=43).contains(&value) {
// quantValue field: all forty values 4..43 follow this integer sequence. Value 44 (QP 168)
// continues it (step 102); it was checked against a measured step of about 100 on an EOS R50
// C-RAW file. Nothing above 44 has been seen, so it stays rejected.
if !(4..=44).contains(&value) {
return Err(RawError::Unsupported(format!("Canon CRX quantization value {value}")));
}
let scale = [40i32, 45, 51, 57, 64, 72].get(usize::from(value % 6)).copied().ok_or_else(|| corrupt("quantization table index"))?;
@@ -754,7 +756,7 @@ fn qp_map(src: &[u8], width: usize, height: usize) -> Result<QpMap> {
let right = previous.get(x + 1).copied().unwrap_or(above);
let symbol = bits.qp_rice(k)?;
let value = median(left, above, corner).checked_add(signed(symbol)).ok_or_else(|| corrupt("QP prediction overflow"))?;
if !(131..=167).contains(&value) {
if !(131..=168).contains(&value) {
return Err(RawError::Unsupported(format!("Canon CRX adaptive QP value {value}")));
}
*current.get_mut(x).ok_or_else(|| corrupt("QP outside row"))? = value;
@@ -968,11 +970,11 @@ mod tests {
#[test]
fn measured_quantization_and_allocation_limits() {
for (quant, expected) in [(4, 1), (9, 1), (10, 2), (16, 4), (22, 8), (26, 12), (32, 25), (36, 40), (41, 72), (42, 80), (43, 90)] {
for (quant, expected) in [(4, 1), (9, 1), (10, 2), (16, 4), (22, 8), (26, 12), (32, 25), (36, 40), (41, 72), (42, 80), (43, 90), (44, 102)] {
assert_eq!(quant_step(quant).unwrap(), expected);
}
assert!(matches!(quant_step(3), Err(RawError::Unsupported(_))));
assert!(matches!(quant_step(44), Err(RawError::Unsupported(_))));
assert!(matches!(quant_step(45), Err(RawError::Unsupported(_))));
for (width, height) in [(20_000, 20_000), (65_538, 2), (2, 65_538)] {
let mut config = coding();
config.width = width;
@@ -1003,6 +1005,12 @@ mod tests {
assert!(matches!(qp_map(&[0, 0, 1, 4, 0x94], 1, 4), Err(RawError::Unsupported(_))));
}
#[test]
fn qp_168_is_accepted_and_169_is_not() {
assert_eq!(qp_map(&[0, 0, 1, 0x50, 0x94], 1, 4).unwrap().data, [168; 4]);
assert!(matches!(qp_map(&[0, 0, 1, 0x52, 0x94], 1, 4), Err(RawError::Unsupported(_))));
}
#[test]
fn odd_height_qp_map_uses_its_last_row_alone() {
// EOS R7 C-RAW: 4732 sensor rows give a 1183-row QP map, so the last
+37 -16
View File
@@ -58,28 +58,38 @@ pub(crate) fn black_from_columns(data: &[u16], width: usize, cols: Range<usize>,
}
/// White level estimate: the saturation plateau if a noticeable number of samples sit at the maximum value,
/// otherwise the full `bits` range.
/// otherwise the full `bits` range. A few stray samples above the plateau (hot pixels, e.g. on a Canon EOS 70D
/// CR2) don't hide it.
pub(crate) fn white_from_data(data: &[u16], bits: u32) -> f32 {
let full = ((1u32 << bits.clamp(1, 16)) - 1) as f32;
let full_level = (1u32 << bits.clamp(1, 16)) - 1;
let full = full_level as f32;
let step = (data.len() / 2_000_000).max(1);
// samples above the nominal range are padding / invalid (e.g. 0xFFFF fill), not saturation
let Some(&mx) = data.iter().filter(|&&v| v as f32 <= full).max() else { return full };
let mut hist = vec![0usize; full_level as usize + 1];
for &v in data.iter().step_by(step) {
if let Some(h) = hist.get_mut(v as usize) {
*h += 1;
}
}
let min_plateau = (data.len() / 20_000).max(16);
// the top of the data, skipping up to half a plateau's worth of strays so a real plateau can't be skipped
let strays = min_plateau / 2;
let mut above = 0usize;
let Some(mx) = hist.iter().enumerate().rev().find_map(|(v, &n)| {
above += n * step;
(above > strays).then_some(v as u32)
}) else {
return full;
};
let band = (mx / 256).max(1);
let near = mx.saturating_sub(band);
let below = near.saturating_sub(band);
let step = (data.len() / 2_000_000).max(1);
let (mut top, mut under) = (0usize, 0usize);
for &v in data.iter().step_by(step) {
if v as f32 > full {
continue;
}
if v >= near {
top += 1;
} else if v >= below {
under += 1;
}
}
let count = |from: u32, to: u32| hist.get(from as usize..to as usize).map_or(0, |h| h.iter().sum::<usize>());
// the strays count as saturated too
let top = count(near, full_level + 1);
let under = count(below, near);
// a saturation plateau is a spike: many more samples in the top band than in the band just below it
if mx as f32 > full * 0.5 && top * step >= (data.len() / 20_000).max(16) && top >= 4 * under + 4 {
if mx as f32 > full * 0.5 && top * step >= min_plateau && top >= 4 * under + 4 {
// plateau: use its lower edge so everything at saturation maps to ≥ 1.0
near as f32
} else {
@@ -116,4 +126,15 @@ mod tests {
assert!((14900.0..=15000.0).contains(&w), "{w}");
assert_eq!(white_from_data(&[], 12), 4095.0);
}
/// A Canon EOS 70D CR2 saturates at 13583 but has a handful of samples up to 14426 (out of ~20M): the plateau
/// is still found instead of falling back to 16383 (≈0.3 EV too dark, highlights never seen as clipped).
#[test]
fn white_plateau_with_strays_above() {
let mut d: Vec<u16> = (0..100_000).map(|i| (2048 + i % 10000) as u16).collect();
d.extend(std::iter::repeat_n(13583u16, 5000));
d.extend([13700, 13900, 14100, 14426]);
let w = white_from_data(&d, 14);
assert!((13500.0..=13583.0).contains(&w), "{w}");
}
}
+54 -2
View File
@@ -57,6 +57,30 @@ fn default_crop(mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect {
Rect::new(*x as usize, *y as usize, *width as usize, *height as usize)
}
/// A body that records no CropArea, measured on its own files: the image area inside the sensor data (where the
/// camera's full-size JPEG sits when the two are aligned) and the level where its channels clip.
struct MeasuredBody {
model: &'static str,
/// Sensor data size and sample depth of the measured files.
size: (usize, usize),
bits: u32,
/// Image area in sensor pixels: x, y, width, height.
crop: (usize, usize, usize, usize),
white: f32,
}
/// * **NIKON D4** (148 uncompressed 14-bit NEFs from one body, 2023–2026, ISO 100–10000): the data is 4992 × 3292,
/// with 2 columns of optical black left of the image and 42 right of it, then 8 empty ones; the camera's
/// 4928 × 3280 JPEG sits at (8, 6). Without this area the optical black shows as purple stripes on both sides.
/// Green saturates at 15546–15672 while red and blue run on to 15687–16383, so the data's own top (~16300) leaves
/// clipped highlights magenta; one level of 15520 for every channel turns them white and keeps the white balance.
const MEASURED_BODIES: &[MeasuredBody] =
&[MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: 15520.0 }];
fn measured_body(model: &str, w: usize, h: usize, bits: u32) -> Option<&'static MeasuredBody> {
MEASURED_BODIES.iter().find(|b| b.model == model.trim() && b.size == (w, h) && b.bits == bits)
}
fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
tiff.all_ifds()
.into_iter()
@@ -120,9 +144,13 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
(Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() },
_ => Cfa::bayer_static("RGGB"),
};
let white = white_from_data(samples, bits);
let body = measured_body(&ifd0.string(t::MODEL).unwrap_or_default(), w, h, bits);
let white = body.map_or_else(|| white_from_data(samples, bits), |b| b.white);
let active_w = trailing_masked_columns(samples, w, h, white);
let crop = default_crop(mn.as_ref(), active_w, h);
let crop = match body {
Some(&MeasuredBody { crop: (x, y, cw, ch), .. }) if x + cw <= active_w && y + ch <= h => Rect::new(x, y, cw, ch),
_ => default_crop(mn.as_ref(), active_w, h),
};
let mut metadata = lightcraft_meta::from_tiff(&tiff);
metadata.width = Some(crop.width as u32);
metadata.height = Some(crop.height as u32);
@@ -275,6 +303,30 @@ mod tests {
}
}
/// The D4 records no CropArea: its measured image area and saturation level apply to files of its data size and
/// depth only.
#[test]
fn measured_d4_image_area_and_white_level() {
let (w, h) = (4992usize, 3292usize);
let decode_as = |model: &str, bits: u16| {
let mut nef = nef(1, bits, vec![vec![0x10; w * h * 2]], w as u32, h as u32, h as u32);
let at = nef.windows(10).position(|b| b == b"NIKON TEST").unwrap();
let name = format!("{model:<10}");
nef[at..at + 10].copy_from_slice(name.as_bytes());
crate::decode(&nef).unwrap()
};
let d4 = decode_as("NIKON D4", 14);
assert_eq!(d4.crop, Rect::new(8, 6, 4928, 3280));
assert_eq!(d4.white, vec![15520.0]);
assert_eq!((d4.metadata.width, d4.metadata.height), (Some(4928), Some(3280)));
let developed = d4.develop(crate::Method::Bilinear).unwrap();
assert_eq!((developed.width, developed.height), (4928, 3280));
for other in [decode_as("NIKON D5", 14), decode_as("NIKON D4", 12)] {
assert_eq!(other.crop, Rect::new(0, 0, w, h));
assert_ne!(other.white, vec![15520.0]);
}
}
#[test]
fn black_level_tag_is_in_14_bit_units() {
let (w, h) = (16usize, 4usize);
+3
View File
@@ -2,6 +2,7 @@
name = "lightcraft-ui-egui"
version.workspace = true
edition.workspace = true
rust-version = "1.95"
license.workspace = true
description = "LightCraft egui frontend (Lightroom-style UI); swappable — nothing below depends on it (L5)"
@@ -9,6 +10,7 @@ description = "LightCraft egui frontend (Lightroom-style UI); swappable — noth
workspace = true
[dependencies]
craft-ui = { workspace = true }
lightcraft-geom = { workspace = true }
lightcraft-meta = { workspace = true }
lightcraft-color = { workspace = true }
@@ -25,6 +27,7 @@ serde_json = { workspace = true }
log = { workspace = true }
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
lightcraft-denoise = { workspace = true, features = ["runtime"] }
lightcraft-codecs = { workspace = true }
lightcraft-faces = { workspace = true }
+8
View File
@@ -94,6 +94,7 @@
"Aqua Hue": "Aquamarin – Farbton",
"Aqua Luminance": "Aquamarin – Luminanz",
"Aqua Saturation": "Aquamarin – Sättigung",
"Arrange Panel": "Bereich anordnen",
"ArtCraft Website": "ArtCraft-Website",
"ArtCraft — more creative apps": "ArtCraft — weitere Kreativ-Apps",
"As Shot": "Wie Aufnahme",
@@ -251,6 +252,7 @@
"Close Window": "Fenster schließen",
"Close all": "Alle schließen",
"Close file": "Datei schließen",
"Close panel": "Bereich schließen",
"Cloudy": "Bewölkt",
"Cmd+Shift+R, keeps the photo and clears its edits.)": "mit ⌘⇧R die Bearbeitungen zurück und behält das Foto.",
"Collapse": "Einklappen",
@@ -556,6 +558,7 @@
"Flip Overlay (⇧O)": "Überlagerung spiegeln (⇧O)",
"Flip V": "V spiegeln",
"Flip Vertical": "Vertikal spiegeln",
"Float panel": "Bereich frei platzieren",
"Flow": "Fluss",
"Fluorescent": "Leuchtstofflicht",
"Focal Length": "Brennweite",
@@ -885,6 +888,7 @@
"Move them": "Verschieben",
"Move to": "Verschieben nach",
"Move to Group": "In Gruppe verschieben",
"Move to group": "In Gruppe verschieben",
"Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Verschieben: Die Dateien werden in den folgenden Ordner verschoben; jedes Original (und seine XMP-Begleitdatei) wird erst nach Überprüfung der Kopie aus der Quelle entfernt. Duplikate und fehlgeschlagene Dateien bleiben an ihrem Speicherort.",
"Moves every photo imported from this disk to Recently Deleted; no file is touched": "Verschiebt alle von diesem Datenträger importierten Fotos nach „Zuletzt gelöscht“; keine Datei wird verändert",
"Moves the photos imported from this folder to Recently Deleted; no file is touched": "Verschiebt die aus diesem Ordner importierten Fotos nach „Zuletzt gelöscht“; keine Datei wird verändert",
@@ -1005,6 +1009,7 @@
"Paint over a distraction on the photo to remove it.": "Übermale ein störendes Element im Foto, um es zu entfernen.",
"Paint overlay": "Überlagerung auftragen",
"Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "RAW-Dateien von Panasonic und Leica, RW2, RWL und ältere RAW-Dateien von DMC-LX1 bis DC-S1R II, werden in",
"Panels": "Bereiche",
"Panorama": "Panorama",
"Panorama Merge": "Zu Panorama zusammenfügen",
"Panorama Merge Preview": "Panoramavorschau",
@@ -1225,6 +1230,7 @@
"Reset Control": "Regler zurücksetzen",
"Reset Crop": "Zuschnitt zurücksetzen",
"Reset Edits": "Bearbeitungen zurücksetzen",
"Reset Panel Layout": "Bereichsanordnung zurücksetzen",
"Reset Parametric Curve": "Parametrische Kurve zurücksetzen",
"Reset Section": "Bereich zurücksetzen",
"Reset Tone Curve": "Gradationskurve zurücksetzen",
@@ -1232,6 +1238,8 @@
"Reset all edits (Cmd+Shift+R)": "Alle Bearbeitungen zurücksetzen (⌘⇧R)",
"Reshuffle": "Neu mischen",
"Resize Face Box": "Gesichtsrahmengröße ändern",
"Resize panel window": "Größe des Bereichsfensters ändern",
"Resize panels": "Bereichsgrößen ändern",
"Resize to": "Größe ändern auf",
"Resolution (ppi)": "Auflösung (ppi)",
"Restore": "Wiederherstellen",
+8
View File
@@ -79,6 +79,7 @@
"Aqua Hue": "Tono aguamarina",
"Aqua Luminance": "Luminancia aguamarina",
"Aqua Saturation": "Saturación aguamarina",
"Arrange Panel": "Organizar panel",
"ArtCraft Website": "Sitio web de ArtCraft",
"ArtCraft — more creative apps": "ArtCraft: más aplicaciones creativas",
"As Shot": "Como se tomó",
@@ -231,6 +232,7 @@
"Close Window": "Cerrar ventana",
"Close all": "Cerrar todo",
"Close file": "Cerrar archivo",
"Close panel": "Cerrar panel",
"Cloudy": "Nublado",
"Collapse": "Contraer",
"Collapse All Stacks": "Contraer todas las pilas",
@@ -523,6 +525,7 @@
"Flip Overlay (⇧O)": "Voltear superposición (⇧O)",
"Flip V": "Voltear V",
"Flip Vertical": "Voltear verticalmente",
"Float panel": "Hacer flotante el panel",
"Flow": "Flujo",
"Fluorescent": "Fluorescente",
"Focal Length": "Distancia focal",
@@ -844,6 +847,7 @@
"Move them": "Moverlas",
"Move to": "Mover a",
"Move to Group": "Mover a un grupo",
"Move to group": "Mover al grupo",
"Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Mover: los archivos se mueven a la carpeta de abajo; cada original (y su archivo XMP complementario) se quita del origen solo después de verificar su copia. Los duplicados y los archivos que fallan quedan donde están.",
"Moves every photo imported from this disk to Recently Deleted; no file is touched": "Mueve todas las fotos importadas desde este disco a Eliminadas recientemente; no se toca ningún archivo",
"Moves the photos imported from this folder to Recently Deleted; no file is touched": "Mueve las fotos importadas desde esta carpeta a Eliminadas recientemente; no se toca ningún archivo",
@@ -961,6 +965,7 @@
"Paint only areas like the one under the brush": "Pintar solo zonas similares a la que está bajo el pincel",
"Paint over a distraction on the photo to remove it.": "Pintar sobre un elemento que distrae en la foto para eliminarlo.",
"Paint overlay": "Superposición de pintura",
"Panels": "Paneles",
"Panorama": "Panorámica",
"Panorama Merge": "Combinación de panorámica",
"Panorama Merge Preview": "Vista previa de combinación de panorámica",
@@ -1174,6 +1179,7 @@
"Reset Control": "Restablecer control",
"Reset Crop": "Restablecer recorte",
"Reset Edits": "Restablecer ediciones",
"Reset Panel Layout": "Restablecer disposición de paneles",
"Reset Parametric Curve": "Restablecer curva paramétrica",
"Reset Section": "Restablecer sección",
"Reset Tone Curve": "Restablecer curva de tonos",
@@ -1181,6 +1187,8 @@
"Reset all edits (Cmd+Shift+R)": "Restablecer todas las ediciones (Cmd+Mayús+R)",
"Reshuffle": "Volver a mezclar",
"Resize Face Box": "Cambiar tamaño del recuadro de cara",
"Resize panel window": "Cambiar tamaño de la ventana del panel",
"Resize panels": "Cambiar tamaño de paneles",
"Resize to": "Cambiar tamaño a",
"Resolution (ppi)": "Resolución (ppp)",
"Restore": "Restaurar",
+9 -1
View File
@@ -1876,5 +1876,13 @@
"Downloading model": "Téléchargement du modèle",
"Finding faces": "Recherche des visages",
"Synchronizing folder": "Synchronisation du dossier",
"Looking for changes": "Recherche des modifications"
"Looking for changes": "Recherche des modifications",
"Arrange Panel": "Arrange Panel",
"Close panel": "Close panel",
"Float panel": "Float panel",
"Move to group": "Move to group",
"Panels": "Panels",
"Reset Panel Layout": "Reset Panel Layout",
"Resize panel window": "Resize panel window",
"Resize panels": "Resize panels"
}
+8
View File
@@ -81,6 +81,7 @@
"Aqua Hue": "アクアの色相",
"Aqua Luminance": "アクアの輝度",
"Aqua Saturation": "アクアの彩度",
"Arrange Panel": "パネルを配置",
"ArtCraft Website": "ArtCraftのウェブサイト",
"ArtCraft — more creative apps": "ArtCraft — その他のクリエイティブアプリ",
"As Shot": "撮影時の設定",
@@ -228,6 +229,7 @@
"Close": "閉じる",
"Close Panel": "パネルを閉じる",
"Close Window": "ウィンドウを閉じる",
"Close panel": "パネルを閉じる",
"Cloudy": "曇り",
"Collapse All Stacks": "すべてのスタックを閉じる",
"Collapse Stack": "スタックを折りたたむ",
@@ -476,6 +478,7 @@
"Fixed keys": "固定キー",
"Flagged as Pick": "採用フラグ付き",
"Flagged as Reject": "不採用フラグ付き",
"Float panel": "パネルをフロート化",
"Focal Length": "焦点距離",
"Focus (assisted culling)": "ピント(選別補助)",
"Food": "料理",
@@ -593,6 +596,7 @@
"Move Preset to New Group": "プリセットを新しいグループに移動",
"Move Up": "上へ移動",
"Move in Stack": "スタック内で移動",
"Move to group": "グループに移動",
"Muted": "落ち着いた色",
"Muted Film": "ミューテッドフィルム",
"My Photos Panel": "「マイフォト」パネル",
@@ -621,6 +625,7 @@
"Original + Settings": "元画像+設定",
"Original extension without the dot": "元の拡張子(ドットなし)",
"Original file name without its extension": "拡張子を除いた元のファイル名",
"Panels": "パネル",
"Panorama": "パノラマ",
"Panorama Merge Preview": "パノラマ合成のプレビュー",
"Panorama with Last Settings": "前回の設定でパノラマ合成",
@@ -694,6 +699,7 @@
"Reorder Masks": "マスクを並べ替え",
"Reset All": "すべてリセット",
"Reset All Keyboard Shortcuts": "キーボードショートカットをすべてリセット",
"Reset Panel Layout": "パネル配置をリセット",
"Reshuffle": "シャッフルし直す",
"File name": "ファイル名",
"Fill Edges": "境界を補完",
@@ -1123,6 +1129,8 @@
"Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "すべてのポイントカーブとパラメトリックカーブをリセット(チャンネルのダブルクリックで個別にリセット)",
"Reset all edits (Cmd+Shift+R)": "すべての編集をリセット(⌘⇧R)",
"Resize Face Box": "顔の枠のサイズを変更",
"Resize panel window": "パネルウィンドウのサイズを変更",
"Resize panels": "パネルのサイズを変更",
"Resize to": "サイズ変更",
"Resolution (ppi)": "解像度(ppi)",
"Restore": "復元",
+8
View File
@@ -84,6 +84,7 @@
"Aqua Hue": "Matiz do ciano",
"Aqua Luminance": "Luminância do ciano",
"Aqua Saturation": "Saturação do ciano",
"Arrange Panel": "Organizar painel",
"ArtCraft Website": "Site do ArtCraft",
"ArtCraft — more creative apps": "ArtCraft — mais aplicativos criativos",
"As Shot": "Como capturado",
@@ -237,6 +238,7 @@
"Close Window": "Fechar janela",
"Close all": "Fechar tudo",
"Close file": "Fechar arquivo",
"Close panel": "Fechar painel",
"Cloudy": "Nublado",
"Collapse": "Recolher",
"Collapse All Stacks": "Recolher todas as pilhas",
@@ -529,6 +531,7 @@
"Flip Overlay (⇧O)": "Inverter sobreposição (⇧O)",
"Flip V": "Espelhar V",
"Flip Vertical": "Espelhar verticalmente",
"Float panel": "Tornar painel flutuante",
"Flow": "Fluxo",
"Fluorescent": "Fluorescente",
"Focal Length": "Distância focal",
@@ -852,6 +855,7 @@
"Move them": "Movê-los",
"Move to": "Mover para",
"Move to Group": "Mover para grupo",
"Move to group": "Mover para grupo",
"Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Mover: os arquivos são movidos para a pasta abaixo; cada original (e seu sidecar XMP) é removido da origem somente após a cópia ser verificada. Duplicatas e arquivos que falham permanecem onde estão.",
"Moves every photo imported from this disk to Recently Deleted; no file is touched": "Move todas as fotos importadas deste disco para Excluídos recentemente; nenhum arquivo é alterado",
"Moves the photos imported from this folder to Recently Deleted; no file is touched": "Move as fotos importadas desta pasta para Excluídos recentemente; nenhum arquivo é alterado",
@@ -971,6 +975,7 @@
"Paint only areas like the one under the brush": "Pinta apenas áreas como a que está sob o pincel",
"Paint over a distraction on the photo to remove it.": "Pinte sobre uma distração na foto para removê-la.",
"Paint overlay": "Sobreposição de pintura",
"Panels": "Painéis",
"Panorama": "Panorama",
"Panorama Merge": "Mesclagem de panorama",
"Panorama Merge Preview": "Prévia da mesclagem de panorama",
@@ -1185,6 +1190,7 @@
"Reset Control": "Redefinir controle",
"Reset Crop": "Redefinir recorte",
"Reset Edits": "Redefinir edições",
"Reset Panel Layout": "Redefinir disposição dos painéis",
"Reset Parametric Curve": "Redefinir curva paramétrica",
"Reset Section": "Redefinir seção",
"Reset Tone Curve": "Redefinir curva de tonalidade",
@@ -1192,6 +1198,8 @@
"Reset all edits (Cmd+Shift+R)": "Redefinir todas as edições (Cmd+Shift+R)",
"Reshuffle": "Embaralhar de novo",
"Resize Face Box": "Redimensionar caixa de rosto",
"Resize panel window": "Redimensionar janela do painel",
"Resize panels": "Redimensionar painéis",
"Resize to": "Redimensionar para",
"Resolution (ppi)": "Resolução (ppi)",
"Restore": "Restaurar",
+8
View File
@@ -60,6 +60,7 @@
"Aqua Hue": "Тон голубого",
"Aqua Luminance": "Яркость голубого",
"Aqua Saturation": "Насыщенность голубого",
"Arrange Panel": "Расположить панель",
"ArtCraft Website": "Сайт ArtCraft",
"ArtCraft — more creative apps": "ArtCraft — другие творческие приложения",
"As Shot": "Как снято",
@@ -194,6 +195,7 @@
"Close Window": "Закрыть окно",
"Close all": "Закрыть все",
"Close file": "Закрыть файл",
"Close panel": "Закрыть панель",
"Cloudy": "Облачно",
"Collapse All Stacks": "Свернуть все стопки",
"Color": "Цвет",
@@ -434,6 +436,7 @@
"Flip Overlay (⇧O)": "Отразить наложение (⇧O)",
"Flip V": "Отразить по верт.",
"Flip Vertical": "Отразить по вертикали",
"Float panel": "Сделать панель плавающей",
"Flow": "Поток",
"Fluorescent": "Флуоресцентная лампа",
"Focal length": "Фокусное расстояние",
@@ -703,6 +706,7 @@
"Move them": "Переместить их",
"Move to": "Переместить в",
"Move to Group": "Переместить в группу",
"Move to group": "Переместить в группу",
"Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Перемещение: файлы перемещаются в указанную ниже папку; каждый оригинал (и его XMP-файл) удаляется из источника только после проверки копии. Дубликаты и файлы с ошибками остаются на месте.",
"Muted": "Приглушённый",
"Muted Film": "Приглушённая плёнка",
@@ -804,6 +808,7 @@
"Paint only areas like the one under the brush": "Закрашивать только области, похожие на ту, что под кистью",
"Paint over a distraction on the photo to remove it.": "Закрасьте лишнее на фото, чтобы убрать это.",
"Paint overlay": "Наложение закраски",
"Panels": "Панели",
"Panorama Merge": "Слияние панорамы",
"Panorama with Last Settings": "Панорама с последними настройками",
"Panorama…": "Панорама…",
@@ -977,12 +982,15 @@
"Reset Control": "Сбросить элемент управления",
"Reset Crop": "Сбросить кадрирование",
"Reset Edits": "Сбросить правки",
"Reset Panel Layout": "Сбросить расположение панелей",
"Reset Parametric Curve": "Сбросить параметрическую кривую",
"Reset Section": "Сбросить раздел",
"Reset Tone Curve": "Сбросить тоновую кривую",
"Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Сбросить все кривые: точечные (все каналы) и параметрическую (двойной щелчок по каналу сбрасывает только его)",
"Reset all edits (Cmd+Shift+R)": "Сбросить все правки (Cmd+Shift+R)",
"Reshuffle": "Перемешать",
"Resize panel window": "Изменить размер окна панели",
"Resize panels": "Изменить размеры панелей",
"Resize to": "Изменить размер до",
"Resolution (ppi)": "Разрешение (ppi)",
"Restore": "Восстановить",
+9 -1
View File
@@ -2070,5 +2070,13 @@
"Downloading model": "Завантаження моделі",
"Finding faces": "Пошук облич",
"Synchronizing folder": "Синхронізація папки",
"Looking for changes": "Пошук змін"
"Looking for changes": "Пошук змін",
"Arrange Panel": "Arrange Panel",
"Close panel": "Close panel",
"Float panel": "Float panel",
"Move to group": "Move to group",
"Panels": "Panels",
"Reset Panel Layout": "Reset Panel Layout",
"Resize panel window": "Resize panel window",
"Resize panels": "Resize panels"
}
+8
View File
@@ -83,6 +83,7 @@
"Aqua Hue": "青色色相",
"Aqua Luminance": "青色明度",
"Aqua Saturation": "青色饱和度",
"Arrange Panel": "排列面板",
"ArtCraft Website": "ArtCraft 网站",
"ArtCraft — more creative apps": "ArtCraft — 更多创意应用",
"As Shot": "拍摄时设置",
@@ -236,6 +237,7 @@
"Close Window": "关闭窗口",
"Close all": "全部关闭",
"Close file": "关闭文件",
"Close panel": "关闭面板",
"Cloudy": "阴天",
"Collapse All Stacks": "折叠所有堆叠",
"Collapse Stack": "折叠堆叠",
@@ -496,6 +498,7 @@
"Fixed keys": "固定按键",
"Flagged as Pick": "已标记为留用",
"Flagged as Reject": "已标记为排除",
"Float panel": "浮动面板",
"Focal Length": "焦距",
"Focus (assisted culling)": "对焦(辅助筛选)",
"Frame time": "帧耗时",
@@ -563,6 +566,7 @@
"Move Preset to New Group": "将预设移动到新分组",
"Move Up": "上移",
"Move in Stack": "在堆叠中移动",
"Move to group": "移到组中",
"Name (A–Z)": "名称(A–Z)",
"Name of the folder the original is in": "原始文件所在文件夹的名称",
"Named People": "已命名的人物",
@@ -580,6 +584,7 @@
"Only": "仅限",
"Original extension without the dot": "不含点的原始扩展名",
"Original file name without its extension": "不含扩展名的原始文件名",
"Panels": "面板",
"Panorama": "全景",
"Panorama Merge Preview": "全景合并预览",
"Panoramas": "全景",
@@ -635,6 +640,7 @@
"Reorder Masks": "重新排序蒙版",
"Reset All": "全部重置",
"Reset All Keyboard Shortcuts": "重置所有键盘快捷键",
"Reset Panel Layout": "重置面板布局",
"Reshuffle": "重新随机排序",
"File name": "文件名",
"Fill Edges": "填充边缘",
@@ -1179,6 +1185,8 @@
"Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "重置所有曲线:点曲线(所有通道)和参数曲线(双击某个通道可单独重置该通道)",
"Reset all edits (Cmd+Shift+R)": "重置所有编辑 (Cmd+Shift+R)",
"Resize Face Box": "调整人脸框大小",
"Resize panel window": "调整面板窗口大小",
"Resize panels": "调整面板大小",
"Resize to": "调整大小为",
"Resolution (ppi)": "分辨率 (ppi)",
"Restore": "恢复",
+8
View File
@@ -83,6 +83,7 @@
"Aqua Hue": "青色色相",
"Aqua Luminance": "青色明度",
"Aqua Saturation": "青色飽和度",
"Arrange Panel": "排列面板",
"ArtCraft Website": "ArtCraft 網站",
"ArtCraft — more creative apps": "ArtCraft — 更多創作應用程式",
"As Shot": "拍攝時設定",
@@ -236,6 +237,7 @@
"Close Window": "關閉視窗",
"Close all": "全部關閉",
"Close file": "關閉檔案",
"Close panel": "關閉面板",
"Cloudy": "陰天",
"Collapse All Stacks": "收合所有堆疊",
"Collapse Stack": "收合堆疊",
@@ -496,6 +498,7 @@
"Fixed keys": "固定按鍵",
"Flagged as Pick": "已標記為精選",
"Flagged as Reject": "已標記為排除",
"Float panel": "浮動面板",
"Focal Length": "焦距",
"Focus (assisted culling)": "焦點(輔助挑選)",
"Frame time": "影格時間",
@@ -563,6 +566,7 @@
"Move Preset to New Group": "將預設集移至新群組",
"Move Up": "上移",
"Move in Stack": "在堆疊中移動",
"Move to group": "移至群組",
"Name (A–Z)": "名稱(A–Z)",
"Name of the folder the original is in": "原始檔所在資料夾的名稱",
"Named People": "已命名的人物",
@@ -580,6 +584,7 @@
"Only": "僅限",
"Original extension without the dot": "不含點的原始副檔名",
"Original file name without its extension": "不含副檔名的原始檔名",
"Panels": "面板",
"Panorama": "全景",
"Panorama Merge Preview": "全景合併預覽",
"Panoramas": "全景",
@@ -635,6 +640,7 @@
"Reorder Masks": "重新排序遮色片",
"Reset All": "全部重設",
"Reset All Keyboard Shortcuts": "重設所有鍵盤快速鍵",
"Reset Panel Layout": "重設面板配置",
"Reshuffle": "重新隨機排序",
"File name": "檔案名稱",
"Fill Edges": "填補邊緣",
@@ -1179,6 +1185,8 @@
"Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "重設所有點曲線通道與參數曲線(按兩下通道可只重設該通道)",
"Reset all edits (Cmd+Shift+R)": "重設所有編輯(Cmd+Shift+R)",
"Resize Face Box": "調整臉部框大小",
"Resize panel window": "調整面板視窗大小",
"Resize panels": "調整面板大小",
"Resize to": "調整尺寸為",
"Resolution (ppi)": "解析度(ppi)",
"Restore": "還原",
+4
View File
@@ -320,9 +320,13 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
lightcraft_develop::presets::deep_merge(&mut v, p);
match serde_json::from_value::<crate::UiState>(v) {
Ok(mut u) => {
if let Err(error) = crate::docking::set_widths(&mut u, p) {
return err(error);
}
u.toast = app.ui.toast.clone();
u.dialog = app.ui.dialog.clone();
u.status = app.ui.status.clone();
crate::docking::cancel_replaced_gesture(app, u.docking.as_ref());
app.ui = u;
crate::i18n::set_language(app.ui.language);
ctx.request_repaint();
+653
View File
@@ -0,0 +1,653 @@
//! Application panel identities and persistence around the shared docking renderer.
//! Document contents remain owned by their existing view implementations.
use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone};
use craft_ui::layout::{SplitAxis, SplitSize};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Panel {
Canvas,
Library,
Tools,
Presets,
}
impl Panel {
pub const ALL: &[Self] = &[Self::Canvas, Self::Library, Self::Tools, Self::Presets];
pub fn id(self) -> &'static str {
match self {
Self::Canvas => "canvas",
Self::Library => "library",
Self::Tools => "tools",
Self::Presets => "presets",
}
}
fn label(self) -> &'static str {
match self {
Self::Canvas => "Photos",
Self::Library => "Library",
Self::Tools => "Tools",
Self::Presets => "Presets",
}
}
fn default_side(self) -> Zone {
match self {
Self::Canvas => Zone::Right,
Self::Library => Zone::Left,
Self::Tools => Zone::Right,
Self::Presets => Zone::Right,
}
}
fn parse(id: &str) -> Result<Self, String> {
Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
}
}
fn permissions(panel: &Panel) -> Permissions {
if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
}
fn tabs(panel: Panel) -> Node<Panel> {
Node::Tabs { panels: vec![panel], active: 0 }
}
fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Panel>) -> Node<Panel> {
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Workspace {
pub layout: Layout<Panel>,
/// Closed panels retain their most recent group, side, size or floating rectangle.
pub hidden: Vec<(Panel, Location<Panel>)>,
}
impl Default for Workspace {
fn default() -> Self {
Self {
layout: Layout {
root: Some(split(
SplitAxis::Horizontal,
SplitSize::FixedFirst(crate::state::LEFT_WIDTH.default),
tabs(Panel::Library),
split(
SplitAxis::Horizontal,
SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.default),
split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)),
tabs(Panel::Tools),
),
)),
floating: Vec::new(),
},
hidden: Vec::new(),
}
}
}
impl Workspace {
pub fn validate(&self) -> Result<(), String> {
self.layout.validate().map_err(|error| error.to_string())?;
if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() {
return Err("The panel layout must contain its document view and bounded hidden panels".into());
}
// Saved settings cannot turn the protected document view into a floating or closable tab.
if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) {
return Err("The document view cannot float".into());
}
let mut nodes: Vec<_> = self.layout.root.iter().collect();
while let Some(node) = nodes.pop() {
match node {
Node::Split { first, second, .. } => {
nodes.push(first);
nodes.push(second);
}
Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => {
return Err("The document view cannot share a tab group".into());
}
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => {
return Err("The document view cannot be collapsed".into());
}
_ => {}
}
}
let mut seen = std::collections::HashSet::new();
for (panel, location) in &self.hidden {
if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) {
return Err("A hidden panel has an invalid or duplicate identity".into());
}
if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) {
return Err("A hidden panel cannot restore over the document view".into());
}
let mut probe = self.layout.clone();
if let Err(error) = probe.restore(*panel, location)
&& error != craft_ui::docking::DockError::MissingTarget
{
return Err(error.to_string());
}
}
Ok(())
}
pub fn apply(&mut self, action: Action<Panel>) -> Result<(), String> {
self.validate()?;
let mut next = self.clone();
if let Action::Close { panel } = &action {
let location = next.layout.location(panel).map_err(|error| error.to_string())?;
next.hidden.retain(|(id, _)| id != panel);
next.hidden.push((*panel, location));
} else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
next.hidden.retain(|(id, _)| id != panel);
}
next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
next.validate()?;
*self = next;
Ok(())
}
pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
if self.layout.contains(&panel) == visible {
return Ok(());
}
if !visible {
return self.apply(Action::Close { panel });
}
self.validate()?;
let mut next = self.clone();
let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
next.hidden.retain(|(id, _)| *id != panel);
if let Some(saved) = &mut location
&& saved.floating.is_some()
&& saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor)))
{
// The other members of a floating group may have been closed too.
saved.anchor = None;
saved.placement = Placement::Tab { before: None };
}
let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok());
if !restored {
next.layout
.apply_with_permissions(
Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) },
permissions,
)
.map_err(|error| error.to_string())?;
}
next.validate()?;
*self = next;
Ok(())
}
/// Membership is passive state; an explicit command also exposes the panel's body.
pub fn reveal(&mut self, panel: Panel) -> Result<(), String> {
self.set_visible(panel, true)?;
self.apply(Action::Activate { panel })
}
pub fn exposed(&self, panel: Panel) -> bool {
if self.layout.floating.iter().any(|group| group.panels.get(group.active) == Some(&panel)) {
return true;
}
let mut nodes = self.layout.root.as_ref().map(|node| vec![node]).unwrap_or_default();
while let Some(node) = nodes.pop() {
match node {
Node::Tabs { panels, active } if panels.get(*active) == Some(&panel) => return true,
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == panel && entry.open) => return true,
Node::Split { first, second, .. } => {
nodes.push(first);
nodes.push(second);
}
_ => {}
}
}
false
}
fn command(&mut self, params: &Value) -> Result<(), String> {
self.validate()?;
if let Some(action) = params.get("action") {
let action: Action<Panel> = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
return self.apply(action);
}
let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
if operation == "resizeSplit" {
let path: Vec<bool> =
serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
let size: SplitSize =
serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
return self.apply(Action::ResizeSplit { path, size });
}
let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
let action = match operation {
"open" => return self.reveal(panel),
"close" => Action::Close { panel },
"activate" => Action::Activate { panel },
"setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
"resizeStack" => {
let value = params.get("height").ok_or("height is required")?;
let height = if value.is_null() {
None
} else {
Some(
value
.as_f64()
.filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
.ok_or("height must be finite and bounded")? as f32,
)
};
Action::ResizeStack { panel, height }
}
"float" | "moveFloating" => {
let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
let mut values = [0.0f32; 4];
for (output, value) in values.iter_mut().zip(rect) {
*output = value
.as_f64()
.filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
.ok_or("rect values must be finite and bounded")? as f32;
}
if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
}
"move" => {
let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
"center" => Placement::Split(Zone::Center),
"left" => Placement::Split(Zone::Left),
"right" => Placement::Split(Zone::Right),
"top" => Placement::Split(Zone::Top),
"bottom" => Placement::Split(Zone::Bottom),
_ => return Err("zone must be center, left, right, top or bottom".into()),
};
let placement = if let Some(before) = params.get("before") {
if !matches!(placement, Placement::Split(Zone::Center)) {
return Err("before only applies to a center move".into());
}
Placement::Tab {
before: if before.is_null() {
None
} else {
Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
},
}
} else {
placement
};
Action::Move { panel, anchor, placement }
}
_ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
};
self.apply(action)
}
}
fn from_ui(app: &crate::LightcraftApp) -> Workspace {
let mut workspace = Workspace::default();
if let Some(Node::Split { size, second, .. }) = workspace.layout.root.as_mut() {
*size = SplitSize::FixedFirst(crate::state::LEFT_WIDTH.clamp(app.ui.left_width));
if let Node::Split { size, .. } = second.as_mut() {
*size = SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.clamp(app.ui.right_width));
}
}
workspace
}
fn sync(workspace: &mut Workspace, app: &crate::LightcraftApp) -> Result<(), String> {
workspace.set_visible(Panel::Library, app.ui.left_panel)?;
workspace.set_visible(Panel::Tools, app.ui.right != crate::state::RightPanel::None)?;
workspace.set_visible(Panel::Presets, app.ui.presets)
}
fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) {
app.ui.left_panel = workspace.layout.contains(&Panel::Library);
app.ui.presets = workspace.layout.contains(&Panel::Presets);
if !workspace.layout.contains(&Panel::Tools) {
app.ui.right = crate::state::RightPanel::None;
} else if app.ui.right == crate::state::RightPanel::None {
app.ui.right = crate::state::RightPanel::Edit;
}
for (panel, width, limits) in
[(Panel::Library, &mut app.ui.left_width, crate::state::LEFT_WIDTH), (Panel::Tools, &mut app.ui.right_width, crate::state::RIGHT_WIDTH)]
{
let Ok(location) = workspace.layout.location(&panel) else {
continue;
};
let value = match (location.floating, location.placement, location.size) {
(Some([_, _, width, _]), _, _)
| (None, Placement::Split(Zone::Left), Some(SplitSize::FixedFirst(width)))
| (None, Placement::Split(Zone::Right), Some(SplitSize::FixedSecond(width))) => Some(width),
// A vertical split stores height, and the opposite fixed side sizes a sibling.
_ => None,
};
if let Some(value) = value {
*width = limits.clamp(value);
}
}
}
pub(crate) fn exposed(app: &crate::LightcraftApp, panel: Panel) -> bool {
app.ui.docking.as_ref().is_none_or(|workspace| workspace.exposed(panel))
}
pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result<(), String> {
let panel = if id == "panel.presets" && app.ui.presets {
Some(Panel::Presets)
} else if (id.starts_with("panel.") && id != "panel.close" && id != "panel.presets"
|| id.starts_with("section.")
|| id.starts_with("tool.") && id != "tool.none")
&& app.ui.right != crate::state::RightPanel::None
{
Some(Panel::Tools)
} else {
None
};
if let Some(panel) = panel {
let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app));
sync(&mut workspace, app)?;
workspace.reveal(panel)?;
cancel_replaced_gesture(app, Some(&workspace));
app.ui.docking = Some(workspace);
}
Ok(())
}
/// Check the target's actual egui layer even while a different widget owns the drag.
pub(crate) fn drop_target(ui: &egui::Ui, rect: egui::Rect, pointer: Option<egui::Pos2>) -> bool {
pointer.is_some_and(|pos| rect.intersect(ui.clip_rect()).contains(pos) && ui.ctx().layer_id_at(pos) == Some(ui.layer_id()))
}
/// Explicit host commands replace the snapshot before rendering. Cancel only their old
/// gestures; actions returned by the renderer must keep continuous resizing alive.
pub(crate) fn cancel_replaced_gesture(app: &crate::LightcraftApp, workspace: Option<&Workspace>) {
let replaced = match (app.ui.docking.as_ref(), workspace) {
(Some(old), Some(next)) => old.layout != next.layout || old.hidden != next.hidden,
(None, None) => false,
_ => true,
};
if replaced && let Some(ctx) = &app.tasks.repaint {
craft_ui::docking::cancel_drag::<Panel>(ctx, egui::Id::new("lightcraft-docking"));
}
}
pub fn command(app: &mut crate::LightcraftApp, reset: bool, params: &Value) -> Result<Value, String> {
command_impl(app, reset, params, false)
}
fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, renderer_action: bool) -> Result<Value, String> {
let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app));
if reset {
workspace = Workspace::default();
// The usual LightCraft starting workspace shows its current tool beside the photo.
workspace.set_visible(Panel::Library, false)?;
workspace.set_visible(Panel::Presets, false)?;
} else {
sync(&mut workspace, app)?;
workspace.command(params)?;
}
if !renderer_action {
cancel_replaced_gesture(app, Some(&workspace));
}
publish_visibility(app, &workspace);
app.ui.docking = Some(workspace);
Ok(json!({"docking": app.ui.docking}))
}
pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get(ui.ctx());
let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app));
if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) {
app.ui.toast = Some((format!("Panel layout could not be restored: {error}"), ui.input(|input| input.time) + 8.0, None));
workspace = from_ui(app);
let _ = sync(&mut workspace, app);
}
// Legacy visibility commands may replace the layout before this render starts.
cancel_replaced_gesture(app, Some(&workspace));
// Tool/Canvas callbacks dispatch app commands and must see the real workspace.
app.ui.docking = Some(workspace.clone());
let mut style = DockStyle::from_ui(ui);
style.min_pane = 96.0;
style.float_label = crate::i18n::tr("Float panel").into();
style.close_label = crate::i18n::tr("Close panel").into();
style.move_label = crate::i18n::tr("Move to group").into();
style.panels_label = crate::i18n::tr("Panels").into();
style.resize_label = crate::i18n::tr("Resize panels").into();
style.resize_window_label = crate::i18n::tr("Resize panel window").into();
style.background = t.chrome;
style.tab_background = t.chrome;
style.active_background = t.canvas;
style.text = t.text;
style.inactive_text = t.text_dim;
style.border = egui::Stroke::new(1.0, t.divider);
style.accent = t.accent;
style.font = t.font(12.0);
// Tool and preset panels publish hover previews before the photo consumes them.
let mut canvas = None;
let output = DockArea::new(egui::Id::new("lightcraft-docking")).show_with_limits(
ui,
&workspace.layout,
&style,
|panel| crate::i18n::tr(panel.label()).into(),
permissions,
panel_limits,
|ui, panel| match panel {
Panel::Canvas => {
canvas = Some(ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect())));
}
Panel::Library => {
crate::widgets::register(ui.ctx(), "panel:left_panel", ui.max_rect());
crate::panels::left::show(app, ui);
}
Panel::Tools => {
crate::widgets::register(ui.ctx(), "panel:right_panel", ui.max_rect());
crate::panels::right::show(app, ui);
}
Panel::Presets => crate::panels::presets::show(app, ui),
},
);
if let Some(mut canvas) = canvas {
let ui = &mut canvas;
crate::panels::bottombar::show(app, ui);
let bg = if matches!(
app.ui.view,
crate::state::ViewMode::Detail | crate::state::ViewMode::Compare | crate::state::ViewMode::Survey | crate::state::ViewMode::Reference
) {
t.canvas
} else {
t.grid_bg
};
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match app.ui.view {
crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid => crate::panels::grid::show(app, ui),
crate::state::ViewMode::Detail => crate::panels::detail::show(app, ui),
crate::state::ViewMode::Compare => crate::panels::compare::show_compare(app, ui),
crate::state::ViewMode::Survey => crate::panels::compare::show_survey(app, ui),
crate::state::ViewMode::Reference => crate::panels::compare::show_reference(app, ui),
crate::state::ViewMode::People => crate::panels::people::show(app, ui),
});
}
let rendered = workspace;
let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app));
let _ = sync(&mut workspace, app);
let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden;
if stale {
craft_ui::docking::cancel_drag::<Panel>(ui.ctx(), egui::Id::new("lightcraft-docking"));
}
publish_visibility(app, &workspace);
app.ui.docking = Some(workspace);
if stale {
return;
}
for action in output.actions {
if let Err(error) = serde_json::to_value(action)
.map_err(|error| error.to_string())
.and_then(|action| command_impl(app, false, &json!({"action": action}), true))
{
app.ui.toast = Some((format!("Panel move was not applied: {error}"), ui.input(|input| input.time) + 8.0, None));
}
}
}
/// Keep the established ui.set width controls connected to the panel's current parent split.
pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(), String> {
state.left_width = crate::state::LEFT_WIDTH.clamp(state.left_width);
state.right_width = crate::state::RIGHT_WIDTH.clamp(state.right_width);
let Some(workspace) = state.docking.as_mut() else {
return Ok(());
};
workspace.validate()?;
for (key, panel, width) in [("leftWidth", Panel::Library, state.left_width), ("rightWidth", Panel::Tools, state.right_width)] {
if params.get(key).is_none() {
continue;
}
if let Some((_, location)) = workspace.hidden.iter_mut().find(|(id, _)| *id == panel) {
if let Some(rect) = &mut location.floating {
rect[2] = width;
}
match location.placement {
Placement::Split(Zone::Left) => location.size = Some(SplitSize::FixedFirst(width)),
Placement::Split(Zone::Right) => location.size = Some(SplitSize::FixedSecond(width)),
_ => {} // Width controls do not rewrite a saved vertical split's height.
}
continue;
}
if let Some(group) = workspace.layout.floating.iter().find(|group| group.panels.contains(&panel)) {
let mut rect = group.rect;
rect[2] = width;
workspace.apply(Action::MoveFloating { panel, rect })?;
continue;
}
let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default();
let mut change = None;
while let Some((node, path)) = nodes.pop() {
if let Node::Split { axis, first, second, .. } = node {
let contains = |node: &Node<Panel>| match node {
Node::Tabs { panels, .. } => panels.contains(&panel),
Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel),
Node::Split { .. } => false,
};
if *axis == SplitAxis::Horizontal {
if contains(first) {
change = Some(Action::ResizeSplit { path: path.clone(), size: SplitSize::FixedFirst(width) });
}
if contains(second) {
change = Some(Action::ResizeSplit { path: path.clone(), size: SplitSize::FixedSecond(width) });
}
}
let mut second_path = path.clone();
second_path.push(true);
let mut first_path = path;
first_path.push(false);
nodes.push((second, second_path));
nodes.push((first, first_path));
}
}
if let Some(action) = change {
workspace.apply(action)?;
}
}
Ok(())
}
fn panel_limits(panel: &Panel) -> PanelLimits {
let (min, max_width) = match panel {
Panel::Canvas => (egui::vec2(crate::state::MIN_PHOTO_WIDTH, 160.0), 1_000_000.0),
Panel::Library => (egui::vec2(crate::state::LEFT_WIDTH.min, 120.0), crate::state::LEFT_WIDTH.max),
Panel::Tools => (egui::vec2(crate::state::RIGHT_WIDTH.min, 120.0), crate::state::RIGHT_WIDTH.max),
Panel::Presets => (egui::vec2(250.0, 120.0), 520.0),
};
PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) }
}
#[cfg(test)]
mod tests {
use super::*;
fn app_with_widths() -> crate::LightcraftApp {
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
app.ui.left_width = 333.0;
app.ui.right_width = 410.0;
app
}
fn tools_split(axis: SplitAxis, size: SplitSize, first: bool) -> Workspace {
let rest = split(
SplitAxis::Horizontal,
SplitSize::FixedFirst(333.0),
tabs(Panel::Library),
split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)),
);
let root = if first { split(axis, size, tabs(Panel::Tools), rest) } else { split(axis, size, rest, tabs(Panel::Tools)) };
Workspace { layout: Layout { root: Some(root), floating: Vec::new() }, hidden: Vec::new() }
}
#[test]
fn explicit_reveal_opens_collapsed_tools_and_survives_reload() {
let mut app = app_with_widths();
app.ui.right = crate::state::RightPanel::Crop;
let entries = [Panel::Tools, Panel::Library, Panel::Presets]
.into_iter()
.map(|panel| craft_ui::docking::StackEntry { panel, open: panel != Panel::Tools, height: Some(180.0) })
.collect();
app.ui.docking = Some(Workspace {
layout: Layout {
root: Some(split(SplitAxis::Horizontal, SplitSize::FixedSecond(350.0), tabs(Panel::Canvas), Node::Stack { entries })),
floating: Vec::new(),
},
hidden: Vec::new(),
});
let undo = app.session.undo.len();
app.run("panel.crop", json!({})).unwrap();
assert_eq!(app.ui.right, crate::state::RightPanel::Crop);
let workspace = app.ui.docking.as_ref().unwrap();
assert!(workspace.exposed(Panel::Tools));
assert_eq!(app.session.undo.len(), undo);
let saved = serde_json::to_string(workspace).unwrap();
let reloaded: Workspace = serde_json::from_str(&saved).unwrap();
reloaded.validate().unwrap();
assert!(reloaded.exposed(Panel::Tools));
}
#[test]
fn legacy_width_publication_ignores_heights_and_sibling_widths() {
for (axis, size, first) in [
(SplitAxis::Vertical, SplitSize::FixedFirst(180.0), true),
(SplitAxis::Vertical, SplitSize::FixedSecond(180.0), false),
(SplitAxis::Horizontal, SplitSize::FixedSecond(180.0), true),
(SplitAxis::Horizontal, SplitSize::FixedFirst(180.0), false),
] {
let workspace = tools_split(axis, size, first);
workspace.validate().unwrap();
let mut app = app_with_widths();
publish_visibility(&mut app, &workspace);
assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 410.0), "{axis:?} {size:?}, first={first}");
}
}
#[test]
fn legacy_width_publication_reads_own_horizontal_and_floating_widths() {
for (size, first) in [(SplitSize::FixedFirst(310.0), true), (SplitSize::FixedSecond(310.0), false)] {
let workspace = tools_split(SplitAxis::Horizontal, size, first);
let mut app = app_with_widths();
publish_visibility(&mut app, &workspace);
assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 310.0));
}
let mut workspace = Workspace::default();
workspace.apply(Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap();
let mut app = app_with_widths();
publish_visibility(&mut app, &workspace);
assert_eq!(app.ui.right_width, 390.0);
}
#[test]
fn legacy_width_update_preserves_hidden_top_panel_height() {
let mut workspace = Workspace::default();
workspace.apply(Action::Move { panel: Panel::Tools, anchor: Panel::Canvas, placement: Placement::Split(Zone::Top) }).unwrap();
workspace.set_visible(Panel::Tools, false).unwrap();
let height = workspace.hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size;
let mut state = crate::UiState { docking: Some(workspace), right_width: 410.0, ..Default::default() };
set_widths(&mut state, &json!({"rightWidth":410.0})).unwrap();
assert_eq!(state.docking.as_ref().unwrap().hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size, height);
}
}
+33 -1
View File
@@ -2493,9 +2493,19 @@ mod tests {
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t);
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
let before = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap();
// The dock header leaves less vertical space; bring the metadata action into view.
let panel = h.app.widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1;
let clipped_action = h.app.widgets.iter().find(|(id, _)| id == "icon:editCaptureTime").unwrap().1;
assert!(!panel.contains_rect(clipped_action), "the capture-time action starts below the visible panel: {clipped_action:?}");
h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), t);
h.request("ui.scroll", json!({"dy": -160.0}), t);
h.settle(SETTLE);
let action = h.app.widgets.iter().find(|(id, _)| id == "icon:editCaptureTime").unwrap().1;
assert!(panel.contains_rect(action), "capture-time action must be visible before clicking: {action:?} in {panel:?}");
let r = h.request("ui.clickWidget", json!({"id": "icon:editCaptureTime"}), t);
assert_eq!(r["ok"], true, "{r}");
h.request("ui.clickWidget", json!({"id": "button:captureMode-2"}), t);
let r = h.request("ui.clickWidget", json!({"id": "button:captureMode-2"}), t);
assert_eq!(r["ok"], true, "{r}");
if let Some(crate::state::Dialog::CaptureTime { zone, .. }) = &mut h.app.ui.dialog {
*zone = -3.0;
} else {
@@ -3347,6 +3357,28 @@ mod tests {
assert_eq!(h.app.ui.pan, (0.5, 0.5));
}
#[test]
fn mouse_wheel_zooms_the_loupe_with_and_without_ctrl() {
use crate::state::Zoom;
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "none", "leftPanel": false, "filmstrip": false}), t);
h.settle(SETTLE);
for (unit, y, modifiers) in [
(egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::NONE),
(egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::CTRL),
(egui::MouseWheelUnit::Point, 50.0, egui::Modifiers::CTRL),
] {
h.request("ui.set", json!({"zoom": "fit", "pan": [0.5, 0.5]}), t);
h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t);
h.app.synthetic.push(egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers });
for _ in 0..10 {
h.step();
}
assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "wheel {unit:?} {modifiers:?} must zoom in, got {:?}", h.app.ui.zoom);
}
}
#[test]
fn trackpad_navigation_works_in_tools_compare_reference_and_before_after() {
use crate::state::Zoom;
+17 -1
View File
@@ -90,10 +90,16 @@ struct Pen<'a> {
r: Rect,
s: Stroke,
k: f32,
/// (cos, sin) of a turn about the grid's centre, clockwise on screen ([`paint_turned`]).
turn: Option<(f32, f32)>,
}
impl Pen<'_> {
fn pt(&self, x: f32, y: f32) -> Pos2 {
let (x, y) = match self.turn {
Some((c, s)) => (10.0 + (x - 10.0) * c - (y - 10.0) * s, 10.0 + (x - 10.0) * s + (y - 10.0) * c),
None => (x, y),
};
pos2(self.r.min.x + x * self.k, self.r.min.y + y * self.k)
}
fn line(&self, pts: &[(f32, f32)]) {
@@ -144,10 +150,20 @@ fn star_pts(cx: f32, cy: f32, r: f32) -> Vec<(f32, f32)> {
/// Paint `icon` centred in `rect` (square area used) with `color`.
pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) {
paint_with(p, rect, icon, color, None);
}
/// [`paint`], turned `radians` clockwise about its centre. Only for icons drawn with lines, arcs
/// and circles (rectangles stay square to the screen).
pub fn paint_turned(p: &Painter, rect: Rect, icon: Icon, color: Color32, radians: f32) {
paint_with(p, rect, icon, color, Some((radians.cos(), radians.sin())));
}
fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option<(f32, f32)>) {
let side = rect.width().min(rect.height());
let r = Rect::from_center_size(rect.center(), vec2(side, side));
let k = side / 20.0;
let pen = Pen { p, r, s: Stroke::new((1.5 * k).max(1.0), color), k };
let pen = Pen { p, r, s: Stroke::new((1.5 * k).max(1.0), color), k, turn };
use Icon::*;
match icon {
Sidebar => {
+75 -26
View File
@@ -10,6 +10,7 @@ pub mod album_picker;
pub mod control;
pub mod credits;
pub mod date_picker;
pub mod docking;
pub mod export_task;
pub mod headless;
pub mod i18n;
@@ -41,16 +42,28 @@ mod tests_activity;
#[cfg(test)]
mod tests_album_picker;
#[cfg(test)]
mod tests_before_after;
#[cfg(test)]
mod tests_compare;
#[cfg(test)]
mod tests_copy_paste_dialogs;
#[cfg(test)]
mod tests_crop_rotate;
#[cfg(test)]
mod tests_crop_tool;
#[cfg(test)]
mod tests_curve;
#[cfg(test)]
mod tests_date_picker;
#[cfg(test)]
mod tests_dialogs;
#[cfg(test)]
mod tests_filmstrip;
#[cfg(test)]
mod tests_grid;
#[cfg(test)]
mod tests_info_dialogs;
#[cfg(test)]
mod tests_keymap;
#[cfg(test)]
mod tests_keyword_files;
@@ -89,6 +102,8 @@ mod tests_titlebar;
#[cfg(test)]
mod tests_unsaved;
#[cfg(test)]
mod tests_wb_selector;
#[cfg(test)]
mod tests_zoom;
#[cfg(test)]
mod tests_zoom_keys;
@@ -229,6 +244,9 @@ pub struct LightcraftApp {
/// The keyboard shortcuts editor is waiting for a key press for this command: no shortcut
/// fires (the native menu bar drops its accelerators too) until it gets one or is cancelled.
pub recording_shortcut: Option<String>,
/// A text field had the keyboard at the end of the last frame. egui drops the focus as Esc
/// arrives, before the shortcuts are read, so this is how Esc there stays the field's own.
pub text_focus: bool,
/// The host is [`headless::Headless`] (it answers viewport screenshot commands itself).
pub headless_host: bool,
/// Warnings to show one at a time (damaged settings files…, issue #103).
@@ -341,6 +359,7 @@ impl LightcraftApp {
native_menu: false,
native_shortcuts: Default::default(),
recording_shortcut: None,
text_focus: false,
headless_host: false,
notices: vec![],
quit_prompt: None,
@@ -415,6 +434,9 @@ impl LightcraftApp {
return result;
}
if let Some(r) = menus::run_ui_command(self, id, &params) {
if r.is_ok() {
docking::reveal_command(self, id)?;
}
return r;
}
let r = self.session.execute(id, &params).map_err(|e| e.to_string());
@@ -423,6 +445,21 @@ impl LightcraftApp {
// Keep it hidden after release; O / the overlay eye can show it again.
self.ui.mask_overlay = false;
}
if r.is_ok() {
// These commands finish synchronously, so their completion would otherwise
// be invisible once the activity stack is gone. Never show success on error.
let completed = match id {
"library.compact" => Some(format!("✓ {}", i18n::tr("Optimize Library"))),
"library.clearPreviews" => Some(format!("✓ {}", i18n::tr("Clear Preview Cache"))),
"develop.autoSync" => Some(format!("{}: {}", i18n::tr("Auto Sync"), i18n::tr(if self.session.auto_sync { "On" } else { "Off" }))),
_ => None,
};
if let Some(message) = completed {
// Menu clicks can arrive before the next frame updates last_time.
let now = self.tasks.repaint.as_ref().map(|ctx| ctx.input(|i| i.time)).unwrap_or(self.last_time);
self.ui.toast = Some((message, now + TOAST_SECONDS, None));
}
}
if r.is_ok() && id == "photo.label" {
let label = params.get("label").and_then(Value::as_str).and_then(lightcraft_catalog::ColorLabel::parse);
let text = match label {
@@ -896,7 +933,7 @@ impl LightcraftApp {
{
self.toast(ctx, e);
}
let (presets, photos): (Vec<String>, Vec<String>) = dropped.into_iter().partition(|p| is_preset_file(p));
let (presets, photos) = split_presets(dropped);
if !presets.is_empty() {
let _ = self.run("file.importPresets", serde_json::json!({"paths": presets}));
}
@@ -1013,6 +1050,7 @@ impl LightcraftApp {
ctx.request_repaint();
return;
}
panels::dialogs::prepare(self, ui);
let t0 = now_ms();
if std::mem::take(&mut self.ui.quit) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
@@ -1037,31 +1075,7 @@ impl LightcraftApp {
panels::topbar::show(self, ui);
panels::library_problem::banner(self, ui);
panels::strip::show(self, ui);
if self.ui.right != state::RightPanel::None {
panels::right::show(self, ui);
}
if self.ui.presets {
panels::presets::show(self, ui);
}
if self.ui.left_panel {
panels::left::show(self, ui);
}
panels::bottombar::show(self, ui);
let t = theme::Tokens::get(&ctx);
let bg = if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare | state::ViewMode::Survey | state::ViewMode::Reference)
{
t.canvas
} else {
t.grid_bg
};
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match self.ui.view {
state::ViewMode::PhotoGrid | state::ViewMode::SquareGrid => panels::grid::show(self, ui),
state::ViewMode::Detail => panels::detail::show(self, ui),
state::ViewMode::Compare => panels::compare::show_compare(self, ui),
state::ViewMode::Survey => panels::compare::show_survey(self, ui),
state::ViewMode::Reference => panels::compare::show_reference(self, ui),
state::ViewMode::People => panels::people::show(self, ui),
});
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| docking::show(self, ui));
panels::second::show(self, &ctx);
panels::notices::show(self, &ctx);
panels::dialogs::show(self, &ctx);
@@ -1074,6 +1088,7 @@ impl LightcraftApp {
panels::keyword_list::drag_feedback(self, &ctx);
panels::toast(self, &ctx);
self.widgets = widgets::take_registry(&ctx);
self.text_focus = ctx.egui_wants_keyboard_input();
self.end_frame(&ctx, t0);
}
}
@@ -1141,8 +1156,39 @@ pub fn is_preset_file(path: &str) -> bool {
["lcpreset", "lrtemplate", "xmp", "zip", "cube", "lmp", "mplumpack"].contains(&ext.as_str())
}
/// Splits dropped files into presets and everything else. An `.xmp` dropped next to a photo with the same
/// name (`IMG_1.xmp` or `IMG_1.CR2.xmp` beside `IMG_1.CR2`) is that photo's sidecar, not a preset, and is left out.
pub fn split_presets(dropped: Vec<String>) -> (Vec<String>, Vec<String>) {
let dir_of = |p: &str| std::path::Path::new(p).parent().map(|d| d.to_string_lossy().to_lowercase()).unwrap_or_default();
let stem_of = |p: &str| std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let name_of = |p: &str| std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let is_xmp = |p: &str| std::path::Path::new(p).extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp"));
let photo_names: Vec<(String, String)> =
dropped.iter().filter(|p| !is_preset_file(p)).flat_map(|p| [(dir_of(p), stem_of(p)), (dir_of(p), name_of(p))]).collect();
let (mut presets, mut photos) = (Vec::new(), Vec::new());
for p in dropped {
if !is_preset_file(&p) {
photos.push(p);
} else if is_xmp(&p) && photo_names.contains(&(dir_of(&p), stem_of(&p))) {
// a photo's sidecar travels with the photo
} else {
presets.push(p);
}
}
(presets, photos)
}
#[cfg(test)]
mod drop_tests {
#[test]
fn photo_sidecars_are_not_presets() {
let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let dropped = v(&["/a/IMG_1.CR2", "/a/IMG_1.xmp", "/a/IMG_2.jpg", "/a/IMG_2.jpg.XMP", "/a/Look.xmp", "/b/IMG_1.xmp"]);
let (presets, photos) = super::split_presets(dropped);
assert_eq!(photos, v(&["/a/IMG_1.CR2", "/a/IMG_2.jpg"]));
assert_eq!(presets, v(&["/a/Look.xmp", "/b/IMG_1.xmp"]));
}
#[test]
fn detection_results_are_summarised_in_words() {
let r =
@@ -1616,3 +1662,6 @@ mod cache_tests {
#[cfg(test)]
mod tests_model_setup;
#[cfg(test)]
mod tests_docking_acceptance;
+21 -4
View File
@@ -51,6 +51,8 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> {
}
pub const UI_COMMANDS: &[UiCommand] = &[
("ui.dock", "Arrange Panel", None, ""),
("ui.dock.reset", "Reset Panel Layout", None, "Window"),
("modelSetup.cancel", "Cancel Pending Photo Action", None, ""),
("view.photoGrid", "Photo Grid", None, "View"),
("view.squareGrid", "Square Grid", None, "View"),
@@ -66,6 +68,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("view.reference", "Reference View", Some("Shift+R"), "View"),
("photo.setReference", "Set as Reference Photo", None, ""),
("compare.swap", "Swap Compare Photos", None, "View"),
("compare.set", "Show in Compare", None, ""),
("compare.makeSelect", "Make Candidate the Select", None, "View"),
("view.autoAdvance", "Auto Advance", None, "Photo"),
("view.filmstrip", "Filmstrip", Some("/"), "View"),
@@ -204,7 +207,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
];
fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) {
if app.ui.right == p {
if app.ui.right == p && crate::docking::exposed(app, crate::docking::Panel::Tools) {
app.ui.right = RightPanel::None;
app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = crate::i18n::tr(name)));
} else {
@@ -266,6 +269,9 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> {
/// Handle UI commands; `None` means "not a UI command — send it to the engine".
pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
if matches!(id, "ui.dock" | "ui.dock.reset") {
return Some(crate::docking::command(app, id == "ui.dock.reset", p));
}
if matches!(id, "library.inspectLightroom" | "library.importLightroom") {
// the app's own context: a fresh one's repaints reach no window and its clock starts at zero
let ctx = app.tasks.repaint.clone().unwrap_or_default();
@@ -385,6 +391,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(json!({"reference": id}))
}
"compare.swap" => crate::panels::compare::swap(app),
"compare.set" => {
let Some(id) = p.get("id").and_then(Value::as_u64) else { return Some(Err("compare.set needs `id`".into())) };
crate::panels::compare::set_photo(app, lightcraft_catalog::PhotoId(id), p.get("side").and_then(Value::as_str))
}
"compare.makeSelect" => crate::panels::compare::make_select(app),
"view.autoAdvance" => {
app.ui.auto_advance = !app.ui.auto_advance;
@@ -401,6 +411,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.slideshow = None;
} else if !app.ui.tool.is_empty() {
app.ui.tool.clear();
} else if app.ui.right == RightPanel::Crop && app.ui.view == ViewMode::Detail {
// Esc leaves the crop tool (the crop stays), as C does; the next Esc goes back
panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry");
} else if app.ui.view == ViewMode::People && app.ui.person_page.is_some() {
app.ui.person_page = None;
} else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) {
@@ -751,7 +764,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
"panel.profiles" => {
// toggles between the profile browser and the Edit panel it belongs to
app.ui.right = if app.ui.right == RightPanel::Profiles { RightPanel::Edit } else { RightPanel::Profiles };
app.ui.right = if app.ui.right == RightPanel::Profiles && crate::docking::exposed(app, crate::docking::Panel::Tools) {
RightPanel::Edit
} else {
RightPanel::Profiles
};
if !matches!(app.ui.view, ViewMode::Detail) {
app.ui.view = ViewMode::Detail;
}
@@ -793,7 +810,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(Value::Null)
}
"panel.presets" => {
app.ui.presets = !app.ui.presets;
app.ui.presets = !app.ui.presets || !crate::docking::exposed(app, crate::docking::Panel::Presets);
if app.ui.presets && app.ui.view != ViewMode::Detail {
app.ui.view = ViewMode::Detail;
}
@@ -1614,7 +1631,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
"file.backupLibrary" => app.services.backup_library.is_some(),
"app.openLogFolder" => app.services.reveal.is_some() && app.services.log_file.is_some(),
"file.restoreLibrary" => app.services.restore_library.is_some(),
"compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare,
"compare.swap" | "compare.makeSelect" | "compare.set" => app.ui.view == ViewMode::Compare,
s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => {
app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none()
}
+268 -37
View File
@@ -10,6 +10,15 @@ use crate::state::{BeforeAfter, ViewMode, Zoom};
use crate::theme::Tokens;
use crate::widgets::{icon_button, register, stars};
const VIEW_MODES: &[(&str, Icon, ViewMode, &str)] = &[
("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"),
("squareGrid", Icon::GridSquare, ViewMode::SquareGrid, "Square Grid (G toggles)"),
("detail", Icon::Single, ViewMode::Detail, "Detail (D)"),
("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"),
("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"),
("people", Icon::Subject, ViewMode::People, "People"),
];
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::bottom("bottom_bar")
@@ -17,38 +26,59 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left: 12, right: 12, top: 0, bottom: 0 }))
.show(ui, |ui| {
let full = ui.max_rect();
let left_end = ui
let compact_zoom = full.width() < 360.0;
let right_start = right_side(app, ui, full, compact_zoom);
let left_rect = Rect::from_min_max(full.min, pos2((right_start - 12.0).max(full.left()), full.bottom()));
let mut left_ui = ui.new_child(egui::UiBuilder::new().max_rect(left_rect));
let left_end = left_ui
.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for (id, icon, mode, tip) in [
("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"),
("squareGrid", Icon::GridSquare, ViewMode::SquareGrid, "Square Grid (G toggles)"),
("detail", Icon::Single, ViewMode::Detail, "Detail (D)"),
("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"),
("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"),
("people", Icon::Subject, ViewMode::People, "People"),
] {
if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() {
let _ = app.run(&format!("view.{id}"), json!({}));
if left_rect.width() >= 270.0 {
for &(id, icon, mode, tip) in VIEW_MODES {
if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() {
let _ = app.run(&format!("view.{id}"), json!({}));
}
}
ui.add_space(10.0);
let (sep, _) = ui.allocate_exact_size(vec2(1.0, 22.0), Sense::hover());
ui.painter().rect_filled(sep, 0.0, t.button_border);
ui.add_space(10.0);
let sort = icon_button(ui, "sort", Icon::Sort, vec2(30.0, 30.0), false, true, "Sort");
egui::Popup::menu(&sort).show(|ui| sort_menu(app, ui));
} else {
let current_icon = VIEW_MODES.iter().find(|(_, _, mode, _)| *mode == app.ui.view).map_or(Icon::More, |(_, icon, _, _)| *icon);
let more = icon_button(ui, "viewOptions", current_icon, vec2(40.0, 32.0), true, true, "View");
paint(
ui.painter(),
Rect::from_center_size(more.rect.right_bottom() - vec2(5.0, 5.0), vec2(7.0, 7.0)),
Icon::ChevronDown,
t.text,
);
egui::Popup::menu(&more).show(|ui| {
for &(id, _, mode, label) in VIEW_MODES {
if ui.selectable_label(app.ui.view == mode, crate::i18n::tr(label)).clicked() {
let _ = app.run(&format!("view.{id}"), json!({}));
ui.close();
}
}
ui.separator();
ui.menu_button(crate::i18n::tr("Sort"), |ui| sort_menu(app, ui));
if compact_zoom && matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) {
ui.menu_button(crate::i18n::tr_format!("Click {}:1", app.ui.click_zoom / 100), |ui| click_zoom_menu(app, ui));
}
});
}
ui.add_space(10.0);
let (sep, _) = ui.allocate_exact_size(vec2(1.0, 22.0), Sense::hover());
ui.painter().rect_filled(sep, 0.0, t.button_border);
ui.add_space(10.0);
let sort = icon_button(ui, "sort", Icon::Sort, vec2(30.0, 30.0), false, true, "Sort");
egui::Popup::menu(&sort).show(|ui| sort_menu(app, ui));
})
.response
.rect
.right();
let right_start = right_side(app, ui, full);
centre(app, ui, full, left_end + 12.0, right_start - 12.0);
});
}
/// Width of the centre group: the rating/flag pill, then Copy/Paste Edit Settings and its gear.
const PILL_W: f32 = 196.0;
/// Five 19-point stars, three 22-point flags, seven 3-point gaps, a 6-point group gap
/// and 10-point padding at both ends.
const PILL_W: f32 = 208.0;
/// The rating/flag pill and copy/paste settings, between `from` and `to` (the side groups): the
/// copy buttons go first when there is no room (they are in the Edit menu too), then the pill.
@@ -123,11 +153,11 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to:
}
/// The right-hand group (zoom, view toggles or thumbnail size); returns its left edge.
fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 {
fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, compact_zoom: bool) -> f32 {
let t = Tokens::get(ui.ctx());
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_max(pos2(full.right() - 360.0, full.top()), full.right_bottom()))
.max_rect(Rect::from_min_max(pos2((full.right() - 360.0).max(full.left()), full.top()), full.right_bottom()))
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
child.spacing_mut().item_spacing.x = 6.0;
@@ -191,21 +221,17 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 {
}
}
});
child.add_space(6.0);
let cz = crate::widgets::dropdown(
&mut child,
"clickZoom",
&crate::i18n::tr_format!("Click {}:1", app.ui.click_zoom / 100),
t.font(13.0),
t.text_label,
);
egui::Popup::menu(&cz).show(|ui| {
for pct in crate::state::CLICK_ZOOMS {
if ui.selectable_label(app.ui.click_zoom == pct, format!("{}:1", pct / 100)).clicked() {
let _ = app.run("view.clickZoom", json!({"ratio": pct / 100}));
}
}
});
if !compact_zoom {
child.add_space(6.0);
let cz = crate::widgets::dropdown(
&mut child,
"clickZoom",
&crate::i18n::tr_format!("Click {}:1", app.ui.click_zoom / 100),
t.font(13.0),
t.text_label,
);
egui::Popup::menu(&cz).show(|ui| click_zoom_menu(app, ui));
}
} else {
// thumbnail size slider
let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag());
@@ -225,6 +251,14 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 {
if child.min_rect().width() > 0.0 { child.min_rect().left() } else { full.right() }
}
fn click_zoom_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
for pct in crate::state::CLICK_ZOOMS {
if ui.selectable_label(app.ui.click_zoom == pct, format!("{}:1", pct / 100)).clicked() {
let _ = app.run("view.clickZoom", json!({"ratio": pct / 100}));
}
}
}
fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
use lightcraft_catalog::GroupBy;
use lightcraft_catalog::SortKey::*;
@@ -270,3 +304,200 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
}
}
#[cfg(test)]
mod visual_regressions {
use super::*;
fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput {
let mut input = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() };
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale);
input
}
#[test]
fn capture_bottom_bar_visual_fixtures() {
let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return };
std::fs::create_dir_all(&directory).unwrap();
for width in [280.0, 400.0, 600.0, 1000.0] {
for view in [
ViewMode::PhotoGrid,
ViewMode::SquareGrid,
ViewMode::Detail,
ViewMode::Compare,
ViewMode::Survey,
ViewMode::Reference,
ViewMode::People,
] {
for scale in [1.0, 1.5, 2.0] {
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Default::default());
app.ui.view = view;
let mut ready = false;
let mut harness =
egui_kittest::Harness::builder().with_size(vec2(width, 100.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
move |ui, app: &mut LightcraftApp| {
if !ready {
crate::theme::install_fonts(ui.ctx());
crate::theme::apply(ui.ctx());
ready = true;
return;
}
crate::widgets::take_registry(ui.ctx());
show(app, ui);
},
app,
);
harness.input_mut().max_texture_side = Some(8192);
harness.run_steps(4);
let stem = format!("lightcraft-bottom-{view:?}-{width}-{scale}x");
harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
let panel = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new("bottom_bar")).unwrap().outer_rect;
let controls: serde_json::Map<String, serde_json::Value> = crate::widgets::take_registry(&harness.ctx)
.into_iter()
.map(|(id, r)| (id, json!([r.left(), r.top(), r.width(), r.height()])))
.collect();
std::fs::write(
directory.join(format!("{stem}.json")),
serde_json::to_vec_pretty(&json!({
"screen": [width, 100.0], "scale": scale,
"regions": {"bottom-bar": [panel.left(), panel.top(), panel.width(), panel.height()]},
"controls": controls
}))
.unwrap(),
)
.unwrap();
}
}
}
}
#[test]
fn bottom_bar_controls_do_not_overlap_in_narrow_canvas() {
for width in [280.0, 400.0, 600.0, 1000.0] {
for view in [
ViewMode::PhotoGrid,
ViewMode::SquareGrid,
ViewMode::Detail,
ViewMode::Compare,
ViewMode::Survey,
ViewMode::Reference,
ViewMode::People,
] {
for scale in [1.0, 1.5, 2.0] {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx);
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Default::default());
app.ui.view = view;
let mut output = ctx.run_ui(scaled_input(vec2(width, 100.0), scale), |ui| show(&mut app, ui));
output.textures_delta.clear();
assert_eq!(ctx.content_rect().size(), vec2(width, 100.0));
assert_eq!(ctx.pixels_per_point(), scale);
let controls = crate::widgets::take_registry(&ctx);
assert!(!controls.is_empty());
for (index, (id, rect)) in controls.iter().enumerate() {
assert!(rect.left() >= 0.0 && rect.right() <= width, "{view:?} at {width}: {id} outside viewport: {rect:?}");
for (other_id, other) in controls.iter().skip(index + 1) {
assert!(!rect.intersect(*other).is_positive(), "{view:?} at {width}: {id} overlaps {other_id}: {rect:?} / {other:?}");
}
}
}
}
}
}
#[test]
fn compact_view_menu_opens_and_changes_view_by_keyboard() {
use egui_kittest::kittest::Queryable;
for scale in [1.0, 1.5, 2.0] {
let app = LightcraftApp::new(lightcraft_engine::Session::new(), Default::default());
let mut ready = false;
let mut harness = egui_kittest::Harness::builder().with_size(vec2(280.0, 640.0)).with_pixels_per_point(scale).build_ui_state(
move |ui, app: &mut LightcraftApp| {
if !ready {
crate::theme::install_fonts(ui.ctx());
crate::theme::apply(ui.ctx());
ready = true;
return;
}
crate::shortcuts::handle(app, ui.ctx());
crate::widgets::take_registry(ui.ctx());
show(app, ui);
},
app,
);
harness.run_steps(4);
assert_eq!(harness.ctx.content_rect().size(), vec2(280.0, 640.0));
assert_eq!(harness.ctx.pixels_per_point(), scale);
let mut focused = false;
for _ in 0..12 {
harness.key_press(egui::Key::Tab);
harness.run_steps(3);
focused = harness.get_by_label("View").is_focused();
if focused {
break;
}
}
assert!(focused, "Tab reaches the compact View control at {scale}x");
harness.key_press(egui::Key::Space);
harness.run_steps(3);
let mut people_focused = false;
for _ in 0..20 {
harness.key_press(egui::Key::Tab);
harness.run_steps(3);
people_focused = harness.get_by_label("People").is_focused();
if people_focused {
break;
}
}
assert!(people_focused, "Tab reaches People at {scale}x");
harness.key_press(egui::Key::Space);
harness.run_steps(3);
assert_eq!(harness.state().ui.view, ViewMode::People, "keyboard activation dispatches the view command");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Services;
use crate::headless::HeadlessView;
#[test]
fn rating_and_flag_buttons_keep_padding_inside_the_pill() {
for scale in [1.0, 2.0] {
for room in [240.0, 500.0] {
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default());
let mut view = HeadlessView::new();
for frame in 0..3 {
let raw = HeadlessView::raw_input(vec2(700.0, 40.0), scale, frame as f64 / 60.0, vec![]);
view.run(raw, |ui| {
let full = ui.max_rect();
centre(&mut app, ui, full, 20.0, 20.0 + room);
});
let widgets = crate::widgets::take_registry(&view.ctx);
let star = widgets.iter().find(|(id, _)| id == "star:bottom:1").unwrap().1;
let pill = view
.shapes
.iter()
.find_map(|shape| match &shape.shape {
egui::Shape::Rect(rect) if rect.rect.contains(star.center()) && rect.rect.height() == 30.0 => Some(rect.rect),
_ => None,
})
.unwrap();
let inner = pill.shrink2(vec2(10.0, 4.0));
for (id, rect) in &widgets {
if id.starts_with("star:bottom:") || id.starts_with("flag:") {
assert!(inner.contains_rect(*rect), "{id} {rect:?} exceeds the pill's padded bounds {inner:?} at scale {scale}");
}
}
assert!(pill.left() >= 20.0 && pill.right() <= 20.0 + room);
if let Some((_, copy)) = widgets.iter().find(|(id, _)| id == "button:copySettings") {
assert!(copy.left() >= pill.right() + 10.0);
}
}
}
}
}
}
+24
View File
@@ -73,6 +73,30 @@ pub fn swap(app: &mut LightcraftApp) -> Result<Value, String> {
Ok(Value::Null)
}
/// Show photo `id` on one side of the pair: `side` "select" or "candidate", else the side that is
/// active (the one the Edit panel and the rating keys act on). A photo already on the other side
/// swaps the two. The photo becomes the active one, so what is edited is always on screen.
pub fn set_photo(app: &mut LightcraftApp, id: PhotoId, side: Option<&str>) -> Result<Value, String> {
if app.session.catalog.photo(id).is_none_or(|p| p.deleted) {
return Err(format!("no photo {}", id.0));
}
let (sel, cand) = compare_pair(app).ok_or("nothing to compare")?;
let to_select = match side {
Some("select") => true,
Some("candidate") => false,
Some(other) => return Err(format!("unknown side `{other}` (select or candidate)")),
None => app.session.active() == Some(sel),
};
let (a, b) = match (to_select, id == sel, id == cand) {
(true, _, true) | (false, true, _) => (cand, sel),
(_, true, _) | (_, _, true) => (sel, cand),
(true, false, false) => (id, cand),
(false, false, false) => (sel, id),
};
select_pair(app, a, b, id);
Ok(json!({"select": a.0, "candidate": b.0}))
}
/// The candidate becomes the select; the next photo becomes the candidate.
pub fn make_select(app: &mut LightcraftApp) -> Result<Value, String> {
let (_, b) = compare_pair(app).ok_or("nothing to compare")?;
+204 -28
View File
@@ -153,13 +153,44 @@ pub(crate) fn pan_image(app: &mut LightcraftApp, area: Rect, img: Rect, delta: e
let _ = app.run("view.navigate", json!({"pan": pan}));
}
/// Net vertical notches (up is positive) of unmodified line/page-unit wheel events: a real mouse wheel, not a trackpad.
fn wheel_notches(events: &[egui::Event]) -> f32 {
events
.iter()
.filter_map(|e| match e {
egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page, delta, modifiers, .. }
if *modifiers == egui::Modifiers::NONE && delta.y.is_finite() =>
{
Some(delta.y)
}
_ => None,
})
.sum()
}
/// Native pinch (also modifier-wheel zoom) and two-finger scroll, scoped to this image view.
pub(crate) fn navigate_gesture(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, area: Rect, img: Rect, native: [usize; 2]) -> bool {
if !resp.hovered() {
return false;
}
let (factor, delta, anchor) =
let (mut factor, mut delta, anchor) =
ui.input(|i| (i.zoom_delta(), i.translation_delta(), i.multi_touch().map(|t| t.center_pos).or(i.pointer.hover_pos())));
// A notched mouse wheel (line/page units, no modifier) zooms around the pointer, through egui's smoothed wheel
// delta like Ctrl+wheel (so it glides instead of jumping); trackpads report points and still pan.
let wheel_id = egui::Id::new("loupe-wheel-zoom");
if ui.input(|i| wheel_notches(&i.events)) != 0.0 {
ui.data_mut(|d| d.insert_temp(wheel_id, true));
}
if ui.data(|d| d.get_temp::<bool>(wheel_id)).unwrap_or(false) {
let smooth = ui.input(|i| i.smooth_scroll_delta.y);
let speed = ui.ctx().options(|o| o.input_options.scroll_zoom_speed);
if smooth == 0.0 {
ui.data_mut(|d| d.remove_temp::<bool>(wheel_id));
} else {
factor *= (speed * smooth).exp();
delta = egui::Vec2::ZERO;
}
}
if !factor.is_finite() || factor <= 0.0 || !delta.is_finite() || (factor == 1.0 && delta == egui::Vec2::ZERO) {
return false;
}
@@ -531,7 +562,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let p = ui.painter_at(canvas);
// what a view slot shows: its own render of this photo, else the stand-ins (no blank frame
// between photos, and the full render replaces them in place)
let draw = |slot: Slot, r: Rect| -> &'static str {
let draw = |slot: Slot, r: Rect, clip: Rect| -> &'static str {
let mine = |s: Slot| app.renderer.textures.get(&s).filter(|t| t.photo == id);
let (tex, what) = if let Some(t) = mine(slot) {
(t, "render")
@@ -543,7 +574,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
return "none";
};
let image_rect = fit_texture_rect(r, tex.size);
p.image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
p.with_clip_rect(clip).image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
what
};
// soft proofing: a paper-white surround and the proof's name, as Lightroom shows it
@@ -554,13 +585,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
let shown;
if split {
// each view stays in its pane: zoomed in, a photo reaches past it (and would cover the
// other view's margins with its whole-frame render, magnified and blurred)
let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan);
draw(Slot::Before, br);
shown = draw(Slot::Main, img_rect);
p.text(pos2(br.left(), br.bottom() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim);
p.text(pos2(img_rect.left(), img_rect.bottom() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim);
draw(Slot::Before, br, areas[0]);
shown = draw(Slot::Main, img_rect, main_area);
// the labels under each photo, or under its pane when the photo reaches past it
let label_at = |r: Rect, pane: Rect| pos2(r.left().max(pane.left()), r.bottom().min(pane.bottom()) + 14.0);
p.text(label_at(br, areas[0]), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim);
p.text(label_at(img_rect, main_area), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim);
} else if show_before {
shown = draw(Slot::Before, img_rect);
shown = draw(Slot::Before, img_rect, canvas);
p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text);
} else if let Some((_key, label)) = &hover_key
&& let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id)
@@ -573,7 +608,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE);
shown = "hover";
} else {
shown = draw(Slot::Main, img_rect);
shown = draw(Slot::Main, img_rect, canvas);
if shown == "none" {
match app.renderer.failure(Slot::Main) {
Some(e) => {
@@ -685,7 +720,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d),
RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d),
RightPanel::RedEye => eye_overlay(app, ui, &resp, &map, &d),
_ => general_interaction(app, ui, &resp, &map, img_rect, main_area, native, aspect),
_ => {
// side by side / stacked: Before shows the same frame, so a click in its view (white
// balance, colour samples, targeted adjustment) means the same point of the photo.
// Zoomed in, a view's image reaches past its pane: what counts is the pane clicked.
let pointer = resp.interact_pointer_pos().or(resp.hover_pos());
let map = match (split, areas.first(), pointer) {
(true, Some(before), Some(q)) if before.contains(q) => {
CanvasMap::new(&frame, fit_rect(*before, aspect, app.ui.zoom, native, ppp, app.ui.pan))
}
_ => map,
};
general_interaction(app, ui, &resp, &map, img_rect, main_area, native, aspect)
}
}
}
// drawn and hit-tested above the loupe and its tools: clicks on it pan
@@ -1148,16 +1195,7 @@ fn general_interaction(
return;
}
if app.ui.tool == "colorRange" {
// click: sample the colour for the selected mask's colour range; ⇧-click adds (up to 5)
if resp.clicked()
&& let Some(q) = resp.interact_pointer_pos()
{
let n = map.norm(q);
let add = ui.input(|i| i.modifiers.shift);
if let Err(e) = app.run("mask.sampleColor", json!({"x": n.x, "y": n.y, "add": add})) {
app.toast(ui.ctx(), e);
}
}
color_range_pick(app, ui, resp, map);
return;
}
if app.ui.tool == "pointColor" {
@@ -1220,11 +1258,13 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
let shade = Color32::from_black_alpha(150);
let mut mesh = egui::epaint::Mesh::default();
let outer = [img.left_top(), img.right_top(), img.right_bottom(), img.left_bottom()];
// the box's corners in screen order (a flipped photo shows its top-left corner elsewhere)
let on_screen = flipped_corners(&pts, frame.flip_h, frame.flip_v);
for i in 0..4 {
let a = outer[i];
let b = outer[(i + 1) % 4];
let c = pts[(i + 1) % 4];
let dd = pts[i];
let c = on_screen[(i + 1) % 4];
let dd = on_screen[i];
let base = mesh.vertices.len() as u32;
for v in [a, b, c, dd] {
mesh.vertices.push(egui::epaint::Vertex { pos: v, uv: Pos2::ZERO, color: shade });
@@ -1285,7 +1325,7 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
None if inside(hq) && !rotating => egui::CursorIcon::Move,
// a drag here rotates: no system cursor shows that, so draw a curved double arrow
None => {
rotate_cursor(ui, hq);
rotate_cursor(ui, hq, rotate_cursor_turn(&pts, hq, inside(hq)));
egui::CursorIcon::None
}
};
@@ -1383,15 +1423,38 @@ fn top_painter(ui: &egui::Ui, name: &'static str) -> egui::Painter {
ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new(name)))
}
/// The rotation pointer: a curved double arrow on a dark disc, so it reads over any photo.
fn rotate_cursor(ui: &egui::Ui, at: Pos2) {
/// The rotation pointer: a curved double arrow on a dark disc, so it reads over any photo,
/// turned `turn` radians clockwise ([`rotate_cursor_turn`]).
fn rotate_cursor(ui: &egui::Ui, at: Pos2, turn: f32) {
let p = top_painter(ui, "crop-rotate-cursor");
let r = Rect::from_center_size(at, vec2(22.0, 22.0));
p.circle_filled(at, 12.0, Color32::from_black_alpha(150));
crate::icons::paint(&p, r.shrink(2.0), crate::icons::Icon::RotateDrag, Color32::WHITE);
crate::icons::paint_turned(&p, r.shrink(2.0), crate::icons::Icon::RotateDrag, Color32::WHITE, turn);
register(ui.ctx(), "cropRotateCursor", r);
}
/// How far to turn the rotation pointer (radians, clockwise) so its arc, drawn bulging up, bulges
/// away from the crop box `pts` (its four corners on screen, maybe rotated): up above the top edge,
/// right beside the right edge, down below it, diagonally off a corner. Measured from the nearest
/// point of the box's outline; `inside` (a rotate drag that has come back over the box) faces it
/// the other way, still away from that edge.
fn rotate_cursor_turn(pts: &[Pos2], q: Pos2, inside: bool) -> f32 {
let nearest = (0..pts.len())
.map(|i| {
let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
let ab = b - a;
let t = if ab.length_sq() > 0.0 { ((q - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 };
a + ab * t
})
.min_by(|a, b| a.distance_sq(q).total_cmp(&b.distance_sq(q)));
let Some(nearest) = nearest else { return 0.0 };
let away = if inside { nearest - q } else { q - nearest };
if away.length() < 0.5 {
return 0.0;
}
away.y.atan2(away.x) + std::f32::consts::FRAC_PI_2
}
/// While rotating: the angle, next to the pointer, kept inside `bounds` (the canvas): on the
/// pointer's other side when it would run past an edge.
fn angle_readout(ui: &egui::Ui, at: Pos2, angle: f64, bounds: Rect) {
@@ -1451,6 +1514,19 @@ fn to_straight(n: Point, angle: f64, frame: &Frame) -> Point {
Point::new(r.x / w, r.y / h)
}
/// A crop box's corners (top-left, top-right, bottom-right, bottom-left of the unflipped photo)
/// in the order they appear on screen when the photo is shown flipped.
fn flipped_corners(pts: &[Pos2], flip_h: bool, flip_v: bool) -> [Pos2; 4] {
let at = |i: usize| pts.get(i).copied().unwrap_or(Pos2::ZERO);
let order = match (flip_h, flip_v) {
(false, false) => [0, 1, 2, 3],
(true, false) => [1, 0, 3, 2],
(false, true) => [3, 2, 1, 0],
(true, true) => [2, 3, 0, 1],
};
order.map(at)
}
fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] {
let f = Frame { crop: d.crop.geometry, ..frame.clone() };
f.crop_quad_norm()
@@ -1543,6 +1619,21 @@ fn radial_body_contains(shape: &MaskShape, at: Point, map: &CanvasMap) -> bool {
x * x + y * y <= 1.0
}
/// Colour Range eyedropper: a click samples the colour under it for the selected mask's
/// colour range, at normalized frame coordinates (crop, rotation and zoom aware);
/// ⇧-click adds a sample (up to 5).
fn color_range_pick(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, map: &CanvasMap) {
if resp.clicked()
&& let Some(q) = resp.interact_pointer_pos()
{
let n = map.norm(q);
let add = ui.input(|i| i.modifiers.shift);
if let Err(e) = app.run("mask.sampleColor", json!({"x": n.x, "y": n.y, "add": add})) {
app.toast(ui.ctx(), e);
}
}
}
/// The Masking tool on the photo: outlines and handles of the selected mask, a pin per mask
/// component (click selects its mask, drag moves the component), and brush painting. The mask
/// itself shows as a rendered overlay ([`view_overlay`]).
@@ -1638,6 +1729,15 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
}
}
}
// Colour Range "Pick": the Masking panel owns the loupe's clicks (general_interaction
// never runs while it is open), so the eyedropper is handled here too.
if app.ui.tool == "colorRange" {
if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
}
color_range_pick(app, ui, resp, map);
return;
}
// Object tool: a click includes what's under it, ⌥-click leaves it out
if app.ui.tool == "object" {
if resp.hovered() {
@@ -1672,6 +1772,8 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
{
let n = map.norm(q);
match &mut app.gesture {
// a held, unmoving pointer records nothing: coverage builds by passes, not time (#518)
Some(Gesture::Brush { points }) if points.last() == Some(&n) => {}
Some(Gesture::Brush { points }) => points.push(n),
_ => app.gesture = Some(Gesture::Brush { points: vec![n] }),
}
@@ -2030,7 +2132,12 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
p.galley(pos2(br.min.x + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE);
}
}
if resp.clicked() {
if resp.clicked() && app.ui.view == crate::state::ViewMode::Compare {
// Compare: the clicked photo replaces the active side (or swaps with the other one)
if let Err(e) = app.run("compare.set", json!({"id": id.0})) {
app.toast(ui.ctx(), e);
}
} else if resp.clicked() {
let m = ui.input(|i| i.modifiers);
let mode = if m.command {
"toggle"
@@ -2143,7 +2250,42 @@ fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::R
#[cfg(test)]
mod tests {
use super::{film_label, texture_side};
/// The rotation pointer's arc bulges away from the side of the crop box it is on, and follows
/// a box that is itself rotated.
#[test]
fn rotate_cursor_faces_away_from_the_crop_box() {
use egui::{Pos2, pos2};
use std::f32::consts::{FRAC_PI_2, FRAC_PI_4, PI};
let near = |a: f32, b: f32| ((a - b + PI).rem_euclid(2.0 * PI) - PI).abs() < 1e-3;
let pts = [pos2(100.0, 100.0), pos2(300.0, 100.0), pos2(300.0, 200.0), pos2(100.0, 200.0)];
let turn = |x: f32, y: f32| super::rotate_cursor_turn(&pts, pos2(x, y), false);
assert!(near(turn(200.0, 80.0), 0.0), "above: as drawn");
assert!(near(turn(320.0, 150.0), FRAC_PI_2), "right");
assert!(near(turn(200.0, 230.0), PI), "below");
assert!(near(turn(70.0, 150.0), -FRAC_PI_2), "left");
assert!(near(turn(290.0, 60.0), 0.0), "above the top edge near its end: still up");
// off each corner: diagonally out from it
assert!(near(turn(80.0, 80.0), -FRAC_PI_4), "top-left corner");
assert!(near(turn(320.0, 80.0), FRAC_PI_4), "top-right corner");
assert!(near(turn(320.0, 220.0), 3.0 * FRAC_PI_4), "bottom-right corner");
assert!(near(turn(80.0, 220.0), -3.0 * FRAC_PI_4), "bottom-left corner");
assert!(near(super::rotate_cursor_turn(&pts, pos2(200.0, 110.0), true), 0.0), "dragged back over the top edge: still up");
// a box turned 90°: its top edge is now on the right
let turned = [pts[3], pts[0], pts[1], pts[2]].map(|p| pos2(400.0 - p.y, p.x));
assert!(near(super::rotate_cursor_turn(&turned, pos2(420.0, 200.0), false), FRAC_PI_2));
// a box turned 30°: straight out from its top edge is 30° clockwise of up
let c = pos2(200.0, 150.0);
let rot = |p: Pos2| {
let (s, co) = (30f32.to_radians().sin(), 30f32.to_radians().cos());
let d = p - c;
pos2(c.x + d.x * co - d.y * s, c.y + d.x * s + d.y * co)
};
let tilted = pts.map(rot);
assert!(near(super::rotate_cursor_turn(&tilted, rot(pos2(200.0, 60.0)), false), 30f32.to_radians()));
}
use super::{film_label, flipped_corners, texture_side};
use egui::pos2;
/// Issue #652: a native host reports the GPU's texture limit in the first frame's input only.
/// The loupe must still know it in every later frame (it read the raw input, found nothing and
@@ -2172,4 +2314,38 @@ mod tests {
assert_eq!(film_label("DSC_0001"), "DSC_0001");
assert_eq!(film_label(""), "");
}
/// With the photo flipped, the crop box's corners reach the screen mirrored; the dimming
/// outside the box pairs each photo corner with the box corner on the same side of the screen
/// (it used to pair them in data order, so a flipped photo was dimmed inside the box).
#[test]
fn flipped_crop_box_corners_come_in_screen_order() {
let screen = [pos2(10.0, 10.0), pos2(90.0, 10.0), pos2(90.0, 50.0), pos2(10.0, 50.0)];
// data order (top-left, top-right, bottom-right, bottom-left) as each flip shows it
let h = [screen[1], screen[0], screen[3], screen[2]];
let v = [screen[3], screen[2], screen[1], screen[0]];
let hv = [screen[2], screen[3], screen[0], screen[1]];
assert_eq!(flipped_corners(&screen, false, false), screen);
assert_eq!(flipped_corners(&h, true, false), screen);
assert_eq!(flipped_corners(&v, false, true), screen);
assert_eq!(flipped_corners(&hv, true, true), screen);
}
}
#[cfg(test)]
mod wheel_zoom_tests {
use super::*;
fn wheel(unit: egui::MouseWheelUnit, y: f32, modifiers: egui::Modifiers) -> egui::Event {
egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers }
}
#[test]
fn only_notched_unmodified_wheels_count() {
let m = egui::Modifiers::NONE;
assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, 1.0, m), wheel(egui::MouseWheelUnit::Line, 2.0, m)]), 3.0);
assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Point, 40.0, m)]), 0.0);
assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::CTRL)]), 0.0);
assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, f32::NAN, m)]), 0.0);
}
}
+40 -3
View File
@@ -60,6 +60,21 @@ pub const ABOUT_TABS: &[(&str, &str)] = &[("about", "About"), ("contributors", "
/// Help ▸ What's New (docs/whats-new.md).
pub const WHATS_NEW: &str = include_str!("../../../../docs/whats-new.md");
fn window_id(dialog: &Dialog) -> egui::Id {
egui::Id::new(if matches!(dialog, Dialog::Import { .. }) { "lightcraft-import-dialog" } else { "lightcraft-dialog" })
}
/// Block the underlying panels from the first frame, before the window establishes its modal
/// layer. Close background menus on entry; popups subsequently opened inside the dialog stay usable.
pub(crate) fn prepare(app: &LightcraftApp, ui: &mut egui::Ui) {
let Some(dialog) = &app.ui.dialog else { return };
ui.disable();
let layer = egui::LayerId::new(egui::Order::Middle, window_id(dialog));
if ui.ctx().memory(|m| m.top_modal_layer()) != Some(layer) {
egui::Popup::close_all(ui.ctx());
}
}
pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(mut dlg) = app.ui.dialog.clone() else { return };
let at_start = dlg.clone();
@@ -77,6 +92,16 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
});
let mut close = false;
let mut confirm = false;
// Return presses OK in a dialog without a text field of its own (Lightroom's default button);
// a widget that has the keyboard focus (a text field, a button or checkbox reached with Tab)
// takes Return itself. Consumed before the dialog is drawn, so nothing else sees it too.
if return_confirms(&dlg)
&& ctx.memory(|m| m.focused().is_none())
&& !egui::Popup::is_any_open(ctx)
&& ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter))
{
confirm = true;
}
let title: String = match &dlg {
Dialog::TextPrompt { title, .. } => title.as_str(),
Dialog::NewAlbum { folder: true, .. } => "Create Folder",
@@ -127,7 +152,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
// own so the size it is given doesn't carry over to the other dialogs. Its content scrolls
// rather than growing the window when the options below the grid get taller (e.g. Copy).
let import = matches!(dlg, Dialog::Import { .. });
let window_id = egui::Id::new(if import { "lightcraft-import-dialog" } else { "lightcraft-dialog" });
let window_id = window_id(&dlg);
let shown = egui::Window::new(crate::i18n::tr(&title)).id(window_id)
.collapsible(false)
.resizable(import)
@@ -948,9 +973,14 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
ui.add_space(4.0);
ui.horizontal(|ui| {
// a model file LightCraft cannot use has nothing to confirm
// a model file LightCraft cannot use has nothing to confirm, nor has a dialog that
// only shows something (one Close, no Cancel / OK)
let unusable_model = matches!(&dlg, Dialog::DenoiseModel { info, .. } | Dialog::FaceModel { info, .. } if info["kind"] == "unsupported");
let informational = unusable_model || matches!(dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. });
let informational = unusable_model
|| matches!(
dlg,
Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew
);
let sam = &app.session.segmenter;
let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some());
// no download location in this build: nothing to offer but the manual install
@@ -1022,6 +1052,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
});
if let Some(w) = shown {
ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id));
ctx.move_to_top(w.response.layer_id);
crate::widgets::register(ctx, "dialog:window", w.response.rect);
}
@@ -1556,6 +1587,12 @@ fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, label: &str, id: &str, option
});
}
/// Dialogs that Return confirms with nothing focused (those with text fields confirm from the
/// field: `dialog_field`).
fn return_confirms(dlg: &Dialog) -> bool {
matches!(dlg, Dialog::CopySettings { .. } | Dialog::PasteSettings { .. })
}
/// A dialog's one text field: it keeps the focus (except while its own menu has it) and opens
/// with its text selected, so typing replaces it. True when Return confirms the dialog; Esc
/// cancels the dialog (`show`). Taking the focus back with a bare `TextEdit` every frame hid the
+21 -16
View File
@@ -216,7 +216,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if crate::widgets::icon_button(ui, "wbPicker", Icon::Picker, vec2(28.0, 28.0), active, true, "White Balance Selector (W)")
.clicked()
{
app.ui.tool = if active { String::new() } else { "wbPicker".into() };
if active {
app.ui.tool.clear();
} else if let Err(e) = app.run("tool.wbPicker", json!({})) {
// the same as W: from Compare, Survey or the grid it opens the photo in Detail
app.toast(ui.ctx(), e);
}
}
});
});
@@ -561,21 +566,21 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
// EXIF strip
if let Some(ph) = app.session.catalog.photo(id) {
let m = &ph.meta;
let items = [
m.iso.map(|i| format!("ISO {i}")).unwrap_or_default(),
m.focal_mm.map(|f| format!("{f:.0}mm")).unwrap_or_default(),
m.aperture.map(|a| format!("f/{a:.1}")).unwrap_or_default(),
m.shutter.clone(),
];
let y = r.bottom() - 13.0;
for (i, s) in items.iter().enumerate() {
let x = r.left() + 18.0 + (r.width() - 36.0) * i as f32 / 3.0;
let align = match i {
0 => Align2::LEFT_CENTER,
3 => Align2::RIGHT_CENTER,
_ => Align2::CENTER_CENTER,
};
p.text(pos2(x, y), align, s, t.font(12.5), t.text_label);
let parts: Vec<String> = [
m.iso.map(|i| format!("ISO {i}")),
m.focal_mm.map(|f| format!("{f:.0}mm")),
m.aperture.map(|a| format!("f/{a:.1}")),
if m.shutter.is_empty() { None } else { Some(m.shutter.clone()) },
]
.into_iter()
.flatten()
.collect();
if !parts.is_empty() {
let text = parts.join(" · ");
let y = r.bottom() - 13.0;
let x = r.left() + r.width() / 2.0;
p.text(pos2(x, y), Align2::CENTER_CENTER, text, t.font(12.5), t.text_label);
}
}
}
+1 -1
View File
@@ -107,7 +107,7 @@ pub(crate) fn follow(app: &mut LightcraftApp, from: &str, to: &str) {
/// The pointer is on the part of `rect` that is shown: inside it and inside the panel's visible
/// (scrolled) area, so a drop never lands on a row or title hidden under the top bar.
fn shown_under(ui: &egui::Ui, rect: Rect, pointer: Option<egui::Pos2>) -> bool {
pointer.is_some_and(|p| rect.contains(p) && ui.clip_rect().contains(p))
crate::docking::drop_target(ui, rect, pointer)
}
/// A keyword being dragged, each frame (after the panels, which take the drop): its name follows
+3 -7
View File
@@ -180,8 +180,7 @@ fn sidebar_section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str,
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider));
let width = app.ui.left_width;
let resized = super::resizable_side(ui, true, "left_panel", frame, width, crate::state::LEFT_WIDTH, 0.0, |ui| {
frame.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("My Photos"), t.semibold(15.0), t.text);
@@ -300,9 +299,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
});
});
if let Some(w) = resized {
app.ui.left_width = w;
}
}
/// How many library photos have no file (Local browse records are not checked; see
@@ -775,7 +771,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare
/// Whether the pointer is over `rect` and over what the panel shows of it (a row scrolled out of
/// sight is not a drop target).
fn pointer_over(ui: &egui::Ui, rect: Rect) -> bool {
ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| rect.contains(p) && ui.clip_rect().contains(p))
crate::docking::drop_target(ui, rect, ui.input(|i| i.pointer.latest_pos()))
}
/// How close to the top or bottom edge of the sidebar (points) a dragged album starts scrolling it.
@@ -1010,7 +1006,7 @@ pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) {
/// release there adds them.
fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, a: &Album) {
let Some(ids) = app.ui.dragging_photos.clone() else { return };
let over = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| resp.rect.contains(p));
let over = pointer_over(ui, resp.rect);
if !over {
return;
}
+197 -215
View File
@@ -19,224 +19,206 @@ const THUMB_EDGE: usize = 128;
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::right("presets_panel")
.exact_size(t.panel_w)
.resizable(false)
.frame(egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)))
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover());
ui.painter().text(pos2(hr.left() + 24.0, hr.center().y + 2.0), Align2::LEFT_CENTER, crate::i18n::tr("Presets"), t.semibold(15.0), t.text);
let mut hdr = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_max(pos2(hr.right() - 80.0, hr.top()), hr.right_bottom()))
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
if icon_button(&mut hdr, "presetCreate", Icon::Plus, vec2(28.0, 28.0), false, app.session.active().is_some(), "Create Preset…").clicked()
{
app.ui.dialog = Some(crate::state::Dialog::create_preset());
egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover());
ui.painter().text(pos2(hr.left() + 24.0, hr.center().y + 2.0), Align2::LEFT_CENTER, crate::i18n::tr("Presets"), t.semibold(15.0), t.text);
let mut hdr = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_max(pos2(hr.right() - 80.0, hr.top()), hr.right_bottom()))
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
if icon_button(&mut hdr, "presetCreate", Icon::Plus, vec2(28.0, 28.0), false, app.session.active().is_some(), "Create Preset…").clicked() {
app.ui.dialog = Some(crate::state::Dialog::create_preset());
}
let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, "More preset options");
egui::Popup::menu(&more).show(|ui| {
let r = ui.checkbox(&mut app.ui.preset_thumbs, crate::i18n::tr("Show Thumbnails"));
register(ui.ctx(), "presetMenu:thumbnails", r.rect);
ui.separator();
if ui.button(crate::i18n::tr("Import Presets…")).clicked() {
let _ = app.run("file.importPresets", json!({}));
}
let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, "More preset options");
egui::Popup::menu(&more).show(|ui| {
let r = ui.checkbox(&mut app.ui.preset_thumbs, crate::i18n::tr("Show Thumbnails"));
register(ui.ctx(), "presetMenu:thumbnails", r.rect);
ui.separator();
if ui.button(crate::i18n::tr("Import Presets…")).clicked() {
let _ = app.run("file.importPresets", json!({}));
}
let any_user = app.session.presets.iter().any(|p| !p.builtin);
if ui.add_enabled(any_user, egui::Button::new(crate::i18n::tr("Export User Presets…"))).clicked() {
let _ = app.run("file.exportPresets", json!({}));
}
});
divider(ui);
// amount slider (applies to the last applied preset)
let amt_id = egui::Id::new("preset-amount");
let last: Option<(String, f64)> = ui.data(|d| d.get_temp(amt_id));
if let Some((pid, amount)) = last.clone() {
let spec = ControlSpec {
id: "presetAmount",
label: "Amount",
section: Section::Profile,
min: 0.0,
max: 200.0,
default: 100.0,
step: 1.0,
decimals: 0,
track: Track::Plain,
};
let out = slider(ui, &spec, amount, true, None);
if let Some(v) = out.value {
// re-apply from the pre-preset state: undo last preset step then apply with the new amount
let _ = app.run("edit.undo", json!({}));
let _ = app.run("preset.apply", json!({"id": pid, "amount": v}));
ui.data_mut(|d| d.insert_temp(amt_id, (pid, v)));
}
divider(ui);
}
let active = app.session.active();
let current = active.and_then(|id| app.session.develop_of(id)).map(|d| (*d).clone());
let thumbs = app.ui.preset_thumbs && active.is_some();
let row_h = if thumbs { 54.0 } else { 26.0 };
let scroll = egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let mut groups: BTreeMap<String, Vec<Preset>> = BTreeMap::new();
for p in &app.session.presets {
groups.entry(p.group.clone()).or_default().push(p.clone());
}
for (g, items) in groups {
// A group holding stock presets is a stock group (shown in the UI language), even
// when user presets share its name; a group of user presets keeps its name.
let builtin = items.iter().any(|p| p.builtin);
let open_id = egui::Id::new(("preset-group", &g));
let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
register(ui.ctx(), format!("presetGroup:{g}"), r);
paint(
ui.painter(),
Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)),
if open { Icon::ChevronDown } else { Icon::ChevronRight },
t.text_label,
);
ui.painter().text(
pos2(r.left() + 36.0, r.center().y),
Align2::LEFT_CENTER,
crate::i18n::builtin_label(&g, builtin),
t.semibold(13.0),
t.text_label,
);
if resp.clicked() {
ui.data_mut(|d| d.insert_temp(open_id, !open));
}
resp.context_menu(|ui| {
if ui.button(crate::i18n::tr("Export Group…")).clicked() {
let _ = app.run("file.exportPresets", json!({"group": g}));
}
});
if !open {
continue;
}
for pr in items {
let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
register(ui.ctx(), format!("preset:{pid}"), r);
let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid);
if is_last {
ui.painter().rect_filled(r, 0.0, t.tool_active);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.7));
}
let look = if thumbs || resp.hovered() { current.as_ref().map(|d| pr.apply(d, 1.0)) } else { None };
let mut text_x = r.left() + 40.0;
if thumbs && let (Some(id), Some(s)) = (active, look.as_ref()) {
let tr = Rect::from_min_size(pos2(r.left() + 36.0, r.top() + 5.0), vec2(66.0, row_h - 10.0));
ui.painter().rect_filled(tr, 2.0, t.canvas);
if let Some(job) = app.session.variant_job(id, s, THUMB_EDGE)
&& let Some(tex) = app.renderer.variant(job)
{
ui.painter().image(tex.tex.id(), tr, cover_uv(tr, tex.size), Color32::WHITE);
}
text_x = tr.right() + 10.0;
}
ui.painter().text(pos2(text_x, r.center().y), Align2::LEFT_CENTER, &name, t.font(13.0), t.text_label);
if fav {
paint(
ui.painter(),
Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(12.0, 12.0)),
Icon::StarFilled,
t.star,
);
}
// resting on a preset previews it in the loupe (no history entry)
if resp.hovered()
&& let Some(s) = look
{
let label = crate::i18n::tr_format!("Preset: {name}", name = name);
app.hover_preview = Some(HoverPreview { label: label.clone(), settings: s });
if let Some(id) = active {
ui.data_mut(|d| d.insert_temp(egui::Id::new("last-preset-hover"), (id, pr.clone(), label)));
}
}
if resp.clicked() {
let _ = app.run("preset.apply", json!({"id": pid, "amount": 100}));
ui.data_mut(|d| d.insert_temp(amt_id, (pid.clone(), 100.0)));
ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(egui::Id::new("last-preset-hover")));
app.toast(ui.ctx(), crate::i18n::tr_format!("Preset: {name}", name = name));
}
let (builtin, group) = (pr.builtin, pr.group.clone());
resp.context_menu(|ui| {
if ui.button(crate::i18n::tr(if fav { "Remove from Favorites" } else { "Add to Favorites" })).clicked() {
let _ = app.run("preset.favorite", json!({"id": pid}));
}
if builtin {
return;
}
ui.separator();
if ui
.add_enabled(app.session.active().is_some(), egui::Button::new(crate::i18n::tr("Update with Current Settings")))
.clicked()
{
match app.run("preset.update", json!({"id": pid})) {
Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Updated “{name}”", name = name)),
Err(e) => app.toast(ui.ctx(), e),
}
}
if ui.button(crate::i18n::tr("Rename…")).clicked() {
crate::panels::dialogs::prompt(
app,
"Rename Preset",
"Preset name",
&name,
"preset.rename",
json!({"id": pid}),
"name",
);
}
ui.menu_button(crate::i18n::tr("Move to Group"), |ui| {
let mut groups: Vec<String> = app.session.presets.iter().filter(|p| !p.builtin).map(|p| p.group.clone()).collect();
groups.sort();
groups.dedup();
for g in groups.iter().filter(|g| **g != group) {
if ui.button(g).clicked() {
let _ = app.run("preset.move", json!({"id": pid, "group": g}));
}
}
if ui.button(crate::i18n::tr("New Group…")).clicked() {
crate::panels::dialogs::prompt(
app,
"Move Preset to New Group",
"Group name",
"",
"preset.move",
json!({"id": pid}),
"group",
);
}
});
ui.separator();
if ui.button(crate::i18n::tr("Delete Preset")).clicked() {
let _ = app.run("preset.delete", json!({"id": pid}));
}
});
}
}
ui.add_space(30.0);
});
let last_hover_id = egui::Id::new("last-preset-hover");
let pointer_in_scroll = ui.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|pos| scroll.inner_rect.contains(pos));
if pointer_in_scroll {
if app.hover_preview.is_none()
&& let (Some(id), Some(curr)) = (active, current.as_ref())
&& let Some((saved_id, saved_preset, saved_label)) =
ui.data(|d| d.get_temp::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id))
&& saved_id == id
{
app.hover_preview = Some(HoverPreview { label: saved_label, settings: saved_preset.apply(curr, 1.0) });
}
} else {
ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id));
let any_user = app.session.presets.iter().any(|p| !p.builtin);
if ui.add_enabled(any_user, egui::Button::new(crate::i18n::tr("Export User Presets…"))).clicked() {
let _ = app.run("file.exportPresets", json!({}));
}
});
divider(ui);
// amount slider (applies to the last applied preset)
let amt_id = egui::Id::new("preset-amount");
let last: Option<(String, f64)> = ui.data(|d| d.get_temp(amt_id));
if let Some((pid, amount)) = last.clone() {
let spec = ControlSpec {
id: "presetAmount",
label: "Amount",
section: Section::Profile,
min: 0.0,
max: 200.0,
default: 100.0,
step: 1.0,
decimals: 0,
track: Track::Plain,
};
let out = slider(ui, &spec, amount, true, None);
if let Some(v) = out.value {
// re-apply from the pre-preset state: undo last preset step then apply with the new amount
let _ = app.run("edit.undo", json!({}));
let _ = app.run("preset.apply", json!({"id": pid, "amount": v}));
ui.data_mut(|d| d.insert_temp(amt_id, (pid, v)));
}
divider(ui);
}
let active = app.session.active();
let current = active.and_then(|id| app.session.develop_of(id)).map(|d| (*d).clone());
let thumbs = app.ui.preset_thumbs && active.is_some();
let row_h = if thumbs { 54.0 } else { 26.0 };
let scroll = egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let mut groups: BTreeMap<String, Vec<Preset>> = BTreeMap::new();
for p in &app.session.presets {
groups.entry(p.group.clone()).or_default().push(p.clone());
}
for (g, items) in groups {
// A group holding stock presets is a stock group (shown in the UI language), even
// when user presets share its name; a group of user presets keeps its name.
let builtin = items.iter().any(|p| p.builtin);
let open_id = egui::Id::new(("preset-group", &g));
let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
register(ui.ctx(), format!("presetGroup:{g}"), r);
paint(
ui.painter(),
Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)),
if open { Icon::ChevronDown } else { Icon::ChevronRight },
t.text_label,
);
ui.painter().text(
pos2(r.left() + 36.0, r.center().y),
Align2::LEFT_CENTER,
crate::i18n::builtin_label(&g, builtin),
t.semibold(13.0),
t.text_label,
);
if resp.clicked() {
ui.data_mut(|d| d.insert_temp(open_id, !open));
}
resp.context_menu(|ui| {
if ui.button(crate::i18n::tr("Export Group…")).clicked() {
let _ = app.run("file.exportPresets", json!({"group": g}));
}
});
if !open {
continue;
}
for pr in items {
let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
register(ui.ctx(), format!("preset:{pid}"), r);
let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid);
if is_last {
ui.painter().rect_filled(r, 0.0, t.tool_active);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.7));
}
let look = if thumbs || resp.hovered() { current.as_ref().map(|d| pr.apply(d, 1.0)) } else { None };
let mut text_x = r.left() + 40.0;
if thumbs && let (Some(id), Some(s)) = (active, look.as_ref()) {
let tr = Rect::from_min_size(pos2(r.left() + 36.0, r.top() + 5.0), vec2(66.0, row_h - 10.0));
ui.painter().rect_filled(tr, 2.0, t.canvas);
if let Some(job) = app.session.variant_job(id, s, THUMB_EDGE)
&& let Some(tex) = app.renderer.variant(job)
{
ui.painter().image(tex.tex.id(), tr, cover_uv(tr, tex.size), Color32::WHITE);
}
text_x = tr.right() + 10.0;
}
ui.painter().text(pos2(text_x, r.center().y), Align2::LEFT_CENTER, &name, t.font(13.0), t.text_label);
if fav {
paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(12.0, 12.0)), Icon::StarFilled, t.star);
}
// resting on a preset previews it in the loupe (no history entry)
if resp.hovered()
&& let Some(s) = look
{
let label = crate::i18n::tr_format!("Preset: {name}", name = name);
app.hover_preview = Some(HoverPreview { label: label.clone(), settings: s });
if let Some(id) = active {
ui.data_mut(|d| d.insert_temp(egui::Id::new("last-preset-hover"), (id, pr.clone(), label)));
}
}
if resp.clicked() {
let _ = app.run("preset.apply", json!({"id": pid, "amount": 100}));
ui.data_mut(|d| d.insert_temp(amt_id, (pid.clone(), 100.0)));
ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(egui::Id::new("last-preset-hover")));
app.toast(ui.ctx(), crate::i18n::tr_format!("Preset: {name}", name = name));
}
let (builtin, group) = (pr.builtin, pr.group.clone());
resp.context_menu(|ui| {
if ui.button(crate::i18n::tr(if fav { "Remove from Favorites" } else { "Add to Favorites" })).clicked() {
let _ = app.run("preset.favorite", json!({"id": pid}));
}
if builtin {
return;
}
ui.separator();
if ui
.add_enabled(app.session.active().is_some(), egui::Button::new(crate::i18n::tr("Update with Current Settings")))
.clicked()
{
match app.run("preset.update", json!({"id": pid})) {
Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Updated “{name}”", name = name)),
Err(e) => app.toast(ui.ctx(), e),
}
}
if ui.button(crate::i18n::tr("Rename…")).clicked() {
crate::panels::dialogs::prompt(app, "Rename Preset", "Preset name", &name, "preset.rename", json!({"id": pid}), "name");
}
ui.menu_button(crate::i18n::tr("Move to Group"), |ui| {
let mut groups: Vec<String> = app.session.presets.iter().filter(|p| !p.builtin).map(|p| p.group.clone()).collect();
groups.sort();
groups.dedup();
for g in groups.iter().filter(|g| **g != group) {
if ui.button(g).clicked() {
let _ = app.run("preset.move", json!({"id": pid, "group": g}));
}
}
if ui.button(crate::i18n::tr("New Group…")).clicked() {
crate::panels::dialogs::prompt(
app,
"Move Preset to New Group",
"Group name",
"",
"preset.move",
json!({"id": pid}),
"group",
);
}
});
ui.separator();
if ui.button(crate::i18n::tr("Delete Preset")).clicked() {
let _ = app.run("preset.delete", json!({"id": pid}));
}
});
}
}
ui.add_space(30.0);
});
let last_hover_id = egui::Id::new("last-preset-hover");
let pointer_in_scroll = ui.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|pos| scroll.inner_rect.contains(pos));
if pointer_in_scroll {
if app.hover_preview.is_none()
&& let (Some(id), Some(curr)) = (active, current.as_ref())
&& let Some((saved_id, saved_preset, saved_label)) =
ui.data(|d| d.get_temp::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id))
&& saved_id == id
{
app.hover_preview = Some(HoverPreview { label: saved_label, settings: saved_preset.apply(curr, 1.0) });
}
} else {
ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id));
}
});
}
/// The part of a `size` image that covers `r` (centre crop), as texture coordinates.
+1 -7
View File
@@ -14,10 +14,7 @@ use crate::widgets::{divider, register, slider, text_button};
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider));
// the presets column and the left sidebar are laid out after this panel: leave them their room
let reserve = if app.ui.presets { t.panel_w } else { 0.0 } + if app.ui.left_panel { crate::state::LEFT_WIDTH.min } else { 0.0 };
let width = app.ui.right_width;
let resized = super::resizable_side(ui, false, "right_panel", frame, width, crate::state::RIGHT_WIDTH, reserve, |ui| {
frame.show(ui, |ui| {
let Some(id) = app.session.active() else {
// the Keyword List is the library's, not a photo's: it is there without a selection
if app.ui.right == RightPanel::Keywords {
@@ -52,9 +49,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
});
});
if let Some(w) = resized {
app.ui.right_width = w;
}
}
pub fn header(ui: &mut egui::Ui, title: &str) {
+28 -1
View File
@@ -300,12 +300,37 @@ fn grid_bracket_command(keymap: &Keymap, grid: bool, id: &'static str, shortcut:
if keymap.iter().any(|(other, sc)| other != id && find_bindable(other).is_some() && parse(sc) == Some(shortcut)) { None } else { Some(command) }
}
/// Content widgets keep keyboard ownership, including text fields and arrow-driven sliders.
/// Dock chrome owns activation, traversal, resizing and cancellation; its other keys reach commands.
fn keyboard_owned(app: &LightcraftApp, ctx: &egui::Context) -> bool {
if !ctx.egui_wants_keyboard_input() {
return false;
}
let chrome = app
.ui
.docking
.as_ref()
.is_some_and(|workspace| craft_ui::docking::DockArea::new(egui::Id::new("lightcraft-docking")).chrome_has_focus(ctx, &workspace.layout));
if !chrome {
return true;
}
ctx.input(|input| {
[Key::Enter, Key::Space, Key::Tab, Key::Escape, Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown]
.iter()
.any(|key| input.key_pressed(*key))
})
}
pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
if !matches!(app.ui.dialog, Some(crate::state::Dialog::Shortcuts)) {
app.recording_shortcut = None;
}
// Dialogs own their keys (including Esc and text editing); background photo actions wait.
if app.ui.dialog.is_some() {
return;
}
// don't steal keys from text fields or from the keymap editor recording a shortcut
if ctx.egui_wants_keyboard_input() || app.recording_shortcut.is_some() || app.ui.name_edit.is_some() {
if keyboard_owned(app, ctx) || app.recording_shortcut.is_some() || app.ui.name_edit.is_some() {
return;
}
let mut fire: Vec<String> = Vec::new();
@@ -319,6 +344,8 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
// an open popup (a menu, a date picker's calendar) closes on Esc itself: Esc's command (Back,
// which also closes dialogs) waits until nothing is open
let popup_open = egui::Popup::is_any_open(ctx);
// Esc that just took a text field's focus away (it gives back the field's text) does nothing else
let popup_open = popup_open || app.text_focus;
ctx.input(|i| {
for b in bindable() {
if let Some(sc) = binding(keymap, b.id, b.default)
+3
View File
@@ -280,6 +280,8 @@ pub enum KeywordingView {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct UiState {
/// Shared panel arrangement; absent in legacy settings until their first layout.
pub docking: Option<crate::docking::Workspace>,
#[serde(default = "crate::i18n::default_language")]
pub language: crate::i18n::Locale,
/// The Build Previews run last announced (its identity, finished?).
@@ -780,6 +782,7 @@ pub struct SyncCounts {
impl Default for UiState {
fn default() -> Self {
UiState {
docking: None,
language: crate::i18n::default_language(),
preview_build_seen: None,
unsaved_seen: false,
+19
View File
@@ -393,3 +393,22 @@ fn rows_added_later_grow_the_stack() {
assert!(stack.contains_rect(r), "row {} at {r:?} is cut off by the stack at {stack:?}", g.id());
}
}
#[test]
fn immediate_library_commands_and_auto_sync_show_completion() {
let mut h = demo();
assert!(h.app.run("library.clearPreviews", json!({})).is_ok());
assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("✓ Clear Preview Cache"));
assert_eq!(h.app.run("develop.autoSync", json!({"on": true})).unwrap()["on"], true);
assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Auto Sync: On"));
assert_eq!(h.app.run("develop.autoSync", json!({"on": false})).unwrap()["on"], false);
assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Auto Sync: Off"));
// This demo has no on-disk library to optimize. A failed command must not
// report completion (or accidentally keep its preceding success toast).
h.app.ui.toast = None;
assert!(h.app.run("library.compact", json!({})).is_err());
assert!(h.app.ui.toast.is_none());
}
+71
View File
@@ -0,0 +1,71 @@
//! Headless tests of clicks in the two-view Before / After layouts.
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::state::{BeforeAfter, ViewMode, Zoom};
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
/// Click at a screen position with real egui pointer events (as `ui.pointer` sends them).
fn click_at(h: &mut Headless, pos: egui::Pos2) {
h.app.synthetic.push(egui::Event::PointerMoved(pos));
h.app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.settle(SETTLE);
}
/// The white-balance selector picks the same point of the photo whichever view of a side-by-side
/// or top/bottom Before / After layout is clicked, at Fit and zoomed in. Clicks on the Before
/// view used to be mapped through the After view's rectangle, so they sampled the photo's edge.
#[test]
fn wb_selector_picks_the_same_point_in_the_before_view() {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
let id = h.app.session.visible_cloned()[2];
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0], "active": id.0}}), T);
h.app.ui.view = ViewMode::Detail;
let wb = |h: &Headless| h.app.session.catalog.photo(id).map(|p| (p.develop.wb.temp, p.develop.wb.tint));
// lay the view out and arm the selector; returns the After view's image rect and the canvas
let arm = |h: &mut Headless, mode: BeforeAfter, zoom: Zoom| {
h.request("engine.execute", json!({"command": "develop.reset"}), T);
h.app.ui.before_after = mode;
h.app.ui.zoom = zoom;
h.app.ui.pan = Default::default();
h.settle(SETTLE);
let r = h.request("engine.execute", json!({"command": "tool.wbPicker"}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
(h.app.image_rect.expect("After view on screen"), h.app.canvas_rect.expect("canvas"))
};
for mode in [BeforeAfter::SideBySide, BeforeAfter::TopBottom] {
// the Before view is the After view moved by one pane: measured at Fit, where both are
// centred in their panes and so mirror each other about the canvas centre
let (img, canvas) = arm(&mut h, mode, Zoom::Fit);
let shift = match mode {
BeforeAfter::SideBySide => egui::vec2(img.left() + img.right() - 2.0 * canvas.center().x, 0.0),
_ => egui::vec2(0.0, img.top() + img.bottom() - 2.0 * canvas.center().y),
};
assert!(shift.length() > 100.0, "{mode:?}: two views, {shift:?} apart");
h.request("engine.execute", json!({"command": "tool.wbPicker"}), T);
for zoom in [Zoom::Fit, Zoom::Percent(100.0)] {
let mut picked = Vec::new();
for before_view in [false, true] {
let (img, canvas) = arm(&mut h, mode, zoom);
// the middle of the After pane (on the photo at Fit and zoomed in), or the same
// point of the photo in the Before view
let after = canvas.center() + shift / 2.0;
assert!(img.contains(after), "{mode:?} {zoom:?}: {after:?} on the photo {img:?}");
click_at(&mut h, if before_view { after - shift } else { after });
assert_eq!(h.app.ui.tool, "", "{mode:?} {zoom:?}: the selector is done after one click");
picked.push(wb(&h));
}
assert_ne!(picked[0], Some((6500.0, 0.0)), "{mode:?} {zoom:?}: the click picked a white balance");
assert_eq!(picked[1], picked[0], "{mode:?} {zoom:?}: the Before view picks the After view's point");
}
}
}
+84
View File
@@ -0,0 +1,84 @@
//! Headless tests of Compare with the filmstrip.
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::state::Zoom;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
fn demo() -> Headless {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
Headless::new(app, [1400.0, 900.0], 1.0)
}
/// In Compare, the photo the Edit panel and the rating keys act on is always one of the two on
/// screen: a filmstrip click must not leave the pair showing while a third photo becomes active.
#[test]
fn filmstrip_click_in_compare_keeps_the_active_photo_on_screen() {
let mut h = demo();
let vis: Vec<u64> = h.app.session.visible_cloned().iter().map(|p| p.0).collect();
let rating = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).map(|p| p.rating);
for zoom in [Zoom::Fit, Zoom::Percent(100.0)] {
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]], "active": vis[0]}}), T);
let r = h.request("engine.execute", json!({"command": "view.compare"}), T);
assert_eq!(r["ok"], true, "{r}");
h.app.ui.zoom = zoom;
h.settle(SETTLE);
let target = vis[4];
let before = rating(&h, target);
let r = h.request("ui.clickWidget", json!({"id": format!("film:{target}")}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
let (sel, cand) = h.app.ui.compare.expect("still comparing");
let active = h.app.session.selection.active.map(|p| p.0).expect("an active photo");
// a rating key must not change a photo that isn't shown
h.request("ui.key", json!({"key": "3"}), T);
if target != sel && target != cand {
assert_eq!(rating(&h, target), before, "zoom {zoom:?}: the hidden photo {target} was rated");
}
assert!(active == sel || active == cand, "zoom {zoom:?}: active photo {active} is not on screen (compare shows {sel} | {cand})");
}
}
/// A filmstrip click in Compare puts the photo into the active side: the candidate after
/// entering Compare, the select after clicking the select's pane; a photo already on the other
/// side swaps the two. `compare.set` does the same for agents, with an explicit `side`.
#[test]
fn filmstrip_click_replaces_the_active_side_in_compare() {
let mut h = demo();
let vis: Vec<u64> = h.app.session.visible_cloned().iter().map(|p| p.0).collect();
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]], "active": vis[0]}}), T);
let r = h.request("engine.execute", json!({"command": "view.compare"}), T);
let (sel, cand) = (r["result"]["select"].as_u64().unwrap(), r["result"]["candidate"].as_u64().unwrap());
h.settle(SETTLE);
let click_film = |h: &mut Headless, id: u64| {
let r = h.request("ui.clickWidget", json!({"id": format!("film:{id}")}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
};
let active = |h: &Headless| h.app.session.selection.active.map(|p| p.0);
let others: Vec<u64> = vis.iter().copied().filter(|v| *v != sel && *v != cand).take(3).collect();
// the candidate is active after entering Compare: it is replaced
click_film(&mut h, others[0]);
assert_eq!(h.app.ui.compare, Some((sel, others[0])));
assert_eq!(active(&h), Some(others[0]));
// the select's photo, clicked in the filmstrip: the two swap, it stays active
click_film(&mut h, sel);
assert_eq!(h.app.ui.compare, Some((others[0], sel)));
assert_eq!(active(&h), Some(sel));
// a side chosen explicitly
let r = h.request("engine.execute", json!({"command": "compare.set", "params": {"id": others[1], "side": "select"}}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.ui.compare, Some((others[1], sel)));
assert_eq!(active(&h), Some(others[1]));
// the select is active now: the next filmstrip click replaces it
click_film(&mut h, others[2]);
assert_eq!(h.app.ui.compare, Some((others[2], sel)));
let r = h.request("engine.execute", json!({"command": "compare.set", "params": {"id": others[0], "side": "left"}}), T);
assert_eq!(r["ok"], false, "unknown side is an error: {r}");
}
@@ -0,0 +1,91 @@
//! Return presses OK in Copy Settings (⇧⌘C) and Paste Selected Settings (⇧⌘V); Esc cancels (#735).
use std::time::Duration;
use lightcraft_catalog::PhotoId;
use serde_json::{Value, json};
use crate::LightcraftApp;
use crate::headless::Headless;
use crate::state::Dialog;
const SETTLE: Duration = Duration::from_secs(120);
const T: Duration = Duration::from_secs(10);
/// The demo library with an edited first photo selected; returns the first two photos' ids.
fn edited() -> (Headless, [u64; 2]) {
let services = crate::Services { png: None, ..Default::default() };
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
app.ui.view = crate::state::ViewMode::PhotoGrid;
let mut h = Headless::new(app, [1200.0, 900.0], 1.0);
let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect();
let ids = [ids[0], ids[1]];
exec(&mut h, "library.select", json!({"ids": [ids[0]]}));
exec(&mut h, "develop.set", json!({"values": {"light.exposure": 1.0, "color.vibrance": 30}}));
(h, ids)
}
fn exec(h: &mut Headless, command: &str, params: Value) {
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
assert_eq!(r["ok"], true, "{command}: {r}");
}
fn key(h: &mut Headless, k: Value) {
let r = h.request("ui.key", k.clone(), T);
assert_eq!(r["ok"], true, "{k}: {r}");
h.settle(SETTLE);
}
fn copy_dialog(h: &mut Headless) {
key(h, json!({"key": "C", "cmd": true, "shift": true}));
assert!(matches!(h.app.ui.dialog, Some(Dialog::CopySettings { .. })), "dialog not open: {:?}", h.app.ui.dialog);
}
/// Copies light and colour from the first photo, selects the second and opens ⇧⌘V.
fn paste_dialog(h: &mut Headless, ids: [u64; 2]) {
exec(h, "develop.copy", json!({"groups": ["light", "color"]}));
exec(h, "library.select", json!({"ids": [ids[1]]}));
key(h, json!({"key": "V", "cmd": true, "shift": true}));
assert!(matches!(h.app.ui.dialog, Some(Dialog::PasteSettings { .. })), "dialog not open: {:?}", h.app.ui.dialog);
}
fn exposure(h: &Headless, id: u64) -> f64 {
h.app.session.develop_of(PhotoId(id)).map(|d| d.light.exposure).unwrap_or_default()
}
#[test]
fn return_copies_the_chosen_settings() {
let (mut h, _) = edited();
copy_dialog(&mut h);
assert!(h.app.session.clipboard.is_none());
key(&mut h, json!({"key": "Enter"}));
assert!(h.app.ui.dialog.is_none(), "Return closes the dialog");
assert!(h.app.session.clipboard.is_some(), "Return copies the settings");
}
#[test]
fn escape_closes_copy_settings_without_copying() {
let (mut h, _) = edited();
copy_dialog(&mut h);
key(&mut h, json!({"key": "Escape"}));
assert!(h.app.ui.dialog.is_none(), "Esc closes the dialog");
assert!(h.app.session.clipboard.is_none(), "Esc copies nothing");
}
#[test]
fn return_pastes_into_the_selected_photos() {
let (mut h, ids) = edited();
paste_dialog(&mut h, ids);
key(&mut h, json!({"key": "Enter"}));
assert!(h.app.ui.dialog.is_none(), "Return closes the dialog");
assert_eq!(exposure(&h, ids[1]), 1.0, "Return pastes the settings");
}
#[test]
fn escape_closes_paste_settings_without_pasting() {
let (mut h, ids) = edited();
paste_dialog(&mut h, ids);
key(&mut h, json!({"key": "Escape"}));
assert!(h.app.ui.dialog.is_none(), "Esc closes the dialog");
assert_eq!(exposure(&h, ids[1]), 0.0, "Esc pastes nothing");
}
+64
View File
@@ -0,0 +1,64 @@
//! Headless tests of leaving the crop tool.
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::state::{RightPanel, ViewMode};
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
/// Esc in the crop tool closes the tool and keeps the photo in Detail (and the crop); only the
/// next Esc goes back to the grid. It used to jump straight to the grid.
#[test]
fn escape_leaves_the_crop_tool_before_the_view() {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
let id = h.app.session.visible_cloned()[1];
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0], "active": id.0}}), T);
h.app.ui.view = ViewMode::Detail;
let r = h.request("engine.execute", json!({"command": "panel.crop"}), T);
assert_eq!(r["ok"], true, "{r}");
let r = h.request("engine.execute", json!({"command": "crop.set", "params": {"rect": [0.1, 0.1, 0.8, 0.9]}}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert_eq!(h.app.ui.right, RightPanel::Crop);
let crop = h.app.session.catalog.photo(id).map(|p| p.develop.crop.geometry.rect);
h.request("ui.key", json!({"key": "Escape"}), T);
h.settle(SETTLE);
assert_ne!(h.app.ui.right, RightPanel::Crop, "Esc closes the crop tool");
assert_eq!(h.app.ui.view, ViewMode::Detail, "and stays on the photo");
assert_eq!(h.app.session.catalog.photo(id).map(|p| p.develop.crop.geometry.rect), crop, "the crop is kept");
h.request("ui.key", json!({"key": "Escape"}), T);
h.settle(SETTLE);
assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "the next Esc goes back to the grid");
}
/// Esc in the crop tool's Angle field only leaves the field: egui drops the field's focus as the
/// key arrives, so Esc used to run Back as well (to the grid; with the crop tool's own Esc step,
/// it would close the tool).
#[test]
fn escape_in_the_angle_field_only_leaves_the_field() {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
let id = h.app.session.visible_cloned()[1];
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0], "active": id.0}}), T);
h.app.ui.view = ViewMode::Detail;
h.request("engine.execute", json!({"command": "panel.crop"}), T);
h.settle(SETTLE);
let r = h.request("ui.clickWidget", json!({"id": "cropAngleField"}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
h.request("ui.text", json!({"text": "7"}), T);
h.settle(SETTLE);
h.request("ui.key", json!({"key": "Escape"}), T);
h.settle(SETTLE);
assert_eq!((h.app.ui.view, h.app.ui.right), (ViewMode::Detail, RightPanel::Crop), "Esc only left the field");
// the field has let go: the next Esc is the crop tool's
h.request("ui.key", json!({"key": "Escape"}), T);
h.settle(SETTLE);
assert_eq!((h.app.ui.view, h.app.ui.right == RightPanel::Crop), (ViewMode::Detail, false));
}
+98
View File
@@ -0,0 +1,98 @@
//! Modal application dialogs block background input while retaining their own controls.
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
use serde_json::json;
use std::time::Duration;
const T: Duration = Duration::from_secs(20);
fn demo(size: [f32; 2], ui: serde_json::Value) -> Headless {
let services = Services { png: None, ..Default::default() };
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
let mut h = Headless::new(app, size, 1.0);
let r = h.request("ui.set", ui, T);
assert_eq!(r["ok"], true, "{r}");
h.settle(Duration::from_secs(120));
h
}
/// Application dialogs block the toolbar, including the gear that would reopen Copy Settings
/// and discard its choices. Their own controls work, and closing restores background input.
#[test]
fn modal_dialogs_block_background_clicks_and_preserve_choices() {
let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"}));
let active = h.app.session.active().unwrap();
let rating = h.app.session.catalog.photo(active).unwrap().rating;
h.app.run("dialog.copySettings", json!({})).unwrap();
// Also exercise the first frame of a newly opened dialog.
let r = h.request("ui.clickWidget", json!({"id": "star:bottom:5"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, rating, "the dialog blocks background ratings");
let r = h.request("ui.clickWidget", json!({"id": "copyGroup:none"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
let choices = h.app.ui.dialog.clone();
assert!(matches!(&choices, Some(crate::state::Dialog::CopySettings { groups }) if groups.is_empty()));
let r = h.request("ui.clickWidget", json!({"id": "icon:copyGear"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert_eq!(h.app.ui.dialog, choices, "a background click cannot reopen the dialog and reset its choices");
let r = h.request("ui.clickWidget", json!({"id": "button:dialogCancel"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(h.app.ui.dialog.is_none());
let r = h.request("ui.clickWidget", json!({"id": "star:bottom:5"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, 5, "closing restores toolbar input");
// A different dialog uses the same barrier.
h.app.run("app.settings", json!({})).unwrap();
h.step();
let r = h.request("ui.clickWidget", json!({"id": "star:bottom:1"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, 5);
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(h.app.ui.dialog.is_none());
}
#[test]
fn modal_dialogs_block_background_shortcuts_and_keep_escape() {
let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"}));
let active = h.app.session.active().unwrap();
let rating = h.app.session.catalog.photo(active).unwrap().rating;
h.app.run("dialog.copySettings", json!({})).unwrap();
h.step();
for key in ["5", "ArrowRight", "J"] {
let r = h.request("ui.key", json!({"key": key}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
}
assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, rating);
assert_eq!(h.app.session.active(), Some(active));
assert!(!h.app.ui.show_clipping);
let r = h.request("ui.key", json!({"key": "Escape"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(h.app.ui.dialog.is_none());
let r = h.request("ui.key", json!({"key": "5"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, 5, "closing restores shortcuts");
}
#[test]
fn modal_dialogs_close_background_menus() {
let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"}));
let r = h.request("ui.clickWidget", json!({"id": "menu:Photo"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(egui::Popup::is_any_open(&h.view.ctx));
h.app.run("dialog.copySettings", json!({})).unwrap();
h.step();
assert!(!egui::Popup::is_any_open(&h.view.ctx), "background menus cannot remain above the modal window");
}
@@ -0,0 +1,334 @@
//! Dock changes exercise the same real catalog and command handlers as the application.
use crate::{LightcraftApp, Services, docking::Panel, headless::Headless, state::RightPanel};
use egui::{Event, Modifiers, PointerButton};
use serde_json::json;
fn app() -> Headless {
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
app.ui.left_panel = true;
let mut h = Headless::new(app, [1400.0, 1000.0], 1.0);
for _ in 0..5 {
h.step();
}
h
}
fn dock(h: &mut Headless, p: serde_json::Value) {
h.app.run("ui.dock", p).unwrap();
for _ in 0..4 {
h.step();
}
}
fn rect(h: &Headless, id: &str) -> egui::Rect {
h.app.widgets.iter().find(|(key, _)| key == id).unwrap().1
}
fn release(h: &mut Headless, pos: egui::Pos2) {
h.app.synthetic.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.app.synthetic.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.step();
}
#[test]
fn inactive_same_mode_commands_reveal_without_closing_or_editing() {
let mut h = app();
dock(&mut h, json!({"operation":"move","panel":"tools","target":"library","zone":"center"}));
for command in ["panel.crop", "panel.crop", "tool.brush", "section.light", "panel.profiles"] {
dock(&mut h, json!({"operation":"activate","panel":"library"}));
let undo = h.app.session.undo.len();
let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap();
h.app.run(command, json!({})).unwrap();
assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Tools), "{command}");
assert_ne!(h.app.ui.right, RightPanel::None);
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap(), photos);
h.step();
}
h.app.ui.presets = true;
h.step();
dock(&mut h, json!({"operation":"move","panel":"presets","target":"library","zone":"center"}));
dock(&mut h, json!({"operation":"activate","panel":"library"}));
h.app.run("panel.presets", json!({})).unwrap();
assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Presets));
dock(&mut h, json!({"operation":"activate","panel":"library"}));
dock(&mut h, json!({"operation":"open","panel":"presets"}));
assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Presets));
}
#[test]
fn crop_canvas_double_click_reveals_edit_in_actual_custom_workspace() {
let mut h = app();
h.app.ui.view = crate::state::ViewMode::Detail;
h.app.run("panel.crop", json!({})).unwrap();
assert!(h.settle(std::time::Duration::from_secs(120)), "the demo photo settles before pointer input");
h.app.ui.presets = true;
h.step();
dock(&mut h, json!({"operation":"move","panel":"tools","target":"presets","zone":"center"}));
dock(&mut h, json!({"operation":"activate","panel":"presets"}));
assert_eq!(h.app.ui.right, RightPanel::Crop);
assert!(!h.app.ui.docking.as_ref().unwrap().exposed(Panel::Tools));
let library = h.app.ui.docking.as_ref().unwrap().layout.location(&Panel::Library).unwrap();
let id = h.app.session.active().unwrap();
let photo = serde_json::to_value(h.app.session.catalog.photo(id).unwrap().as_ref()).unwrap();
let undo = h.app.session.undo.len();
let center = h.app.image_rect.expect("crop photo is rendered").center();
release(&mut h, center);
release(&mut h, center);
for _ in 0..3 {
h.step();
}
assert_eq!(h.app.ui.right, RightPanel::Edit, "the real Canvas double-click completes Crop");
let workspace = h.app.ui.docking.as_ref().unwrap();
assert!(workspace.exposed(Panel::Tools), "tool.done reveals Tools after the Canvas callback");
assert_eq!(workspace.layout.location(&Panel::Tools).unwrap().anchor, Some(Panel::Presets));
assert_eq!(workspace.layout.location(&Panel::Library).unwrap(), library);
assert_eq!(h.app.session.active(), Some(id));
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(serde_json::to_value(h.app.session.catalog.photo(id).unwrap().as_ref()).unwrap(), photo);
}
#[test]
fn floating_panel_blocks_photo_album_drop_then_uncovered_target_adds_exact_selection() {
let mut h = app();
let album = h.app.run("album.create", json!({"name":"A Dock Target", "addSelected":false})).unwrap()["id"].as_u64().unwrap();
h.step();
h.step();
let row = rect(&h, &format!("source:album:{album}"));
let ids: Vec<_> = h.app.session.visible_cloned().iter().take(2).map(|id| id.0).collect();
dock(&mut h, json!({"operation":"float","panel":"tools","rect":[row.left()-10.0,row.top()-30.0,330,500]}));
let undo = h.app.session.undo.len();
h.app.ui.dragging_photos = Some(ids.clone());
release(&mut h, row.center());
assert!(h.app.session.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.is_empty());
assert_eq!(h.app.session.undo.len(), undo);
dock(&mut h, json!({"operation":"moveFloating","panel":"tools","rect":[800,100,330,500]}));
h.app.ui.dragging_photos = Some(ids.clone());
release(&mut h, row.center());
assert_eq!(
h.app.session.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos,
ids.into_iter().map(lightcraft_catalog::PhotoId).collect::<Vec<_>>()
);
assert_eq!(h.app.session.undo.len(), undo + 1);
}
#[test]
fn preset_hover_survives_float_movement_without_mutating_photo() {
let mut h = app();
h.app.run("panel.presets", json!({})).unwrap();
dock(&mut h, json!({"operation":"float","panel":"presets","rect":[500,100,300,700]}));
let undo = h.app.session.undo.len();
let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap();
for x in [500, 650] {
dock(&mut h, json!({"operation":"moveFloating","panel":"presets","rect":[x,100,300,700]}));
let row = h.app.widgets.iter().find(|(id, _)| id.starts_with("preset:")).unwrap().1;
h.app.synthetic.push(Event::PointerMoved(row.center()));
h.step();
h.step();
assert!(h.app.hover_preview.is_some());
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap(), photos);
}
h.app.synthetic.push(Event::PointerMoved(egui::pos2(5.0, 5.0)));
h.step();
h.step();
assert!(h.app.hover_preview.is_none());
}
fn key(h: &mut Headless, key: egui::Key) {
h.app.synthetic.push(Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.step();
h.app.synthetic.push(Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.step();
}
#[test]
fn dock_tab_keeps_activation_keys_and_global_command_remains_reachable() {
let mut h = app();
h.app.run("app.setShortcut", json!({"id":"panel.crop","shortcut":"K"})).unwrap();
dock(&mut h, json!({"operation":"move","panel":"tools","target":"library","zone":"center"}));
h.app.run("panel.crop", json!({})).unwrap();
h.step();
// The test chooses the renderer's actual registered tab, rather than an arbitrary focus owner.
let id = egui::Id::new("lightcraft-docking").with(("tab", Panel::Tools));
h.view.ctx.memory_mut(|memory| memory.request_focus(id));
h.step();
key(&mut h, egui::Key::Enter);
assert_eq!(h.app.ui.right, RightPanel::Crop, "Enter activates the tab without tool.done");
key(&mut h, egui::Key::K);
assert_eq!(h.app.ui.right, RightPanel::None, "the global crop toggle is reachable from tab focus");
}
#[test]
fn content_text_edit_retains_command_keys_and_typed_text() {
let mut h = app();
h.app.run("app.setShortcut", json!({"id":"panel.crop","shortcut":"K"})).unwrap();
h.app.run("panel.keywords", json!({})).unwrap();
h.step();
h.step();
let result = h.request("ui.clickWidget", json!({"id":"field:keywordFilter"}), std::time::Duration::from_secs(20));
assert_eq!(result["ok"], true, "{result}");
key(&mut h, egui::Key::K);
h.app.synthetic.push(Event::Text("dock search".into()));
h.step();
assert_eq!(h.app.ui.right, RightPanel::Keywords);
assert_eq!(h.view.ctx.data(|data| data.get_temp::<String>(egui::Id::new("keyword-list-filter"))).as_deref(), Some("dock search"));
}
#[test]
fn floating_presets_block_keyword_photo_drop_then_uncovered_target_changes_exact_metadata() {
let mut h = app();
h.app.run("panel.keywords", json!({})).unwrap();
h.app.run("keyword.create", json!({"name":"A Dock Keyword"})).unwrap();
h.step();
h.step();
let row = rect(&h, "keywordRow:A Dock Keyword");
h.app.ui.presets = true;
h.step();
let ids: Vec<_> = h.app.session.visible_cloned().iter().take(2).map(|id| id.0).collect();
dock(&mut h, json!({"operation":"float","panel":"presets","rect":[row.left()-10.0,row.top()-30.0,300,500]}));
let undo = h.app.session.undo.len();
let before: Vec<_> = ids.iter().map(|id| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(*id)).unwrap().meta.keywords.clone()).collect();
h.app.ui.dragging_photos = Some(ids.clone());
release(&mut h, row.center());
for (id, expected) in ids.iter().zip(&before) {
assert_eq!(&h.app.session.catalog.photo(lightcraft_catalog::PhotoId(*id)).unwrap().meta.keywords, expected);
}
assert_eq!(h.app.session.undo.len(), undo);
dock(&mut h, json!({"operation":"moveFloating","panel":"presets","rect":[400,100,300,500]}));
h.app.ui.dragging_photos = Some(ids.clone());
release(&mut h, row.center());
for (id, mut expected) in ids.iter().zip(before) {
expected.push("A Dock Keyword".into());
let mut actual = h.app.session.catalog.photo(lightcraft_catalog::PhotoId(*id)).unwrap().meta.keywords.clone();
expected.sort();
actual.sort();
assert_eq!(actual, expected);
}
assert_eq!(h.app.session.undo.len(), undo + 1);
}
#[test]
fn covered_folder_rejects_album_move_and_uncovered_folder_nests_exact_album() {
let mut h = app();
let folder = h.app.run("album.create", json!({"name":"A Dock Folder","folder":true})).unwrap()["id"].as_u64().unwrap();
let child = h.app.run("album.create", json!({"name":"B Dock Album","addSelected":false})).unwrap()["id"].as_u64().unwrap();
h.step();
h.step();
let row = rect(&h, &format!("source:folder:{folder}"));
dock(&mut h, json!({"operation":"float","panel":"tools","rect":[row.left()-10.0,row.top()-30.0,330,500]}));
let undo = h.app.session.undo.len();
h.app.ui.dragging_album = Some(child);
release(&mut h, row.center());
assert_eq!(h.app.session.catalog.album(lightcraft_catalog::AlbumId(child)).unwrap().parent, None);
assert_eq!(h.app.session.undo.len(), undo);
dock(&mut h, json!({"operation":"moveFloating","panel":"tools","rect":[800,100,330,500]}));
h.app.ui.dragging_album = Some(child);
release(&mut h, row.center());
assert_eq!(h.app.session.catalog.album(lightcraft_catalog::AlbumId(child)).unwrap().parent, Some(lightcraft_catalog::AlbumId(folder)));
assert_eq!(h.app.session.undo.len(), undo + 1);
}
#[test]
fn covered_keyword_rejects_hierarchy_move_and_uncovered_target_moves_exact_keyword() {
let mut h = app();
h.app.run("panel.keywords", json!({})).unwrap();
h.app.run("keyword.create", json!({"name":"A Dock Parent"})).unwrap();
h.app.run("keyword.create", json!({"name":"B Dock Child"})).unwrap();
h.app.ui.presets = true;
h.step();
h.step();
let row = rect(&h, "keywordRow:A Dock Parent");
dock(&mut h, json!({"operation":"float","panel":"presets","rect":[row.left()-10.0,row.top()-30.0,300,500]}));
let undo = h.app.session.undo.len();
h.app.ui.dragging_keyword = Some("B Dock Child".into());
release(&mut h, row.center());
assert!(h.app.session.catalog.has_keyword("B Dock Child"));
assert!(!h.app.session.catalog.has_keyword("A Dock Parent|B Dock Child"));
assert_eq!(h.app.session.undo.len(), undo);
dock(&mut h, json!({"operation":"moveFloating","panel":"presets","rect":[400,100,300,500]}));
h.app.ui.dragging_keyword = Some("B Dock Child".into());
release(&mut h, row.center());
assert!(!h.app.session.catalog.has_keyword("B Dock Child"));
assert!(h.app.session.catalog.has_keyword("A Dock Parent|B Dock Child"));
assert_eq!(h.app.session.undo.len(), undo + 1);
}
#[test]
fn reset_during_held_divider_drag_keeps_replacement_layout_on_later_movement() {
let mut h = app();
let divider = egui::Id::new("lightcraft-docking").with(("split", Vec::<bool>::new()));
let from = h.view.ctx.read_response(divider).expect("the custom workspace root divider is rendered").rect.center();
let before = h.app.ui.docking.as_ref().unwrap().layout.clone();
h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.app.synthetic.push(Event::PointerMoved(from + egui::vec2(40.0, 0.0)));
h.step();
assert_ne!(h.app.ui.docking.as_ref().unwrap().layout, before, "fixture performs a real divider resize");
assert_eq!(h.view.ctx.dragged_id(), Some(divider));
let resized = h.app.ui.docking.as_ref().unwrap().layout.clone();
h.app.synthetic.push(Event::PointerMoved(from + egui::vec2(55.0, 0.0)));
h.step();
assert_ne!(h.app.ui.docking.as_ref().unwrap().layout, resized, "renderer-generated actions preserve continuous dragging");
let undo = h.app.session.undo.len();
h.app.run("ui.dock.reset", json!({})).unwrap();
let reset = serde_json::to_value(&h.app.ui.docking).unwrap();
h.app.synthetic.push(Event::PointerMoved(from + egui::vec2(90.0, 0.0)));
h.step();
assert_eq!(serde_json::to_value(&h.app.ui.docking).unwrap(), reset, "a held old divider cannot resize the replacement tree");
h.app.synthetic.push(Event::PointerButton {
pos: from + egui::vec2(90.0, 0.0),
button: PointerButton::Primary,
pressed: false,
modifiers: Modifiers::NONE,
});
h.step();
assert_eq!(serde_json::to_value(&h.app.ui.docking).unwrap(), reset);
assert_eq!(h.app.session.undo.len(), undo);
}
fn held_divider_replacement_route(route: &str) {
let mut h = app();
let divider = egui::Id::new("lightcraft-docking").with(("split", Vec::<bool>::new()));
let from = h.view.ctx.read_response(divider).unwrap().rect.center();
let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap();
h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.app.synthetic.push(Event::PointerMoved(from + egui::vec2(40.0, 0.0)));
h.step();
assert_eq!(h.view.ctx.dragged_id(), Some(divider));
let undo = h.app.session.undo.len();
match route {
"ui.set" => {
let response = h.request("ui.set", json!({"rightWidth":410.0}), std::time::Duration::from_secs(20));
assert_eq!(response["ok"], true, "{response}");
assert_eq!(h.app.ui.right_width, 410.0);
}
"panel.close" => {
h.app.run("panel.close", json!({})).unwrap();
h.step();
}
"public-action" => {
let action = craft_ui::docking::Action::Float { panel: Panel::Tools, rect: [800.0, 100.0, 330.0, 500.0] };
h.app.run("ui.dock", json!({"action":action})).unwrap();
h.step();
}
_ => panic!("unknown test route"),
}
let replacement = serde_json::to_value(&h.app.ui.docking).unwrap();
h.app.synthetic.push(Event::PointerMoved(from + egui::vec2(90.0, 0.0)));
h.step();
assert_eq!(serde_json::to_value(&h.app.ui.docking).unwrap(), replacement, "{route} cancels the previous divider gesture");
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap(), photos);
}
#[test]
fn actual_ui_set_width_replacement_cancels_held_divider() {
held_divider_replacement_route("ui.set");
}
#[test]
fn panel_close_sync_replacement_cancels_held_divider() {
held_divider_replacement_route("panel.close");
}
#[test]
fn externally_serialized_action_replacement_cancels_held_divider() {
held_divider_replacement_route("public-action");
}
+50
View File
@@ -0,0 +1,50 @@
//! #782: dialogs that only show something (System Info, All Metadata, What's New) end with one
//! Close, like About, instead of a Cancel / OK pair that both just close them.
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
use serde_json::json;
use std::time::Duration;
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(60);
fn has(h: &Headless, id: &str) -> bool {
h.app.widgets.iter().any(|(w, _)| w == id)
}
/// Open the dialog through its menu item; let the new window size itself before reading it.
fn open(h: &mut Headless, id: &str) {
let r = h.request("ui.menu.invoke", json!({"id": id}), T);
assert_eq!(r["ok"], true, "{id}: {r}");
assert!(h.app.ui.dialog.is_some(), "{id}: no dialog");
h.step();
h.step();
}
#[test]
fn information_dialogs_end_with_one_close() {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1300.0, 900.0], 1.0);
h.settle(SETTLE);
for id in ["app.systemInfo", "dialog.allMetadata", "app.whatsNew"] {
open(&mut h, id);
assert!(!has(&h, "button:dialogCancel"), "{id}: a Cancel button, though there is nothing to confirm");
assert!(has(&h, "button:dialogOk"), "{id}: no Close button");
let close = h.app.widgets.iter().filter(|(w, _)| w == "button:dialogOk").count();
assert_eq!(close, 1, "{id}: one footer button");
let text = h.painted_text();
assert!(text.iter().any(|t| t == "Close"), "{id}: the button reads Close: {text:?}");
assert!(!text.iter().any(|t| t == "Cancel" || t == "OK"), "{id}: no Cancel / OK: {text:?}");
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
assert_eq!(r["ok"], true, "{id}: {r}");
h.step();
assert!(h.app.ui.dialog.is_none(), "{id}: Close closes the dialog");
// Esc still closes it too
open(&mut h, id);
let r = h.request("ui.key", json!({"key": "Escape"}), T);
assert_eq!(r["ok"], true, "{id}: {r}");
h.step();
assert!(h.app.ui.dialog.is_none(), "{id}: Esc closes the dialog");
}
}
+96
View File
@@ -143,6 +143,16 @@ fn mask_adjustments_hide_overlay_without_disabling_the_mask() {
// Changing the shape needs the overlay; changing the image needs an unobscured preview.
exec(&mut h, "mask.refine", json!({"value": 10.0}));
assert!(h.app.ui.mask_overlay);
// The dock header pushes this adjustment below the initial viewport. Exercise the
// actual visible slider rather than sending a synthetic click outside its clipped body.
let panel = h.app.widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1;
let slider = h.app.widgets.iter().find(|(id, _)| id == "slider:exposure").unwrap().1;
assert!(!panel.contains_rect(slider), "exposure initially needs scrolling: {slider:?} in {panel:?}");
h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), T);
h.request("ui.scroll", json!({"dy": -160.0}), T);
h.settle(SETTLE);
let slider = h.app.widgets.iter().find(|(id, _)| id == "slider:exposure").unwrap().1;
assert!(panel.contains_rect(slider), "exposure is visible before clicking: {slider:?} in {panel:?}");
let r = h.request("ui.clickWidget", json!({"id": "slider:exposure", "fx": 0.7}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(!h.app.ui.mask_overlay, "the exposure slider hides the overlay after release");
@@ -298,6 +308,32 @@ fn mask_overlay_keys_and_pins() {
h.settle(SETTLE);
}
/// #518: holding the brush still, however long, paints the same feathered spot as a quick press.
#[test]
fn holding_the_brush_still_does_not_harden_the_spot() {
let mut h = detail("panel.masking");
let r = h.request("ui.set", json!({"brushSize": 0.1, "brushFeather": 80.0, "brushFlow": 100.0}), T);
assert_eq!(r["ok"], true, "{r}");
exec(&mut h, "tool.brush", json!({"new": true}));
// a quick press at the left, a long stationary hold (120 frames, ~2 s) at the right
brush_at(&mut h, 0.3, 0.5);
let mut hold = vec![json!({"kind": "down", "x": 0.7, "y": 0.5})];
hold.extend((0..120).map(|_| json!({"kind": "move", "x": 0.7, "y": 0.5})));
hold.push(json!({"kind": "up", "x": 0.7, "y": 0.5}));
pointer(&mut h, json!(hold));
let d = develop(&h);
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
assert_eq!(strokes.len(), 2, "{strokes:?}");
assert_eq!(strokes[1].points.len(), 1, "a stationary hold records one sample: {:?}", strokes[1].points);
// the same edge profile around both spots
for dx in [0usize, 4, 6, 8] {
let (quick, held) = (mask_coverage(&h, 0, 30 + dx, 50), mask_coverage(&h, 0, 70 + dx, 50));
assert!((quick - held).abs() < 0.01, "{dx} px from the centre: quick {quick}, held {held}");
}
assert!(mask_coverage(&h, 0, 70 + 6, 50) < 0.9, "the edge stays feathered");
h.settle(SETTLE);
}
#[test]
fn brush_strokes_carry_auto_mask() {
let mut h = detail("panel.masking");
@@ -1116,3 +1152,63 @@ fn ai_masks_without_the_model_offer_the_download() {
assert!(develop(&h).masks.is_empty());
assert!(t.elapsed() < SETTLE, "{:?}", t.elapsed());
}
fn color_samples(h: &Headless) -> serde_json::Value {
let d = develop(h);
let m = d.masks.last().expect("a mask");
let Some(MaskShape::ColorRange { samples, .. }) = m.components.iter().map(|c| &c.shape).find(|s| matches!(s, MaskShape::ColorRange { .. }))
else {
panic!("colour range")
};
serde_json::to_value(samples).expect("samples")
}
/// Same samples, up to sub-pixel rounding and the preview level sampled.
#[track_caller]
fn assert_close(a: &serde_json::Value, b: &serde_json::Value) {
let flat = |v: &serde_json::Value| -> Vec<f64> {
v.as_array().unwrap().iter().flat_map(|s| s.as_array().unwrap().iter().map(|x| x.as_f64().unwrap())).collect()
};
let (a, b) = (flat(a), flat(b));
assert_eq!(a.len(), b.len());
assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 0.05), "{a:?} vs {b:?}");
}
/// Regression: the Colour Range "Pick" button armed the eyedropper, but with the Masking panel
/// open the loupe's clicks went to the mask overlay, so no sample was ever taken.
#[test]
fn color_range_pick_button_samples_the_photo() {
let mut h = detail("panel.masking");
exec(&mut h, "mask.add", json!({"kind": "colorRange"}));
h.app.ui.tool.clear();
h.settle(SETTLE);
click(&mut h, "button:colorPick0");
assert_eq!(h.app.ui.tool, "colorRange", "Pick arms the eyedropper");
brush_at(&mut h, 0.25, 0.5);
let ui_sample = color_samples(&h);
assert_eq!(ui_sample.as_array().map(Vec::len), Some(1), "a click samples a colour");
// the sample is the colour at that normalized point
exec(&mut h, "mask.sampleColor", json!({"x": 0.25, "y": 0.5}));
assert_close(&color_samples(&h), &ui_sample);
// ⇧-click adds
let r = h.request("ui.pointer", json!({"shift": true, "events": [{"kind": "down", "x": 0.7, "y": 0.2}, {"kind": "up", "x": 0.7, "y": 0.2}]}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(color_samples(&h).as_array().map(Vec::len), Some(2), "⇧-click adds a sample");
// cropped and zoomed: the click samples the photo point under it
exec(&mut h, "crop.set", json!({"rect": [0.2, 0.1, 0.9, 0.8]}));
h.app.ui.zoom = crate::state::Zoom::Percent(200.0);
h.app.ui.pan = (0.5, 0.5);
h.settle(SETTLE);
brush_at(&mut h, 0.5, 0.5);
let ui_sample = color_samples(&h);
assert_eq!(ui_sample.as_array().map(Vec::len), Some(1));
// (the centre of the crop box, in normalized source coordinates)
let c = develop(&h).crop.geometry.rect;
exec(&mut h, "mask.sampleColor", json!({"x": (c.x0 + c.x1) / 2.0, "y": (c.y0 + c.y1) / 2.0}));
assert_close(&color_samples(&h), &ui_sample); // a cropped, zoomed pick maps to the same frame point
// Pick again ends picking
click(&mut h, "button:colorPick0");
assert!(h.app.ui.tool.is_empty());
h.settle(SETTLE);
}
+44
View File
@@ -0,0 +1,44 @@
//! Headless tests of the White Balance Selector's button.
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::state::ViewMode;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
/// The White Balance Selector button in the Edit panel does what W does: from Compare it opens
/// the active photo in Detail with the selector armed (it used to arm a tool Compare ignores).
/// Clicking it again disarms it.
#[test]
fn wb_selector_button_in_compare_opens_detail_like_w() {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
let vis: Vec<u64> = h.app.session.visible_cloned().iter().map(|p| p.0).collect();
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]], "active": vis[0]}}), T);
let r = h.request("engine.execute", json!({"command": "view.compare"}), T);
assert_eq!(r["ok"], true, "{r}");
h.app.ui.right = crate::state::RightPanel::Edit;
h.settle(SETTLE);
// open the Color section, where the selector sits
if !h.app.widgets.iter().any(|(w, _)| w == "icon:wbPicker") {
h.request("ui.clickWidget", json!({"id": "section:color"}), T);
h.settle(SETTLE);
}
assert_eq!(h.app.ui.view, ViewMode::Compare);
let active = h.app.session.selection.active;
let r = h.request("ui.clickWidget", json!({"id": "icon:wbPicker"}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert_eq!(h.app.ui.view, ViewMode::Detail, "the button leaves Compare like W");
assert_eq!(h.app.ui.tool, "wbPicker");
assert_eq!(h.app.session.selection.active, active, "on the photo that was active in Compare");
let r = h.request("ui.clickWidget", json!({"id": "icon:wbPicker"}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert_eq!(h.app.ui.tool, "", "a second click disarms it");
}
+39
View File
@@ -69,3 +69,42 @@ fn a_rotated_photo_at_100_is_its_pixel_size_swapped() {
let (w, hh) = at_100(&mut h);
assert!((w - nh).abs() < 1.5 && (hh - nw).abs() < 1.5, "{w}×{hh} for {nh}×{nw}");
}
/// Zoomed in, side by side and stacked Before / After views each stay in their pane: the margins
/// around the panes and the gap between them show the background, as at Fit. The whole-frame
/// renders used to be drawn unclipped, so the After's (magnified, blurred) covered the Before
/// pane's margins.
#[test]
fn zoomed_before_after_views_stay_in_their_panes() {
use crate::state::{BeforeAfter, Zoom};
let mut h = detail();
for mode in [BeforeAfter::SideBySide, BeforeAfter::TopBottom] {
let mut shots = Vec::new();
for zoom in [Zoom::Fit, Zoom::Percent(100.0)] {
h.app.ui.before_after = mode;
h.app.ui.zoom = zoom;
h.app.ui.pan = (0.3, 0.4);
h.settle(SETTLE);
let img = h.app.image_rect.expect("After view on screen");
let canvas = h.app.canvas_rect.expect("canvas");
shots.push((h.paint(), img, canvas));
}
let (zoomed, img, canvas) = &shots[1];
assert!(!canvas.contains_rect(*img), "{mode:?}: 100 % reaches past the pane ({img:?})");
// outside both panes: the canvas' edges and the gap between the panes
let c = canvas.center();
let points = [
egui::pos2(canvas.left() + 4.0, c.y),
egui::pos2(c.x, canvas.top() + 4.0),
egui::pos2(c.x, canvas.bottom() - 4.0),
match mode {
BeforeAfter::SideBySide => c,
_ => egui::pos2(canvas.left() + 200.0, c.y),
},
];
for q in points {
let px = |shot: &egui::ColorImage| shot[(q.x as usize, q.y as usize)];
assert_eq!(px(zoomed), px(&shots[0].0), "{mode:?}: background at {q:?}, zoomed in as at Fit");
}
}
}
+17 -1
View File
@@ -194,7 +194,9 @@ impl<'a> TextField<'a> {
let right = ui.input(|i| i.pointer.button_down(egui::PointerButton::Secondary) || i.pointer.button_released(egui::PointerButton::Secondary));
let selection = egui::text_edit::TextEditState::load(ui.ctx(), id).and_then(|s| s.cursor.char_range());
let mut response = ui.add(edit);
if right && response.contains_pointer() {
// The menu may take focus on a primary-button release. egui collapses a
// TextEdit selection when focus leaves; retain the range our own menu acts on.
if right && response.contains_pointer() || memo.menu_open && !ui.memory(|m| m.has_focus(id)) {
let mut state = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default();
state.cursor.set_char_range(selection);
state.store(ui.ctx(), id);
@@ -514,6 +516,20 @@ mod tests {
assert_eq!(rig.text, "Kyoto");
}
#[test]
fn clicking_back_into_field_dismisses_its_menu_and_places_a_new_caret() {
let mut rig = Rig::new("Lisbon");
rig.select_all_by_keys();
rig.click(FIELD, PointerButton::Secondary);
let rect = rig.rect(FIELD).unwrap();
rig.click_at(egui::pos2(rect.left() + 2.0, rect.center().y), PointerButton::Primary);
let range = egui::text_edit::TextEditState::load(&rig.view.ctx, id(FIELD)).unwrap().cursor.char_range().unwrap();
assert!(range.is_empty(), "a click back in the field places a caret: {range:?}");
rig.type_text("X");
assert_eq!(rig.text, "XLisbon");
assert!(rig.endings.is_empty(), "dismissing into the field keeps the edit open");
}
/// With nothing selected, Cut and Copy are greyed out: clicking one does nothing, as in a native
/// menu, and neither closes the menu nor ends the edit.
#[test]
+5 -12
View File
@@ -237,18 +237,11 @@ pub fn apply(ctx: &egui::Context) {
v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(140) };
v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 30, spread: 0, color: Color32::from_black_alpha(160) };
let w = &mut v.widgets;
for (wv, fill) in [
(&mut w.noninteractive, t.chrome),
(&mut w.inactive, t.button),
(&mut w.hovered, t.hover),
(&mut w.active, t.pressed),
(&mut w.open, t.hover),
] {
wv.bg_fill = fill;
wv.weak_bg_fill = fill;
wv.corner_radius = CornerRadius::same(4);
wv.fg_stroke = Stroke::new(1.0, t.text_label);
}
craft_ui::theme::WidgetPalette { noninteractive: t.chrome, inactive: t.button, hovered: t.hover, active: t.pressed, open: t.hover }.apply_to(
w,
CornerRadius::same(4),
Stroke::new(1.0, t.text_label),
);
w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider);
w.inactive.bg_stroke = Stroke::new(1.0, t.button_border);
ctx.set_visuals(v);
+126
View File
@@ -0,0 +1,126 @@
//! Real library/tool panel moves preserve the selected tool and saved arrangement.
use egui::{Event, Modifiers, PointerButton, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use lightcraft_ui_egui::{LightcraftApp, Services, UiState, docking::Panel, state::RightPanel};
use serde_json::json;
use std::time::{Duration, Instant};
fn harness(state: Option<UiState>, width: f32, scale: f32) -> Harness<'static, LightcraftApp> {
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
if let Some(state) = state {
app.ui = state;
} else {
app.ui.left_panel = true;
app.ui.right = RightPanel::Edit;
}
let mut h = Harness::builder().with_size(vec2(width, 900.0)).with_pixels_per_point(scale).build_ui_state(
|ui, app: &mut LightcraftApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
app,
);
h.run_steps(8);
h
}
fn drag(h: &mut Harness<'_, LightcraftApp>, from: egui::Pos2, to: egui::Pos2) {
h.event(Event::PointerMoved(from));
h.run_steps(2);
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(2);
for step in 1..=8 {
h.event(Event::PointerMoved(from.lerp(to, step as f32 / 8.0)));
h.run_steps(1);
}
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(4);
}
fn settle_photo(h: &mut Harness<'_, LightcraftApp>) {
let started = Instant::now();
let mut quiet = 0;
while quiet < 4 {
h.run_steps(2);
if h.state().renderer.in_flight() == 0 && h.state().loupe_shown.is_some_and(|(_, source)| source == "render") {
quiet += 1;
} else {
quiet = 0;
}
assert!(started.elapsed() < Duration::from_secs(30), "the synthetic photo and panel thumbnails must finish rendering before capture");
std::thread::sleep(Duration::from_millis(5));
}
}
#[test]
fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() {
let mut h = harness(None, 1400.0, 1.0);
let source = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").rect().center();
let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Library").rect().center();
drag(&mut h, source, target);
assert_eq!(h.state().ui.docking.as_ref().unwrap().layout.location(&Panel::Tools).unwrap().anchor, Some(Panel::Library));
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").click_button(PointerButton::Secondary);
h.run_steps(3);
h.get_by_label("Float panel").click();
h.run_steps(5);
let rect = h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect;
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").click_button(PointerButton::Secondary);
h.run_steps(3);
h.get_by_label("Close panel").click();
h.run_steps(4);
assert_eq!(h.state().ui.right, RightPanel::None);
h.state_mut().ui.right = RightPanel::Crop;
h.run_steps(4);
assert_eq!(h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect, rect);
let saved = serde_json::to_string(&h.state().ui).unwrap();
let expected = serde_json::to_value(&h.state().ui.docking).unwrap();
let mut reloaded = harness(Some(serde_json::from_str(&saved).unwrap()), 1400.0, 1.0);
assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), expected);
assert_eq!(reloaded.state().ui.right, RightPanel::Crop);
reloaded.state_mut().run("ui.dock", json!({"operation":"move","panel":"tools","target":"library","zone":"center"})).unwrap();
reloaded.run_steps(4);
assert_eq!(reloaded.state().ui.right, RightPanel::Crop);
reloaded.state_mut().run("ui.dock", json!({"operation":"close","panel":"library"})).unwrap();
reloaded.run_steps(3);
assert!(!reloaded.state().ui.left_panel);
assert_eq!(reloaded.state().ui.right, RightPanel::Crop);
}
#[test]
fn invalid_docking_requests_preserve_layout_and_selected_tool() {
let mut h = harness(None, 1400.0, 1.0);
let before = serde_json::to_value(&h.state().ui.docking).unwrap();
for params in [
json!({"operation":"close","panel":"canvas"}),
json!({"operation":"move","panel":"tools","target":"canvas","zone":"center"}),
json!({"operation":"float","panel":"tools","rect":[0,0,-1,100]}),
json!({"operation":"move","panel":"unknown","target":"library","zone":"center"}),
] {
assert!(h.state_mut().run("ui.dock", params).is_err());
assert_eq!(serde_json::to_value(&h.state().ui.docking).unwrap(), before);
assert_eq!(h.state().ui.right, RightPanel::Edit);
}
}
#[test]
fn docking_renders_real_photo_panels_default_floating_and_redocked() {
for width in [900.0, 1400.0] {
for scale in [1.0, 2.0] {
let mut h = harness(None, width, scale);
for state in ["default", "floating", "redocked"] {
if state == "floating" {
h.state_mut().run("ui.dock", json!({"operation":"float","panel":"tools","rect":[120,90,320,600]})).unwrap();
} else if state == "redocked" {
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"tools","target":"library","zone":"center"})).unwrap();
}
h.run_steps(8);
settle_photo(&mut h);
h.state().ui.docking.as_ref().unwrap().validate().unwrap();
if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") {
std::fs::create_dir_all(&directory).unwrap();
h.render().unwrap().save(format!("{directory}/light-{width}-{scale}-{state}.png")).unwrap();
}
}
}
}
}
+47
View File
@@ -22,6 +22,34 @@ that hit an error reply should reconnect. The port has no authentication, so onl
mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
`crates/ui-egui/src/control.rs` (methods) and `apps/lightcraft/src/control_server.rs` (transport).
## Isolated automation profiles
Set `LIGHTCRAFT_CONFIG_DIR` to a fresh directory to isolate UI preferences, app logs, the emergency
panic log, the GPU initialization marker, camera profiles and default model folders. The nonempty
value is used as the complete settings directory. Unset retains the normal platform paths; an explicit
empty value disables the settings directory, while the emergency panic log retains its system-temp path.
Explicit model-directory environment variables still take precedence over their defaults.
Also pass `--library DIR` with a separate scratch library: the configuration override does not change
the default photo-library location. Do not use `--memory` or `LIGHTCRAFT_NO_PREFS` when testing persistence.
A native acceptance run must reject the stderr message `rendering headlessly`: `ui.screenshot`
automatically uses the CPU fallback after two seconds without a presented frame. That fallback is useful
for headless scripts, but cannot establish native-window acceptance. Control input injects egui events;
it does not establish operating-system input delivery or screen-reader acceptance.
On macOS, the native menu owns its keyboard accelerators. `ui.key` injects egui key events and
does not synthesize the Cocoa menu events for those shortcuts. Use `ui.menu.invoke` with the
command ID (for example `edit.undo`, `edit.redo`, `view.filmstrip` or `view.leftPanel`) to test
their command behavior. That API invokes the app command dispatcher directly; it does not traverse
Cocoa menu dispatch. Native menu activation and keyboard accelerator delivery require a separate test.
Before capturing a finished Detail view, poll `ui.inspect` until the expected `loupe.photo` is active,
`loupe.source` is `render` or `cached`, `loupe.pending` is false, and the render queues are empty.
For the loupe, `cached` is a prior view render keyed by the current photo and develop settings;
`thumb`, `small` and `embedded` are separate fallback sources. A presented frame can otherwise
contain an in-progress rendering placeholder. Require loaded thumbnail textures only when the
filmstrip is visible.
## Methods
| Method | Params | Result |
@@ -127,3 +155,22 @@ one pixel.
renders are in flight. Each request runs frames until it is answered and its injected input
(clicks, keys, drags) has played out. Widget ids for `ui.clickWidget` come from `ui.widgets`
(e.g. `button:upright-auto`). A 1600×1000 demo snapshot takes ~1–2 s (debug build).
## Panel docking
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents.
Panel IDs: `library`, `tools`, `presets`. The protected center is `canvas`.
`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tools","rect":[100,100,320,440]}}`.
Advanced `ui.dock` operations use the same validated transaction as pointer gestures:
`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null.
A center `move` can include `before` (panel ID or null to append) for tab order.
The equivalent `{"action": ...}` form accepts a serialized shared docking action;
this is also the form emitted by the renderer. Protected-canvas and app-specific panel
rules apply equally to both forms.
+7 -1
View File
@@ -11,6 +11,12 @@ recommended crop, and the decoder uses it as the default crop, so the developed
framed (PowerShot SX70 HS, EOS 250D, PowerShot G5 X Mark II, EOS M6 Mark II and EOS M50 samples). A rectangle that
is missing or does not fit inside the crop is ignored. The embedded JPEG still shows the whole frame.
The EOS R5 Mark II's APS-C crop-mode files (5376 x 3574) state an active area that starts in dark columns
(132..~256 sit at the black level) and stops ~130 columns short of the image, while their recommended crop
(5088 x 3392, the camera JPEG's size) lies on the image. When the recommended crop doesn't fit inside the stated
active area, it is used as the valid area: on raw.pixls.us 7883/7884 the render matches the camera JPEG
(high-pass correlation 0.99, was -0.13 with a black band on the left). No other of the 110 CC0 CR3s changes.
The decoder is independent Rust code shared by desktop, CLI and WebAssembly;
it has no operating-system imaging dependency or external decoder. Its sources
are the public prose descriptions and patents cited in
@@ -26,7 +32,7 @@ decoded.
CRX version `0x100`, four Bayer planes and `ff01`/`ff02`/`ff03` marker headers
support lossless RAW and three-level C-RAW with horizontal tiles. Uniform
quantization values 4–43 are independently verified. Version `0x200` C-RAW
quantization values 4–43 are independently verified; 44 continues the sequence (step 102, checked against a measured step on an EOS R50 C-RAW file). Version `0x200` C-RAW
supports the `ff11`/`ff12`/`ff13` marker family, a single 14-bit Bayer tile and
its adaptive quantization map. C-RAW is quantized, so next to clipped highlights
its reconstruction can overshoot the sensor range by a few quantization steps
+1 -1
View File
@@ -14,7 +14,7 @@ LightCraft 的界面支持英语、简体中文、繁体中文(台湾)和日
- 尚未翻译的技术性错误、补充说明和发行说明仍以英文显示。
- 网页版将 Noto Sans CJK SC 作为独立的 `lightcraft_zh_hans.otf` 文件随站点分发,页面启动时加载;
这样可显示简体中文,也避免把约 16 MB 字体放进受单文件大小限制的 WASM。部署时需上传整个
`target/web/` 目录。未指定 `CRAFT_FONTS_DIR` 的开发构建仍缺少中文字体。
`target/web-site/` 目录。未指定 `CRAFT_FONTS_DIR` 的开发构建仍缺少中文字体。
- 日期分组标题与拍摄时间按中文格式显示(如 `2026年9月20日 星期日`)。
## 翻译的维护
+13 -13
View File
@@ -77,11 +77,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry |
| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it |
| LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | |
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Line truncated
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535; pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. |
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535; pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). |
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving |
| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) |
@@ -130,8 +130,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid |
| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render |
| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans |
| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session |
| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle |
| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it |
| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; no zoom-link toggle |
| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes |
| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) |
| LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (`interval`), wrapping; Space pauses, ←/→ step, Esc ends |
@@ -188,7 +188,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-EDIT-LIGHT-CURVE-REFINESAT | Curve saturation compensation | P1 | ✅ | `ctl:curve.refineSaturation` | |
| LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | |
| LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | |
| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick` | no magnified loupe while picking |
| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking |
| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`), `crates/raw/src/color.rs` (`rebalance`) | relative scale for non-raw in the UI; raws with a colour matrix (DNG and other raws without a file-local look) are re-developed for the chosen white through the camera's own matrices (camera-space white balance, as Lightroom), and the eyedropper / Auto invert the same model; other photos are adapted (Bradford) |
| LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests |
| LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | |
@@ -246,11 +246,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|---|---|---|---|---|---|
| LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return) |
| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot) |
| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so) |
| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so) |
| LR-CROP-AUTO | Auto straighten | P1 | ✅ | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required) |
| LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN) |
| LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | |
| LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | |
| LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical`, `crates/pipeline/src/geometry.rs` | the crop tool's uncropped view shows the flips too, with the crop box and its dimming mirrored to match |
| LR-CROP-OVERLAY | Crop overlays | P1 | ✅ | `cmd:view.cropOverlay`, `cmd:view.cropOverlayOrientation`, `crates/ui-egui/src/panels/crop_overlay.rs` | thirds, grid, golden ratio, diagonal, triangle, golden spiral (mirrored with ⇧O while cropping); no aspect-ratio overlays |
| LR-CROP-ZOOM | Zoom while cropping | P1 | ✅ | `cmd:view.zoom100`, `cmd:view.zoomIn`, `crates/ui-egui/src/panels/detail.rs` | zoom levels apply with the crop tool open (verified: 100% shows the photo at native size with the crop frame) |
| LR-CROP-GENEXPAND | Generative expand | OOS | 🚫 | | |
@@ -290,7 +290,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, shows the model folder and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) |
| LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic |
| LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty |
| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; live-preview gaps (#517) and stationary accumulation (#518) remain; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure |
| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; coverage builds up by passes, not hold time (holding the brush still adds nothing, #518); live-preview gaps (#517) remain; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure |
| LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | |
| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested |
| LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in), Pick button, Refine slider |
@@ -506,8 +506,8 @@ visible menu bar yet: the menu model is only exposed through the control channel
| MENU-EDIT-UNDO | Undo | P0 | ✅ | `cmd:edit.undo` | label does not name the step |
| MENU-EDIT-REDO | Redo | P0 | ✅ | `cmd:edit.redo` | |
| MENU-EDIT-COPYPASTE | Copy / paste (edit settings) | P0 | ✅ | `cmd:develop.copy`, `cmd:develop.paste` | |
| MENU-EDIT-CHOOSECOPY | Choose Edit Settings to Copy… | P0 | ✅ | `cmd:dialog.copySettings` | |
| MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ✅ | `cmd:dialog.pasteSettings`, `cmd:develop.paste` (`groups`) | checklist prefilled with the copied groups; only copied groups can be pasted |
| MENU-EDIT-CHOOSECOPY | Choose Edit Settings to Copy… | P0 | ✅ | `cmd:dialog.copySettings` | Return copies, Esc cancels |
| MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ✅ | `cmd:dialog.pasteSettings`, `cmd:develop.paste` (`groups`) | checklist prefilled with the copied groups; only copied groups can be pasted; Return pastes, Esc cancels |
| MENU-EDIT-SELECTALL | Select All | P0 | ✅ | `cmd:library.selectAll` | |
| MENU-EDIT-SELECTNONE | Select None | P0 | ✅ | `cmd:library.selectNone` | |
| MENU-EDIT-SELECTBY | Select by flag / rating | P1 | ✅ | `cmd:library.selectBy` | Edit → Select by: picks, rejects, unflagged, ★…★★★★★ and higher, unrated, colour labels; `add` extends the selection |
@@ -565,7 +565,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
| MENU-HELP-SHORTCUTS | Keyboard shortcuts | P1 | ✅ | `cmd:app.shortcuts` | |
| MENU-HELP-FEEDBACK | Send feedback | P2 | ✅ | `cmd:app.feedback` | opens a new issue on the project's GitHub |
| MENU-HELP-LOGFOLDER | Open log folder | P2 | ✅ | `cmd:app.openLogFolder` | an extra (the reference app has no such item): Help ▸ Open Log Folder reveals `logs/lightcraft.log` in the system file manager (Finder, Explorer, or the folder on Linux); off on the web and in `--memory` sessions, which keep no log (issue #260) |
| MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; JSON for agents (`open: false`) |
| MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; one Close button, like About (#782); JSON for agents (`open: false`) |
| MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete (restore / delete permanently in Recently Deleted) |
| MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | `crates/ui-egui/src/panels/detail.rs` | Zoom submenu (fit, 100%, in, out), then the photo menu |
| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), New ▸ Create Album / Smart Album / Smart Album from Filter / Folder (on a folder: made inside it), move to a folder or the top level, rename, delete; smart albums: update rules |
@@ -660,7 +660,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
| KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | |
| KEY-HELP | Help — F1 | P2 | ✅ | `cmd:app.help` | |
| KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | |
| KEY-ESC | Leave tool / view — Esc | P0 | ✅ | `cmd:view.back`, `crates/ui-egui/src/icons.rs` (`navigation_arrowheads_have_both_halves`) | top-bar navigation arrowheads render both halves at 1× and 2× scale |
| KEY-ESC | Leave tool / view — Esc | P0 | ✅ | `cmd:view.back`, `crates/ui-egui/src/icons.rs` (`navigation_arrowheads_have_both_halves`), `crates/ui-egui/src/tests_crop_tool.rs` | top-bar navigation arrowheads render both halves at 1× and 2× scale; in the crop tool Esc closes the tool first (the crop stays), the next Esc goes back to the grid; Esc that ends an edit in a text field does nothing else |
| KEY-COMMIT | Commit tool — Return | P1 | ✅ | `cmd:tool.done` | Return closes Crop / Remove / Red Eye / Masking back to Edit (edits apply live, as in Lightroom) |
| KEY-DELETE-PIN | Delete selected pin — ⌫ | P0 | ✅ | `cmd:mask.delete`, `cmd:spot.delete` | ⌫ deletes the active mask (Masking) or the selected spot (Remove), never the photo while retouching |
| KEY-HIDEPINS | Hide pins — H | P2 | ⬜ | | H = Remove panel |
@@ -675,7 +675,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ✅ | `cmd:library.autoImport` | see LR-IMP-AUTO |
| LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | |
| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info); no people view |
| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; zoom always linked |
| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; a filmstrip click replaces the active side; zoom always linked |
| LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N |
| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo |
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) |
+1 -1
View File
@@ -18,7 +18,7 @@ it's missing or a different version.
Build, then serve:
```sh
cargo xtask web # → <target>/web/{index.html, worker.js, lightcraft_web.js, lightcraft_web_bg.wasm}
cargo xtask web # → <target>/web-site/{index.html, worker.js, lightcraft_web.js, lightcraft_web_bg.wasm}
cargo xtask web --serve # build, then serve on http://127.0.0.1:8080/ (or `--serve 9000`)
cargo xtask web --dev # unoptimized build with debug info (faster to compile, slow to run)
```
+6
View File
@@ -97,6 +97,10 @@
Selected Settings). Ctrl+V only reaches LightCraft while the system clipboard holds text; otherwise use Edit ▸ Paste Edit
Settings.
### Dialogs
- System Info, All Metadata and What's New end with one Close button, like About, instead of Cancel and OK
(issue #782).
### Synchronize Folder
- Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Synchronize
Folder…. The folder and the folders inside it are scanned in the background for photos added on disk, photos whose
@@ -161,6 +165,8 @@
- Shortcuts are editable: Help ▸ Keyboard Shortcuts (⌘/) lists every command with a search box; click a shortcut and
press the new keys (Esc cancels), × removes it, ↺ restores the original, Reset All undoes every change. A key that
belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`.
- Return presses OK in Choose Edit Settings to Copy (⇧⌘C) and Paste Selected Settings (⇧⌘V), and Esc cancels them
(issue #735).
### Formats
- HEIC / HEIF photos (iPhone and Mac) open now: the optional `lightcraft-heif` crate (heic-rs, pure Rust) behind
+11 -1
View File
@@ -15,8 +15,18 @@ if [ "${1:-}" != "--skip-build" ]; then
(cd "$ROOT" && cargo xtask web)
fi
SITE="$CARGO_TARGET_DIR/web"
SITE="$CARGO_TARGET_DIR/web-site"
[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; }
# Guard against Cargo build artifacts leaking into the release (issue #793): cross-compiling writes
# the selected profile's *host* artifacts (build scripts, proc-macros, deps/, build/, .fingerprint/)
# to <target>/<profile>/, so if SITE ever points at a profile directory again the zip would carry
# ~113 MB of build output. Fail loudly instead.
for d in deps build .fingerprint; do
if [ -e "$SITE/$d" ]; then
echo "error: $SITE/$d exists — $SITE holds Cargo build artifacts, not just the site (issue #793)" >&2
exit 1
fi
done
# Paths must be relative so the site works under any prefix.
if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then
echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2
+2 -2
View File
@@ -90,7 +90,7 @@ fn intra_layer_allowed(from: &str, to: &str) -> bool {
/// External crates that constitute a UI toolkit / windowing dependency.
/// Entries ending in `*` are prefixes.
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
pub const UI_CRATES: &[&str] = &["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
/// First layer allowed to use UI crates.
pub const UI_MIN_LAYER: u8 = 5;
@@ -291,7 +291,7 @@ mod tests {
#[test]
fn ui_crates_forbidden_below_l5() {
for dep in ["egui", "eframe", "winit", "rfd"] {
for dep in ["craft-ui", "egui", "eframe", "winit", "rfd"] {
let v = check(&[c("lightcraft-engine", &[(dep, Normal, false)])]);
assert!(matches!(v[..], [Violation::UiBelowL5 { .. }]), "{dep}");
}
+1 -1
View File
@@ -31,7 +31,7 @@ commands:
Lightroom parity summary; --write refreshes the summary table in the document
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (+ the web app)
web [--serve [port]] [--dev]
build the browser app (apps/lightcraft-web) into <target>/web/;
build the browser app (apps/lightcraft-web) into <target>/web-site/;
--serve serves it on http://127.0.0.1:<port> (default 8080)
ci fmt --check, clippy -D warnings, heif, test, parity refs, layers, assets, wasm (stops at first failure)
corpus [--download]
+25 -3
View File
@@ -1,5 +1,5 @@
//! `cargo xtask web`: build `apps/lightcraft-web` for wasm32 and bundle it with `wasm-bindgen`
//! into `<target>/web/` (index.html + worker.js + lightcraft_web.js + lightcraft_web_bg.wasm),
//! into `<target>/web-site/` (index.html + worker.js + lightcraft_web.js + lightcraft_web_bg.wasm),
//! with gzip and brotli precompressed copies (`*.gz`, `*.br`) next to each file.
//! `--serve [port]` then serves that folder with a tiny static HTTP server (std only) that sends
//! the cross-origin isolation headers (COOP/COEP) and the precompressed files when the browser
@@ -13,6 +13,13 @@ use std::process::Command;
use crate::{cargo, metadata, root, run as step};
const TARGET: &str = "wasm32-unknown-unknown";
/// Cargo profile for the optimized (`--dev` off) browser build; see `[profile.web]` in `Cargo.toml`.
const WEB_PROFILE: &str = "web";
/// Directory under `<target>` that receives the bundle. It must **not** be a Cargo profile name:
/// cross-compiling with `--target wasm32-unknown-unknown` makes Cargo write the selected profile's
/// *host* artifacts (build scripts, proc-macros, `deps/`, `build/`, `.fingerprint/`) to
/// `<target>/<profile>/`, and `packaging/web/package.sh` zips the whole bundle directory (issue #793).
const SITE_DIR: &str = "web-site";
const CHINESE_FONT_FILE: &str = "lightcraft_zh_hans.otf";
const CHINESE_FONT_LICENSE_FILE: &str = "OFL-noto-sans-cjk-sc.txt";
@@ -100,14 +107,14 @@ pub fn run(args: &[&str]) -> Result<(), String> {
check_bindgen(&want)?;
let meta = metadata()?;
let target_dir = PathBuf::from(meta["target_directory"].as_str().ok_or("cargo metadata: no target_directory")?);
let profile = if dev { "dev" } else { "web" };
let profile = if dev { "dev" } else { WEB_PROFILE };
let mut c = cargo();
c.args(["build", "-p", "lightcraft-web", "--lib", "--target", TARGET, "--profile", profile]);
step(c, &format!("cargo build -p lightcraft-web --target {TARGET} --profile {profile}"))?;
let wasm = target_dir.join(TARGET).join(if dev { "debug" } else { profile }).join("lightcraft_web.wasm");
let out = target_dir.join("web");
let out = target_dir.join(SITE_DIR);
std::fs::create_dir_all(&out).map_err(|e| format!("{}: {e}", out.display()))?;
let mut b = Command::new("wasm-bindgen");
b.arg(&wasm).args(["--target", "web", "--no-typescript", "--out-name", "lightcraft_web", "--out-dir"]).arg(&out);
@@ -287,6 +294,21 @@ mod tests {
assert!(v.starts_with("0.2."), "{v}");
}
#[test]
fn site_dir_is_not_a_cargo_profile() {
// Cross-compiling writes the selected profile's *host* artifacts (build scripts,
// proc-macros, `deps/`, `build/`, `.fingerprint/`) to `<target>/<profile>/`, so the bundle
// must not live in a profile directory or `packaging/web/package.sh` would zip them
// (issue #793).
let manifest = std::fs::read_to_string(root().join("Cargo.toml")).unwrap();
let is_profile = |name: &str| {
let header = format!("[profile.{name}]");
manifest.lines().any(|l| l.trim() == header)
};
assert!(is_profile(WEB_PROFILE), "`{WEB_PROFILE}` is no longer a profile in Cargo.toml");
assert!(!is_profile(SITE_DIR), "`{SITE_DIR}` is a Cargo profile name; pick a different bundle directory");
}
#[test]
fn wasm_mime() {
assert_eq!(mime(Path::new("a/lightcraft_web_bg.wasm")), "application/wasm");