215 Commits
Author SHA1 Message Date
ZacharyZhang-NY 9431580d7d 14.32 passes review 2026-10-11 17:49:05 -04:00
ZacharyZhang-NY d9d7a174e4 14.32: no row waits now 2026-10-11 17:42:29 -04:00
ZacharyZhang-NY 9c054c4b50 14.32: v1.4.9 released on Gitea and installed live on every system 2026-10-11 17:42:23 -04:00
ZacharyZhang-NY 9bff27540c 14.31 passes review 2026-10-11 17:30:16 -04:00
ZacharyZhang-NY fa72900e2e 14.31 round 2: nine entries 2026-10-11 17:29:57 -04:00
ZacharyZhang-NY 0a90703fa7 14.31 round 1: screenshots after the WebP import 2026-10-11 17:26:39 -04:00
ZacharyZhang-NY 8fc71fbbce 14.31 round 1: the acceptance scenarios check what each row claims; WebP in the window 2026-10-11 17:22:28 -04:00
ZacharyZhang-NY 07e548fe31 14.31: the acceptance table's systems 2026-10-11 17:14:24 -04:00
ZacharyZhang-NY 6e99067754 14.31: the presets menu waited for, as Qt 6.12 shows it later 2026-10-11 17:06:06 -04:00
ZacharyZhang-NY d4113bd50e 14.31: the last two drag events through DragAt 2026-10-11 16:56:06 -04:00
ZacharyZhang-NY 3fa7ffcc13 14.31: drag events built for Qt 6.12 and 6.4 alike 2026-10-11 16:52:23 -04:00
ZacharyZhang-NY 3762166f09 14.31: PSD adjustments read words byte by byte (GCC 16.2's false array-bounds) 2026-10-11 16:12:16 -04:00
ZacharyZhang-NY 462b8af18e 14.31: the acceptance run for 1.4.6 to 1.4.9 2026-10-11 16:08:53 -04:00
ZacharyZhang-NY 3c03ec702f 14.30 passes review 2026-10-11 16:04:27 -04:00
ZacharyZhang-NY f238ac43fd 14.30 round 1: millimetres named 2026-10-11 16:04:00 -04:00
ZacharyZhang-NY 3829131035 14.30: version 1.4.9, README, change log 2026-10-11 16:01:28 -04:00
ZacharyZhang-NY 9f964f1af1 14.29 passes review 2026-10-11 15:56:15 -04:00
ZacharyZhang-NY 10f51cdf72 14.29: the blank start tab stays put; Untitled numbers stay low 2026-10-11 15:52:24 -04:00
ZacharyZhang-NY 1e647a9b74 14.28 passes review 2026-10-11 15:42:28 -04:00
ZacharyZhang-NY 22bd52e8ae 14.28 round 1: the cache, the job's brightness and the scope's bytes pinned 2026-10-11 15:39:40 -04:00
ZacharyZhang-NY 6dc0ffbfa3 14.28: Camera Raw's preview 5 to 20 times faster 2026-10-11 15:29:31 -04:00
ZacharyZhang-NY 817b32b4aa 14.27 passes review 2026-10-11 14:27:47 -04:00
ZacharyZhang-NY a27e051b10 14.27 round 3: Green Saturation's binding pinned 2026-10-11 14:26:41 -04:00
ZacharyZhang-NY ccc859ae41 14.27 round 2: every Calibration binding and Balance's shares pinned 2026-10-11 14:23:20 -04:00
ZacharyZhang-NY e45897f4f9 14.27 round 1: Blending 0, Balance, luminance and order pinned; dead serializers gone 2026-10-11 14:18:07 -04:00
ZacharyZhang-NY 9c0a23ea7e 14.27: Camera Raw's Color Mixer, Calibration, curve, vignettes and Color Grading match Photoshop 2026-10-11 14:05:24 -04:00
ZacharyZhang-NY 8b7a8ffa0e 14.26 passes review 2026-10-11 13:44:41 -04:00
ZacharyZhang-NY 8fca6f2a2e 14.26 round 1: measures, Highlights alone, order and sharpening pinned 2026-10-11 13:43:01 -04:00
ZacharyZhang-NY 32f6362787 14.26: adaptive Contrast, Highlights and Shadows; Dehaze, Texture, Clarity and Sharpening 2026-10-11 13:33:45 -04:00
ZacharyZhang-NY a1453df517 14.25 passes review 2026-10-11 13:22:15 -04:00
ZacharyZhang-NY 141a174cac 14.25 round 2: temperature's edge, each channel missing, translucent exposure 2026-10-11 13:21:05 -04:00
ZacharyZhang-NY f0fe272038 14.25 round 1: the search's edges and ties, composition order, a dark Auto 2026-10-11 13:13:42 -04:00
ZacharyZhang-NY efadd6f7c6 14.25: Camera Raw's Light and Color sliders match Photoshop's 2026-10-11 13:03:24 -04:00
ZacharyZhang-NY 15bb440bc9 14.24 passes review 2026-10-11 12:49:22 -04:00
ZacharyZhang-NY aa90a3e934 14.24 round 3: the magnifier beside a Hand drag 2026-10-11 12:48:21 -04:00
ZacharyZhang-NY d9cdac38e7 14.24 round 2: the zoom moves beside the other drags 2026-10-11 12:45:46 -04:00
ZacharyZhang-NY 15df492723 14.24 round 1: a zoom from 75%, and drags beside it 2026-10-11 12:39:35 -04:00
ZacharyZhang-NY 4d70d37b30 14.24: Ctrl or Meta with a middle drag zooms 2026-10-11 12:34:44 -04:00
ZacharyZhang-NY 1323315b0c 14.23 passes review 2026-10-11 12:25:45 -04:00
ZacharyZhang-NY 6d88770310 14.23 round 2: no stale picture count 2026-10-11 12:24:59 -04:00
ZacharyZhang-NY df04daa07c 14.23 round 1: the importer's contract names WebP 2026-10-11 12:23:56 -04:00
ZacharyZhang-NY f7498108c5 14.23: WebP import 2026-10-11 12:14:13 -04:00
ZacharyZhang-NY b10a9c5b71 14.22 passes review 2026-10-11 12:09:05 -04:00
ZacharyZhang-NY 874958f377 14.22 round 1: every limit reached, optionals read checked 2026-10-11 12:07:59 -04:00
ZacharyZhang-NY e8b74c6e47 14.22: effects, text and shapes outlast size changes and adjustments 2026-10-11 12:03:49 -04:00
ZacharyZhang-NY bafb07978a 14.21 passes review 2026-10-11 11:52:13 -04:00
ZacharyZhang-NY d1fc510e78 14.21 round 1: the paint counter watches from before the show 2026-10-11 11:50:57 -04:00
ZacharyZhang-NY 1ef841a77c 14.21: the slider snaps before it ever paints 2026-10-11 11:47:08 -04:00
ZacharyZhang-NY 0dc24419d4 14.20 passes review 2026-10-11 11:46:37 -04:00
ZacharyZhang-NY da5967f396 14.20 round 2: blurs by the width's factor and centring at scale two tested 2026-10-11 11:45:39 -04:00
ZacharyZhang-NY fc56a3801f 14.20 round 1: the whole picture guarded, origins, streaks and the restarted wait tested 2026-10-11 11:33:46 -04:00
ZacharyZhang-NY 085b4dac6e 14.20: the Navigator minimap from 300% 2026-10-11 11:22:15 -04:00
ZacharyZhang-NY bd4602fed9 14.19 passes review 2026-10-11 10:54:08 -04:00
ZacharyZhang-NY 65bb367e34 14.19 round 1: Scanlines normalized with the filter, Two Colors rounded, sheet tests apart 2026-10-11 10:53:13 -04:00
ZacharyZhang-NY 1a129b9a23 14.19: Filter › Scanlines, its own filter, on Swift's cdde327 kernel 2026-10-11 10:46:16 -04:00
ZacharyZhang-NY d06fb55bc4 14.18 passes review 2026-10-11 10:26:13 -04:00
ZacharyZhang-NY e8c5c99e3c 14.18 round 4: exponent zeros and supplementary digits; ICU's fraction rounding documented 2026-10-11 10:23:37 -04:00
ZacharyZhang-NY c93f6dd96e 14.18 round 3: encoding's door turns bad_alloc into a render error, sizes parse exactly 2026-10-11 10:14:37 -04:00
ZacharyZhang-NY b66ef3c8ea 14.18 round 2: size fields read Swift's Int field, the mask and a tall page tested 2026-10-11 09:58:21 -04:00
ZacharyZhang-NY cd31454815 14.18 round 1: a starved PDF throws, an early zoom centres, inches round to even, tests tightened 2026-10-11 09:38:52 -04:00
ZacharyZhang-NY 9be01073df 14.18: Export As for PNG, JPEG and PDF, sized, over a checkerboard 2026-10-11 09:10:51 -04:00
ZacharyZhang-NY e6b7f043bb 14.17 passes review 2026-10-11 08:40:39 -04:00
ZacharyZhang-NY dedecb88bd 14.17 round 1: effect eyes keep the gate; the adjustment menu tested 2026-10-11 08:38:37 -04:00
ZacharyZhang-NY 98f18f1221 14.17: the Layers panel grays only for what lasts 2026-10-11 08:30:40 -04:00
ZacharyZhang-NY ffee8ff178 14.16 passes review 2026-10-11 08:16:07 -04:00
ZacharyZhang-NY 7de167878c 14.16 round 1: partial amounts each way, tested 2026-10-11 08:14:20 -04:00
ZacharyZhang-NY 39214d9332 14.16: Camera Raw's Shadows and Highlights keep tones in order 2026-10-11 08:10:11 -04:00
ZacharyZhang-NY 9a36106d32 14.15 passes review 2026-10-11 08:05:35 -04:00
ZacharyZhang-NY 397497d045 14.15 round 1: a child of a shut folder scrolls into view 2026-10-11 08:03:56 -04:00
ZacharyZhang-NY da8b05509f 14.15: a chosen layer is revealed in the Layers panel 2026-10-11 07:52:39 -04:00
ZacharyZhang-NY e49359d997 14.14 passes review 2026-10-11 07:42:43 -04:00
ZacharyZhang-NY 0eafd4a09e 14.14 round 1: each note's place, tested 2026-10-11 07:40:58 -04:00
ZacharyZhang-NY 1815267ace 14.14: tool bars scroll when narrow 2026-10-11 07:36:26 -04:00
ZacharyZhang-NY 0fd4fe33a8 14.13 passes review 2026-10-11 07:25:22 -04:00
ZacharyZhang-NY fb4eb57d32 14.13 round 1: the name label hides while its field shows 2026-10-11 07:23:31 -04:00
ZacharyZhang-NY 022e0d0524 14.13: the name field keeps the name's size 2026-10-11 07:19:19 -04:00
ZacharyZhang-NY d096825e8d 14.12 passes review 2026-10-11 07:14:24 -04:00
ZacharyZhang-NY 0278b7b36d 14.12 round 1: Don't Save's colours are Cancel's, tested 2026-10-11 07:12:33 -04:00
ZacharyZhang-NY 89b546a1b0 14.12: Don't Save as plain as Cancel, held and tested 2026-10-11 07:08:55 -04:00
ZacharyZhang-NY c9731eb18c 14.11 passes review 2026-10-11 07:07:18 -04:00
ZacharyZhang-NY 75759d2a39 14.11 round 1: buttons disabled after being made, tested 2026-10-11 07:05:39 -04:00
ZacharyZhang-NY 9611cfa11c 14.11: the Layers footer's buttons in the primary ink, dimmed as the menus 2026-10-11 07:01:32 -04:00
ZacharyZhang-NY e0ba02707e 14.10 passes review 2026-10-11 06:45:20 -04:00
ZacharyZhang-NY b7c3f74575 14.10 round 1: Space let go mid-pan keeps the hand closed, tested 2026-10-11 06:43:21 -04:00
ZacharyZhang-NY e6575685ea 14.10: the closed hand through a pan, tested 2026-10-11 06:39:26 -04:00
ZacharyZhang-NY 3d071b3015 14.9 passes review 2026-10-11 06:36:21 -04:00
ZacharyZhang-NY 73ce3a2a23 14.9 round 1: a newer edit outlasts the repeat; optional reads checked 2026-10-11 06:33:38 -04:00
ZacharyZhang-NY 886b18dd7f 14.9: Filter › Last Filter on Ctrl+Meta+F 2026-10-11 06:28:14 -04:00
ZacharyZhang-NY 0136d9d41a 14.8 passes review 2026-10-11 06:12:35 -04:00
ZacharyZhang-NY 8974f93fd6 14.8 round 3: half the Color Detail, pinned 2026-10-11 06:10:59 -04:00
ZacharyZhang-NY d50f45cbcd 14.8 round 2: each colour plane's edge, the reach's amount and scale, a lone pixel 2026-10-11 06:08:08 -04:00
ZacharyZhang-NY a834076ca9 14.8 round 1: coverage and Color Smoothness pinned by exact pixels 2026-10-11 05:59:34 -04:00
ZacharyZhang-NY 440bd1c9b0 14.8: Camera Raw's colour noise reduction keeps brightness 2026-10-11 05:54:59 -04:00
ZacharyZhang-NY 00876282a5 14.7 passes review 2026-10-11 05:48:16 -04:00
ZacharyZhang-NY e320f2cd77 14.7 round 1: the layers edge and the definitions, tested 2026-10-11 05:45:48 -04:00
ZacharyZhang-NY e1207281a4 14.7: Canvas Only, Toggle Fullscreen on F and Escape 2026-10-11 05:38:35 -04:00
ZacharyZhang-NY 56f444b1ad 14.6 passes review 2026-10-11 05:13:37 -04:00
ZacharyZhang-NY 12655e0d9e 14.6 round 2: a row's key is drawn, tested 2026-10-11 05:11:38 -04:00
ZacharyZhang-NY 49d82284f0 14.6 round 1: canonical matching, a chosen second row, every mode from its sibling 2026-10-11 05:06:11 -04:00
ZacharyZhang-NY e126762d5b 14.6: the command palette on Ctrl+F 2026-10-11 04:58:06 -04:00
ZacharyZhang-NY 75fb546de8 14.5 passes review 2026-10-11 04:32:25 -04:00
ZacharyZhang-NY 478f24b3ff 14.5 round 1: the signal sees the snap guides cleared 2026-10-11 04:30:25 -04:00
ZacharyZhang-NY 1289a6c790 14.5: one View › Snap for all snapping 2026-10-11 04:24:36 -04:00
ZacharyZhang-NY dea7346a7c 14.4 passes review 2026-10-11 04:18:37 -04:00
ZacharyZhang-NY 3eaf54f935 14.4 round 2: an open edit closes before the turn, tested 2026-10-11 04:16:11 -04:00
ZacharyZhang-NY f883f10777 14.4 round 1: a quarter turn copies across only when image pixels map one to one onto device pixels; every layer and placed mask turns 2026-10-11 04:10:39 -04:00
ZacharyZhang-NY d8bb7b38b2 14.4: Image › Rotate Canvas 90° Clockwise and Counterclockwise; quarter turns on whole pixels copy exactly 2026-10-11 03:55:25 -04:00
ZacharyZhang-NY 28cbe856e6 14.3 passes review 2026-10-11 03:25:41 -04:00
ZacharyZhang-NY 5b219433a7 14.3 round 2: print sizes round Foundation's way, the shortest decimal then halves to even 2026-10-11 03:23:36 -04:00
ZacharyZhang-NY bb9b5c959e 14.3 round 1: print sizes tie to even; one trim of Foundation's .whitespaces, U+200B included, for New Canvas, validDimension and hex 2026-10-11 03:12:40 -04:00
ZacharyZhang-NY f8c1332c29 14.3: New Canvas in inches, cm or mm at 72 or 300 DPI, transparent, white or black, every setting a pill 2026-10-11 03:02:29 -04:00
ZacharyZhang-NY c4c6307d0e 14.2 passes review 2026-10-11 02:44:10 -04:00
ZacharyZhang-NY 768030bf10 14.2 round 1: the RAW bullet says when the request ends 2026-10-11 02:43:18 -04:00
ZacharyZhang-NY c04d1fb6e4 14.2: RAW import rounds to 8 bits with fine noise, in the kernel; the develop sheet shows Importing… until the layer lands 2026-10-11 02:38:04 -04:00
ZacharyZhang-NY 5aad975e8b 14.1 passes review 2026-10-11 02:08:23 -04:00
ZacharyZhang-NY afd4e9ba39 14.1: test comment 2026-10-11 02:06:16 -04:00
ZacharyZhang-NY fa0d38666f 14.1: Color Overlay, Inner Glow and Inner Shadow recolour translucent pixels fully; Add layer effect 2026-10-11 02:06:08 -04:00
ZacharyZhang-NY 117b66f5c5 TASKS: 14.0's review beside it 2026-10-11 02:01:48 -04:00
ZacharyZhang-NY 60842c51da 14.0 passes review 2026-10-11 02:01:44 -04:00
ZacharyZhang-NY 44e8859d93 14.0 round 1: items by first release, kernels staged, 14.22 as Swift has it 2026-10-11 02:00:12 -04:00
ZacharyZhang-NY 3c0d1843d5 14.0: Compositor-Ref at v1.4.9; section 14 splits v1.4.5..v1.4.9 into items 2026-10-11 01:56:11 -04:00
ZacharyZhang-NY 1fcbec84e9 Move to Gitea: install scripts, links and a release script that builds the DEBs, RPMs, Arch package and a new AppImage 2026-10-11 01:40:28 -04:00
ZacharyZhang-NY 4c7d98b17f 13.19j: the change log names the blend menu's Escape fix 2026-10-04 01:01:43 -04:00
ZacharyZhang-NY c854e42116 13.19i: distro checks pass on all five; the pickers preview only while open and end on the popup's Hide, as Qt 6.10 changed both 2026-10-04 01:01:26 -04:00
ZacharyZhang-NY c59e85babc 13.19h: the acceptance run checks the seventeen rows new in 1.4.5 through the window's own controls; shots retaken 2026-10-03 23:45:53 -04:00
ZacharyZhang-NY 8bd3fc3264 13.19g: a raster commit's continuation runs where the commit ends, as Swift's await returns; nothing lands between 2026-10-03 22:54:20 -04:00
ZacharyZhang-NY 28f6337d13 13.19f: review 3's scope; the tab strip's other-tab guard and the divider's second press pinned 2026-10-03 20:46:39 -04:00
ZacharyZhang-NY 1de10e711c 13.19e: 70318de verified; the Smear's allocation guards starved in WarpFailureTests; the commit's thinning and the bar's deferred finish pinned 2026-10-03 20:08:40 -04:00
ZacharyZhang-NY 3757eb8910 13.19d: 2190a1b verified; sizeLabel's ties tested; tests read optionals through value(); two mask tests brought to 13.12 2026-10-03 19:16:59 -04:00
ZacharyZhang-NY ab34fd6ace 13.19b and 13.19c: main builds; the upright pin and its mutants 2026-10-03 18:01:44 -04:00
ZacharyZhang-NY d053405aa9 13.19a: the spec at Compositor-Ref, out of the flake's source; the resumed step list 2026-10-03 17:36:48 -04:00
ZacharyZhang-NY 1f42c6241f Ignore the Compositor-Ref checkout 2026-10-03 17:06:45 -04:00
ZacharyZhang-NY 6ccff8a9d7 AGENTS and TASKS: status before the system reinstall 2026-10-01 23:09:59 -04:00
ZacharyZhang-NY 19d87370e9 TASKS: where the 1.4.5 work stopped 2026-10-01 23:02:57 -04:00
ZacharyZhang-NY 8204e6a28b Merge branch 'port-d'
# Conflicts:
#	AGENTS.md
2026-10-01 23:02:47 -04:00
ZacharyZhang-NY cf0ec57095 Merge branch 'port-c'
# Conflicts:
#	AGENTS.md
2026-10-01 23:02:04 -04:00
ZacharyZhang-NY 9e14109da3 Merge branch 'port-b' 2026-10-01 23:01:56 -04:00
ZacharyZhang-NY 70318de2e8 Review fixes in progress, unverified: stopped by the user before the tests ran 2026-10-01 23:01:53 -04:00
ZacharyZhang-NY 2190a1b67e Review fixes in progress, unverified: stopped by the user before the tests ran 2026-10-01 23:01:53 -04:00
ZacharyZhang-NY 63e42acc7c Review fixes in progress, unverified: stopped by the user before the tests ran 2026-10-01 23:01:53 -04:00
ZacharyZhang-NY 76dad20b63 Merge branch 'port-a' 2026-10-01 20:03:04 -04:00
ZacharyZhang-NY b2a22f6c51 Release paperwork for OmaPhoto 1.4.5: version, change log, README, acceptance table (13.19) 2026-10-01 20:02:35 -04:00
ZacharyZhang-NY 1e6d3ead1b Merge branch 'port-e' 2026-10-01 20:02:25 -04:00
ZacharyZhang-NY 5c8e18c5af Review 2 fixes for 13.17 and 13.18: the glow scales back exactly, tests for the front tab, a rendering editor, the Scanlines rows, removePoint's reach, repair's strict bound, the divider strip; bullets corrected 2026-10-01 20:02:02 -04:00
ZacharyZhang-NY 8dfb6f0ae4 Merge branch 'port-a' 2026-10-01 20:00:54 -04:00
ZacharyZhang-NY e72cfbac2a AGENTS: the eye's black fill order recorded as equivalent (13.15) 2026-10-01 20:00:41 -04:00
ZacharyZhang-NY 6735aad4ef Review 1 fixes for 13.7, 13.14, 13.15: a lost tab release ends the drag (Swift's dragTimer); pins for the pill spacing, the Ungroup chord, the eye under the arrows, the Quick Look digest 2026-10-01 20:00:30 -04:00
ZacharyZhang-NY 85bacdd558 Merge branch 'port-b' 2026-10-01 19:21:25 -04:00
ZacharyZhang-NY 382c9c20ad Review 1 fixes for 13.9a and 13.9b: the rounded move, effects while moving and dragging a gradient, a draft in a clipping stack pinned; stale Soft Light, nine-mode and 12.7a lines corrected 2026-10-01 19:20:40 -04:00
ZacharyZhang-NY 9ccf39010c ScrubbableSheetsTests builds against 13.4a's CanvasSizeSheet 2026-10-01 19:19:51 -04:00
ZacharyZhang-NY 81894f744a Merge 13.13 and 13.16: held keys flip the Move bar, its values apply as one step, resize handles snap; Liquify sharp, Smudge without ghosts, Blur's Radius in pieces; AGENTS and TASKS 2026-10-01 18:41:19 -04:00
ZacharyZhang-NY 7c6265af77 Merge branch 'main' into port-d 2026-10-01 18:38:20 -04:00
ZacharyZhang-NY c7e8bdfe8e Merge 13.11 (cc9bf95); Blur pieces and per-tile clone pieces on its grid sampling (13.16, part two) 2026-10-01 18:38:05 -04:00
ZacharyZhang-NY de8f23acbe Blur softens only the pieces the brush reaches; a clone is cut once per tile (13.16, part two) 2026-10-01 18:18:57 -04:00
ZacharyZhang-NY 72230b09b8 Merge 13.17 and 13.18: a dragged curve point stays on its neighbour, bands match one band, SheetTeardownTests on 13.4a's sheet; AGENTS and TASKS 2026-10-01 18:18:42 -04:00
ZacharyZhang-NY a004db172b Merge branch 'port-e' 2026-10-01 18:11:24 -04:00
ZacharyZhang-NY a77cce4598 Camera Raw curves as Photoshop's: the fitted parametric curve, the tone curve on each channel, one drag in the graph (13.18) 2026-10-01 18:10:13 -04:00
ZacharyZhang-NY f91849ade5 AGENTS and TASKS for 13.10, 13.11, 13.12; liveFolderMaskClip beside drawMaskAlone 2026-10-01 18:08:33 -04:00
ZacharyZhang-NY e243751f64 Merge branch 'port-c' 2026-10-01 18:06:09 -04:00
ZacharyZhang-NY 0b0d36a7df Masks as Photoshop's: the button reveals the selection, Alt-click shows a mask alone (13.12) 2026-10-01 18:04:14 -04:00
ZacharyZhang-NY d571a090d3 AGENTS and TASKS for 13.8, 13.9a, 13.9b 2026-10-01 17:51:22 -04:00
ZacharyZhang-NY 90a8e2860e Merge branch 'port-b' 2026-10-01 17:48:41 -04:00
ZacharyZhang-NY cc9bf95722 Say why a brush cannot paint; paint at the layer's own resolution; Cancel and Apply only when an edit waits, scaled layers' scale (13.11) 2026-10-01 17:48:38 -04:00
ZacharyZhang-NY cbd8612014 Liquify keeps pixels sharp, Smudge without ghost copies, Blur's own Radius, the largest Smear commits (13.16, part one) 2026-10-01 17:48:08 -04:00
ZacharyZhang-NY d2c8507fc7 Grain and Add Noise stay with the document on the canvas; the GPU canvas's contracts judged and pinned on the CPU canvas (13.9b) 2026-10-01 17:47:52 -04:00
ZacharyZhang-NY 06843364fe Soft Light by Photoshop's formula; stacks, coverage and adjustments in every mode proven (13.9a) 2026-10-01 17:47:52 -04:00
ZacharyZhang-NY cb85b86d58 Sharper canvas, PSD adjustments and masks, Photoshop's Saturation, Levels and Hue/Saturation on every core, a growing blur's preview kept, the hierarchy kept (13.8) 2026-10-01 17:47:51 -04:00
ZacharyZhang-NY 77afa0c9ba AGENTS and TASKS for 13.7, 13.14, 13.15 2026-10-01 17:30:07 -04:00
ZacharyZhang-NY 91dc6f2252 Tab drags: the press measured where it lands; another button's move drags nothing (13.14) 2026-10-01 17:25:57 -04:00
ZacharyZhang-NY 5bfbc6cd5d Quit settles a pending gradient, a pixel move and open dialogs; Dither › Scanlines (CRT) on all cores (13.17) 2026-10-01 17:14:47 -04:00
ZacharyZhang-NY 5be68a4f96 Tab drags: the three-point threshold, an unheld move and stacking pinned; no minimum hint (13.14) 2026-10-01 17:06:31 -04:00
ZacharyZhang-NY b50657bcdb Move bar: held Ctrl and Shift flip Auto Select and the lock, values apply as one step, resize handles snap (13.13) 2026-10-01 16:56:16 -04:00
ZacharyZhang-NY 01bb579068 Tab drags: a right move by one, a lost release, a close mid-drag pinned (13.14) 2026-10-01 16:43:33 -04:00
ZacharyZhang-NY fe3198777d New Canvas's dots are filled, never outlined: pinned (13.7) 2026-10-01 16:30:30 -04:00
ZacharyZhang-NY 6ea248367d Layer › Ungroup Layers (Ctrl+Shift+G), in a folder's menu too; Alt over a mask shows the eye pointer (13.15) 2026-10-01 16:22:27 -04:00
ZacharyZhang-NY 3f2b99f045 Project tabs drag to reorder; tabs that don't fit gather in a menu (13.14) 2026-10-01 16:22:27 -04:00
ZacharyZhang-NY 0616f9b006 New Canvas presets in a More menu; every save writes QuickLook/Preview.jpg (13.7) 2026-10-01 15:58:57 -04:00
ZacharyZhang-NY 51e373af4c Shift keeps moved pixels on a line; Ctrl+A selects the canvas from the Layers panel (13.10) 2026-10-01 14:35:13 -04:00
ZacharyZhang-NY 765e071c0c mutate.py: cores from OMAPHOTO_CPUS, 32 by default 2026-10-01 14:28:41 -04:00
ZacharyZhang-NY 9ed7c20681 Font previews in the Type bar; Ctrl+Z while typing judged and pinned (13.6) 2026-10-01 14:26:36 -04:00
ZacharyZhang-NY 93a4c62b8f View › Grid Settings…: spacing, subdivisions, colour, style and opacity, kept across launches (13.5b) 2026-10-01 12:43:50 -04:00
ZacharyZhang-NY 3992bc913d The Marquee, shapes and a moved selection snap to View › Snap To; Ctrl draws freely (13.5a) 2026-10-01 09:55:24 -04:00
ZacharyZhang-NY 64ebd1cdf9 Export JPEG: a zoomable full-size preview, its buttons and the View menu's zoom keys (13.4b) 2026-10-01 08:53:43 -04:00
ZacharyZhang-NY bc49b722eb Export JPEG and Canvas Size take the app's colour picker; a panel opens over a modal sheet (13.4a) 2026-10-01 07:16:16 -04:00
ZacharyZhang-NY 0b7eb6cb6f Select › Color Range: sampled colours, Fuzziness, Invert, a live preview and the panel (13.3b) 2026-10-01 06:30:26 -04:00
ZacharyZhang-NY 462fe805b1 Dither's ASCII as readable text: monospaced cells on one baseline, Text Size (13.3a) 2026-10-01 01:31:10 -04:00
ZacharyZhang-NY dcedfea53f Faces for the selected letters, project format version 11 (13.2) 2026-10-01 00:05:20 -04:00
雷电芽衣 802ae82f72 README: the software-cursor workaround for garbled cursors on NVIDIA with Hyprland (13.1, #9) 2026-09-30 21:05:48 -04:00
雷电芽衣 39dcbc7705 Survey Compositor 1.3.4 to 1.4.5 into items 13.1 to 13.19; spec pinned at v1.4.5 (13.0) 2026-09-30 20:44:51 -04:00
雷电芽衣 e94fd73573 Acceptance run with screenshots and the MVP table; window teardown no longer reaches destroyed menus or toolbar (11.5) 2026-09-30 14:39:11 -04:00
雷电芽衣 51bec23cc5 Live installs of 1.3.3 on every supported system (12.16) 2026-09-30 13:07:34 -04:00
雷电芽衣 c2d4637c34 One RPM per Fedora release, 43 and 44, each with its own LibRaw (12.16) 2026-09-30 12:36:18 -04:00
雷电芽衣 1904144511 OmaPhoto 1.3.3: version bump, change log, README; steady export failure tests, Hue/Saturation test after 12.14d2, NixOS shell MIME database (12.16)
Release / version (push) Canceled after 0s
Release / deb (ubuntu:24.04, noble) (push) Canceled after 0s
Release / deb (ubuntu:26.04, resolute) (push) Canceled after 0s
Release / rpm (push) Canceled after 0s
Release / arch (push) Canceled after 0s
Release / publish (push) Canceled after 0s
2026-09-30 11:32:25 -04:00
雷电芽衣 bc9d43f1e8 Filter › Dither, as Compositor 1.3.3 (12.15) 2026-09-30 07:55:57 -04:00
雷电芽衣 aa5a44e517 Closing the picker gives open text the keys back; colour edits wait for text editing (12.14d2) 2026-09-30 06:16:28 -04:00
雷电芽衣 27a4fe8428 Colour only the selected letters of text being edited; project format version 10 (12.14d1) 2026-09-30 05:15:51 -04:00
雷电芽衣 2fa8d9921d Coloured slider tracks with double-click reset, aligned filter titles; entries release focus before their owners go (12.14c) 2026-09-30 03:32:51 -04:00
雷电芽衣 2183348aa6 Open Recent, print sizes through an invalid resolution, Color Grading under Color (12.14b) 2026-09-30 01:43:23 -04:00
雷电芽衣 94b884a2ef Closing or quitting while text is edited applies it first (12.14a) 2026-09-30 00:25:34 -04:00
雷电芽衣 5ee5aee07b A guide for AI agents writing projects, the project format page, README lines (12.13c) 2026-09-29 23:30:45 -04:00
雷电芽衣 d4c065fa77 Keep editing while a project saves; saves, reloads and opens queue behind one writer (12.13b) 2026-09-29 22:39:49 -04:00
雷电芽衣 838d5b1459 Reload a project changed on disk, fingerprinted by its manifest and image sizes (12.13a) 2026-09-29 20:13:20 -04:00
雷电芽衣 aa253d9715 Scrub numbers by dragging their titles in the sheets and panels (12.12c2) 2026-09-29 17:07:35 -04:00
雷电芽衣 5bc7889387 Scrub numbers by dragging their labels in the tool bars and layer opacity (12.12c1) 2026-09-29 15:06:49 -04:00
雷电芽衣 f335818e53 Masks painted anywhere on the canvas; mask thumbnails' ground white or black (12.12b) 2026-09-29 13:26:01 -04:00
雷电芽衣 136ee65a48 Typed text drawn as its pixels; effects kept while typing, painting masks and undoing (12.12a) 2026-09-29 12:47:36 -04:00
雷电芽衣 8fbb104c48 Clicked text on its baseline, live text on the Move tool's double click, SVG import (12.11c) 2026-09-29 10:55:10 -04:00
雷电芽衣 008493b0a3 Import simple Photoshop text as editable text (12.11b2) 2026-09-29 09:04:50 -04:00
雷电芽衣 a2241601ec Read Photoshop Large Documents, layerless files and oversized layers (12.11b1) 2026-09-29 04:23:27 -04:00
雷电芽衣 8a9c56ee1f The document's own pixel budget, marching ants at screen resolution (12.11a) 2026-09-29 00:19:45 -04:00
雷电芽衣 ffba1753c6 Crop ratios and selection start, Vignette on empty layers, text colour previews (12.10c); mutation runs use 12 cores 2026-09-28 21:03:16 -04:00
雷电芽衣 7a873eb9c7 Whole layers on the clipboard and several duplicates (12.10b) 2026-09-28 19:09:46 -04:00
雷电芽衣 fc076de71d The format version in one place and the layer context menu (12.10a) 2026-09-28 15:28:21 -04:00
雷电芽衣 9ac3b1b81c Stepped keyboard zoom and a steady tab strip (12.9b) 2026-09-28 13:18:10 -04:00
雷电芽衣 fdb62978c5 Finishing filters: Vignette, Bloom / Glow, Tonal Contrast (12.9a) 2026-09-28 10:50:06 -04:00
622 changed files with 46898 additions and 4631 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
[(' if (m_cameraRawBrightness && m_cameraRawBrightness->first == previewSource.cacheKey())\n return m_cameraRawBrightness->second;\n', ''), (' job.cameraRawBrightness);', ' std::nullopt);')]
+1
View File
@@ -0,0 +1 @@
[(' const QImage pixels = image.format() == QImage::Format_RGBA8888_Premultiplied ? image : drawn(image);', ' const QImage pixels = drawn(image);')]
-91
View File
@@ -1,91 +0,0 @@
# A version tag builds the four packages, each on its own system, and publishes them.
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
version:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
# The tag must name the version each build file carries.
- run: |
v=${GITHUB_REF_NAME#v}
grep -qF "project(omaphoto VERSION $v " CMakeLists.txt || { echo "CMakeLists.txt is not $v"; exit 1; }
grep -qxF "pkgver=$v" packaging/arch/PKGBUILD || { echo "PKGBUILD is not $v"; exit 1; }
grep -qF "version = \"$v\";" flake.nix || { echo "flake.nix is not $v"; exit 1; }
deb:
needs: version
runs-on: ubuntu-24.04
# One DEB per Ubuntu LTS: each names its own libraries.
strategy:
matrix:
include:
- codename: noble
base: ubuntu:24.04
- codename: resolute
base: ubuntu:26.04
steps:
- uses: actions/checkout@v7
# The dev image carries ONNX Runtime, its notices and the model.
- run: docker build -t omaphoto-dev --build-arg BASE=${{ matrix.base }} -f Dockerfile.dev .
- run: |
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/src" -w /src omaphoto-dev sh -c '
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr \
-DOMAPHOTO_BUNDLE_ORT=ON -DOMAPHOTO_MODEL=/usr/local/share/omaphoto/u2net.onnx &&
cmake --build build --target omaphoto && cd build && cpack -G DEB'
mv build/omaphoto_*.deb omaphoto-${{ matrix.codename }}_amd64.deb
- uses: actions/upload-artifact@v7
with:
name: deb-${{ matrix.codename }}
path: omaphoto-${{ matrix.codename }}_amd64.deb
rpm:
needs: version
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- run: docker build -t omaphoto-fedora scripts/distro/fedora
- run: |
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/src" -w /src omaphoto-fedora sh -c '
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr \
-DOMAPHOTO_BUNDLE_ORT=ON -DOMAPHOTO_MODEL=/usr/local/share/omaphoto/u2net.onnx &&
cmake --build build --target omaphoto && cd build && cpack -G RPM'
mv build/omaphoto-*.x86_64.rpm omaphoto.x86_64.rpm
- uses: actions/upload-artifact@v7
with:
name: rpm
path: omaphoto.x86_64.rpm
arch:
needs: version
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- run: docker build -t omaphoto-arch scripts/distro/arch
# makepkg refuses root; a user of the runner's id builds.
- run: |
docker run --rm -v "$PWD:/src" omaphoto-arch sh -c "
useradd -u $(id -u) builder && chown -R builder /src &&
su builder -c 'cd /src/packaging/arch && makepkg -f'"
mv packaging/arch/omaphoto-[0-9]*-x86_64.pkg.tar.zst omaphoto-x86_64.pkg.tar.zst
- uses: actions/upload-artifact@v7
with:
name: arch
path: omaphoto-x86_64.pkg.tar.zst
publish:
needs: [deb, rpm, arch]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
merge-multiple: true
path: packages
- working-directory: packages
run: sha256sum omaphoto-noble_amd64.deb omaphoto-resolute_amd64.deb omaphoto.x86_64.rpm omaphoto-x86_64.pkg.tar.zst > SHA256SUMS
- env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" packages/* --title "OmaPhoto ${GITHUB_REF_NAME#v}" \
--notes "Install with the script for your system: see the README's Install section."
+6
View File
@@ -5,3 +5,9 @@
/packaging/arch/pkg/
/packaging/arch/*.pkg.tar.zst
/packaging/arch/u2net.onnx
/Compositor-Ref/
/build-appimage/
/AppDir/
/*.AppImage
/release/
+127 -46
View File
@@ -1,13 +1,22 @@
# OmaPhoto — agent notes
OmaPhoto is the Linux port of Compositor, the macOS app at https://github.com/robbietilton/Compositor. That repository's Swift tree (`Compositor/`, `CompositorTests/`) is the specification; this repository mirrors it. Its checkout at `../Compositor-spec`, pinned to the tag being ported (v1.3.3 since item 12), is where the Swift sources are read; never edit it for OmaPhoto work.
OmaPhoto is the Linux port of Compositor, the macOS app at https://github.com/robbietilton/Compositor. That repository's Swift tree (`Compositor/`, `CompositorTests/`) is the specification; this repository mirrors it. Its checkout at `Compositor-Ref`, inside this repository and ignored by git, pinned to the tag being ported (v1.4.9 since item 14), is where the Swift sources are read; never edit it for OmaPhoto work.
## Status
Item 14 (Compositor v1.4.9) is ported and released as OmaPhoto 1.4.9 on Gitea (2026-10-11), its packages installed live on every supported system (14.32). Its items are listed under 14 in `TASKS.md` and checked off as each passes review; that list is the progress truth. Work in this one checkout: no worktrees, branches or copies beside it.
## Designing or editing a project
To make or change an image in a `.comp` project, the source is not needed: read [docs/writing-comp-files.md](docs/writing-comp-files.md). It covers the format, the rules that make a project load, and writing it safely while it is open. [docs/project-format.md](docs/project-format.md) describes the whole format.
## Trees
- The repository root: the app. C++20, Qt 6 Widgets (floor Qt 6.4), CMake, Qt Test. CPU raster only: QPainter plus the C kernels in `kernels/`, copied unchanged from Compositor's `Compositor/Rendering/*.c` and compiled in place.
- `packaging/`: the desktop entry, MIME type, icons (Compositor's app icon) and the Arch `PKGBUILD`; `flake.nix` at the root for NixOS.
- `scripts/install/`: one install script per distribution family, each fetching the latest release's package.
- `.github/workflows/release.yml`: a `v*` tag builds the DEB, RPM and Arch package and publishes them.
- `scripts/release.sh`: builds a tag's DEBs, RPMs, Arch package and AppImage in Docker and publishes them as a release on the project's Gitea, https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto (the GitHub repository is gone; GitHub suspended the account in 2026-10).
- `docs/acceptance.md`: the MVP acceptance table, its screenshots in `docs/acceptance/`.
- `TASKS.md`: ordered task list and progress truth. Its entries before 11.4b speak of the port's old home, `linux/` inside Compositor's repository.
Names: the app is OmaPhoto (binary `omaphoto`, app id `io.github.ZacharyZhang_NY.OmaPhoto`, logging `omaphoto.*`, build options `OMAPHOTO_*`). Compositor's project format stays whole (`.comp`, `application/x-compositor-project`), so projects move between the two apps; C++ types keep their Swift twins' names (`CompositorApp`, `CompositorMenus`).
@@ -32,110 +41,181 @@ Platform adaptations, each forced by a missing Qt analog:
- `LayerRenderer::drawBrushPreview` replaces Swift's even-odd clips with `CompositionMode_Source` copies into the aside surface, hard-edged (`composite` runs with nearest sampling and the layer's interpolation quality); the source stays clipped to the grid and a raster's base to the source bounds, as Swift's clips keep them. Its `BrushPreview` struct carries Swift's labelled arguments. A raster counts as a source by itself, so a caller never flattens a painted asset just to pass a non-nil image. It throws `std::logic_error` on an empty pixel grid, where Swift would divide by zero.
- `LiveMaskRenderer` has no `bounds`. Swift allocates its coverage and group surfaces over the clip's bounding box, one pixel per user unit, and clips with CoreGraphics; the twin's surfaces are the painter's device in device pixels, drawn with the painter's device transform, because clip coverage must be what `Options::clip` takes. `drawOwn` receives that coverage, and an outer clip (folder masks) multiplies in; a clipping stack composites its group one to one through `LayerRenderer::composite`, under the painter's own opacity as in Swift; `draw` saves and restores the painter around `drawOwn`, exceptions included. The canvas keeps this cheap by painting each dirty rect through an image surface (item 4.4). The constructor takes a pixel budget so tests can reach the limit; passing it, or failing to allocate, logs a warning on `omaphoto.rendering` where Swift skips in silence.
- `AdjustmentSurface::draw` allocates what the painter can show in device pixels (Swift: one pixel per user unit), lets the body draw in the painter's own coordinates, and draws the surface back under the painter's own opacity, composition mode and clip, as `CGContext.draw` inherits them. A surface that cannot be allocated, or passes the budget, logs a warning and draws nothing. On Linux it also gives the hand-blended HSL modes a backdrop on devices without readable pixels (widgets, pixmaps).
- `ImageExporter` is a namespace of functions. Swift's actor only serialises calls; the twin keeps no state, and callers run it off the UI thread. Files are `QString` paths. PNG and JPEG go through `QImageWriter` with an sRGB tag and the resolution as dots per metre; the JPEG preview is read back with `QImageReader`, scaled to 1000 pixels at most. `write` replaces the file through `QSaveFile` and throws `std::runtime_error` with the system's message, where Swift rethrows the Cocoa error. Exports log on `omaphoto.io`.
- `ImageExporter` is a namespace of functions. Swift's actor only serialises calls; the twin keeps no state, and callers run it off the UI thread. Files are `QString` paths. PNG and JPEG go through `QImageWriter` with an sRGB tag and the resolution as dots per metre; the JPEG preview is read back with `QImageReader`, at full size up to `JPEGResult::previewLimit` (8192) a side (13.4b). `write` replaces the file through `QSaveFile` and throws `std::runtime_error` with the system's message, where Swift rethrows the Cocoa error. Exports log on `omaphoto.io`.
- `ProjectStore` is a namespace too. Swift's synthesized `Codable` is written by hand in `IO/ProjectStore+Manifest.cpp`: the same keys, the same refusals (wrong types, missing required keys, non-canonical UUIDs), unknown keys ignored, `CGPoint` and `CGSize` as two-number arrays, UUIDs upper case (`uuidString`), two-space indentation so the 4 MiB limit bites where Swift's does. Asset files carry the upper-case names macOS writes; Linux file systems tell the cases apart.
- Saving stages a hidden sibling folder, syncs its files and folders to disk, then swaps it in with `renameat2(RENAME_EXCHANGE)`; a first save is a plain `rename`. Where the system knows no exchange (FAT sticks, old kernels) the store logs a warning and replaces in two moves. Either way the parent folder is synced after the swap, the old project is kept until that sync succeeds, and a failed sync puts it back before the error is thrown. If the way back fails too, nothing is deleted: the old project stays where the swap left it (the hidden sibling, or its `.old` neighbour after two moves) and a critical log on `omaphoto.io` names that path. `ProjectReplaceTests` defines `rename`, `renameat2`, `fsync` and `read` in the test executable, where they take libc's place, to order and fail each call. There is no `NSFileCoordinator`. A missing manifest is `ProjectError(invalid)` and a missing asset `missingImage`, where Swift passes on a Cocoa error; a failed write throws `std::runtime_error` with the system's message. `QFile` reports no error when a read fails after partial data, so the manifest's byte count is checked against the file's size. The manifest's `version`, `width` and `height` are `qint64`, Swift's `Int`, read from Qt's exact integer (`QJsonValue::toVariant` as `LongLong`), never through a double: a wild number decodes and is refused by its own rule, with every digit of its value in the version error. One fact of Qt's parser stays, checked against Swift 6.0.3's decoder: a number token outside Int64, or one with a fraction or an exponent, passes through a double inside Qt. So the 1,024 integers below Int64's minimum and `-9.223372036854775808e18` read as the minimum where Swift refuses them, and `9007199254740993.0` loses its last digit where Swift keeps it. Every such value lies outside each valid range; only the kind or the number of the refusal differs. `ProjectTests` pins one. A whole number with a fraction or an exponent (`5.0`, `5e0`) reads exactly, as Swift's slow path reads it; a token that underflows a double (`1e-400`) fails Qt's whole parse as an illegal number, where Swift reads zero. Only a Grain seed (9.5a), every `UInt32` of which is valid, turns that into a project Swift opens and the twin refuses; `aSeedReadsWhatQtsParserReads` pins it.
- Qt refuses image decodes over 128 MB (`QImageReader::allocationLimit`). `ImageImporter::liftAllocationLimit` lifts that limit for the store and the importer: their own pixel budgets are checked from the file headers before any pixel is read.
- Qt refuses image decodes over 128 MB (`QImageReader::allocationLimit`). `ImageImporter::liftAllocationLimit` lifts that limit for the store, the importer and the JPEG export's full-size preview (13.4b): their own pixel budgets are checked from the file headers before any pixel is read.
- `ImageImporter` is a namespace; the Swift actor only serialises calls and caches a `CIContext`. The type comes from the file's bytes, as `CGImageSourceGetType` reads it: `QImageReader::setDecideFormatFromContent` for JPEG, PNG and TIFF (TIFF needs `qt6-image-formats-plugins`), libheif's `heif_get_file_mime_type` for HEIC (`image/heic`: HEVC in HEIF). Other ISO media (AVIF, plain HEIF, video) and every other format Qt recognises are `unsupported`; bytes nothing recognises are `unreadable`. HEIC decodes through libheif, which applies the file's own turn and crop boxes, so its EXIF orientation is never applied again. Its colours come from the ICC profile, else from the `nclx` box, which ImageIO reads for Swift: the box's primaries (libheif hands over their chromaticities) with the curve it names: sRGB, linear, gamma 2.2 or 2.8 through Qt's own transfer functions, and BT.709's curve (shared by BT.601, BT.2020, xvYCC and BT.1361 between zero and one) as a table; unspecified primaries or curve count as sRGB's, like untagged pixels. Qt 6.4's `QColorSpace` constructor that takes points and a table builds an invalid space (probed), so the table is set afterwards. Curves with no conversion here (PQ and HLG, which need tone mapping; SMPTE 240M, SMPTE 428, the logarithmic ones), primaries Qt cannot hold and a box libheif rejects are `unreadable`, each with its reason in the log: no HEIC imports with colours the importer knows to be wrong. `heif_init` loads the decoder plugins (Ubuntu ships HEVC decoding as `libheif-plugin-libde265`); a missing decoder is `unreadable`, with libheif's message in the log. For Qt's formats the importer reads the pixels as stored, converts them to sRGB while the ICC profile is still attached, and only then applies the EXIF or TIFF orientation itself: Qt 6.4's `setAutoTransform` drops the colour space. Untagged pixels count as sRGB, as CoreImage counts them. Qt 6.4 reads no orientation from a PNG's `eXIf` chunk (probed: `supportsOption(ImageTransformation)` is false), so such a PNG imports as stored; JPEG, TIFF and HEIC turn upright. The result is `RGBA8888_Premultiplied`, tagged sRGB. A thumbnail's long side is 96 and its short side rounds up, as Swift's `extent.integral` does. The HEIC fixtures in `tests/fixtures` are committed; `scripts/heic-fixtures.sh` makes them and stops at the first failure, because the dev image carries no HEVC encoder. The tests patch the lossless `p3-nclx.heic` in memory to name other primaries and curves.
- `LayerRenderer::drawCoverage`: Swift clips to the mask and fills white; QPainter has no soft clip. The twin builds white whose alpha is the mask (premultiplied, from the halved mask `draw` would use, since CoreGraphics filters a shrinking mask and QPainter's bilinear filter aliases) and draws it under the painter's own opacity and clip, in the composition mode under which alpha stands for a clip's coverage: the painter's own for every mode that is linear in the source and keeps pixels where the source is clear (source-over, the blend modes, the rest of Porter and Duff's), and its exact stand-in for the seven that are not (Source and Plus become SourceOver, Clear becomes DestinationOut, SourceIn SourceAtop, SourceOut Xor, DestinationIn Destination, DestinationAtop DestinationOver). `DrawCoverageTests` holds all 24 modes against the oracle, a fill of opaque white mixed in by coverage, over opaque, half clear and clear pixels. Those seven take no painter opacity (CoreGraphics scales the source by it, Qt mixes the result by it, and only linear modes make the two agree): below full opacity they are a `logic_error`, as is any raster operation. No caller sets a mode today. Source-over that is white through coverage, 255 a + d(1 - a), on gray and colour devices alike. It takes the placement `draw` takes, `radians()` included, so a wild angle turns both alike. A mask that is not gray is a `logic_error`; a white image that cannot be allocated is `ExportError(render)`.
- QPainter places an unscaled, unturned image on whole device pixels whatever the hints say; CoreGraphics would blur it by the fraction. Layers the app makes sit on whole pixels, Shape lines aside (8.8), and any scale or turn takes QPainter's filtered path, so tests of soft edges draw under a scale.
- `ImageResizer` and `CanvasResizer` are namespaces. `Task.checkCancellation()` in the Swift resizer has no twin: no caller cancels a resize. Each turns a `std::bad_alloc` into `ExportError(render)` at its door (since 10.1 and 10.2), so the workers that run them catch it. A layer's new box is judged by `LayerTransform::isValid` before any number becomes an integer, where Swift converts first: `Int(Double)` traps on a wild value, C++ would be undefined. Records are rebuilt field by field as Swift rebuilds them, so a field Swift leaves out is dropped once that field exists (the image resizer passes on neither shape, effects nor text; the canvas resizer passes on shape and text, not effects). Options hold `qint64`, Swift's `Int`, like the manifest. `CanvasSizeDraft` keeps plain members where Swift has `let`, so the struct stays assignable.
- `ImageResizer` and `CanvasResizer` are namespaces. `Task.checkCancellation()` in the Swift resizer has no twin: no caller cancels a resize. Each turns a `std::bad_alloc` into `ExportError(render)` at its door (since 10.1 and 10.2), so the workers that run them catch it. A layer's new box is judged by `LayerTransform::isValid` before any number becomes an integer, where Swift converts first: `Int(Double)` traps on a wild value, C++ would be undefined. Records are rebuilt field by field as Swift rebuilds them, so a field Swift leaves out is dropped once that field exists (the image resizer passes on effects, scaled by the root of its two factors, but neither shape nor text; the canvas resizer passes on all three, since 14.22). Options hold `qint64`, Swift's `Int`, like the manifest. `CanvasSizeDraft` keeps plain members where Swift has `let`, so the struct stays assignable.
- `EditorSession` is a `QObject`. One signal, `changed()`, stands in for Swift's observation: every method that changes state emits it, a refused request stays silent, and widgets compare what they show with what the session holds, as `CanvasView.synchronizeDisplay` does. `beginEdit` emits as `endEdit` does, because an open transaction closes `canUndo` and `canRedo`. `fit` and `zoom` emit only when the view changed, so a zoom that is no number stays silent. The canvas edits `viewport` in place and then calls `notify()`. A Swift property with a `didSet` is a private member with a getter and a setter (`activeLayerID`, `isProjectBusy`, `showsNewDocument`, `showsImporter`, `isImporting`, `importError`, `renamingLayerID`); `document`, `tool`, `collapsedGroupIDs` and `selectedLayerIDs` are read through getters and change only through session methods; `history` and `viewport` are public members, as Swift's tests use them. `activeLayer()` returns a copy, as Swift's computed property does. `EditorSessionSignalTests` holds the signal's contract: every change is announced, a refusal is silent, and the last signal of a change sees the state the call leaves behind, read inside the slot.
- `EditorSession.swift` runs to 741 lines, so the twin splits: `EditorSession+Model.h/.cpp` hold `ImageLayer`, `CanvasDocument` and `NavigationTool` (their own header also breaks the include cycle with `DocumentHistory`, which a member by value needs complete), `EditorSession.h/.cpp` the core, `EditorSession+Layers.cpp` the layer operations, `EditorSession+Transform.cpp` the transform edits, `EditorSession+Imports.cpp` the import queue and the busy gates; `EditorSession+Projects.cpp` twins the Swift file of that name. A session function that Swift defines in an extension lives in the twin of that Swift file, declared in its `Name+Session.h`: the `LayerGroups` extension (folders, the multi-selection, grouping, placing, `layerRows`) in `LayerGroups.cpp`, `finishDeletingLayer(s)` in `LiveLayerMask.cpp`.
- `EditorSession.swift` runs to 741 lines, so the twin splits: `EditorSession+Model.h/.cpp` hold `ImageLayer`, `CanvasDocument` and `NavigationTool` (their own header also breaks the include cycle with `DocumentHistory`, which a member by value needs complete), `EditorSession.h/.cpp` the core, `EditorSession+Layers.cpp` the layer operations, `EditorSession+Transform.cpp` the transform edits, `EditorSession+Imports.cpp` the import queue and the busy gates (its queue's declarations in `EditorSession+Imports.h` since 14.2, keeping the header under 500 lines); `EditorSession+Projects.cpp` twins the Swift file of that name. A session function that Swift defines in an extension lives in the twin of that Swift file, declared in its `Name+Session.h`: the `LayerGroups` extension (folders, the multi-selection, grouping, placing, `layerRows`) in `LayerGroups.cpp`, `finishDeletingLayer(s)` in `LiveLayerMask.cpp`.
- `groupSelectedLayers` follows Swift's steps (what stays keeps its place, the folder sits at the topmost selected branch, the wrapped layers go last in hierarchy order) with two terms dropped because they are dead in the twin: the `nil` that ends each ancestor chain (the twin's shared parent starts as the root) and the clamp of the insertion index (it counts layers that stay, so it cannot pass their number). `placeLayer` drops Swift's `target != id` for the same reason: the target is sought after the layer is taken out, so a layer is never found above itself. `canPlaceLayer` is tested by itself as well as through `placeLayer`, whose validation would hide its terms; the panel asks it before a drop. `selectLayers` and `extendSelection` fall back on any selected layer, as Swift's `Set.first` does: tests never depend on which. A session's document always passes `LayerHierarchy::validate` (every mutator checks before it commits), so every `parentID` names a folder of the document; `groupSelectedLayers` reads parents through `std::map::at`, which throws if that ever breaks.
- Transform edits: `TransformEdit` holds what exists so far (`layerID`, `draft`, `group`); `persistent`, `corners`, `mask` and `floating` arrive with the items that read them (`TASKS.md`, "Session hooks owed"). A draft changes only through `previewTransform`, which refuses an invalid value, and begins as a valid transform or box, so it is valid by construction. `commitTransform` therefore gives each layer what `pendingTransform` gives it, one definition for preview and commit, inside one transaction, and skips a layer whose own result is invalid (a box squeezed until a member falls under a pixel); Swift's `edit.draft.isValid` and `firstIndex` guards are left out, since nothing can reach them. An edit that moves nothing records nothing, as in Swift: the history drops a transaction that changed nothing. `canTransform` and `groupTransformMembers` drop Swift's `isGroup` terms (a folder never holds pixels, `LayerHierarchy::validate` sees to it, so `asset` says the same), and `beginTransform` drops `transformEdit == nil` (`canTransform` reads it through `canEditLayers`). `groupTransformBox` keeps `max(1, …)`: rounding leaves a one-pixel layer at origin 1.3 a box under one pixel, and a test holds that. `DistortWarp::corners` came forward from 6.2 for the group box.
- Swift's `async` session calls (`importImages`, `waitForProjectAccess`, `waitForFileRequest`) take a completion callback in the twin. A waiter runs at once when the session allows it; otherwise it is kept, and the setter that frees it posts each waiter to the event loop (`Qt::QueuedConnection`), where it looks again when its turn comes, as a Swift continuation re-enters its `while` loop: no waiter runs inside the setter, and the first may take the project before the second looks. `importImages` takes `QList<QUrl>` (a URL that is no local file is `unsupported`, Swift's `isFileURL` guard), decodes one file at a time through `QtConcurrent::run` and a `QFutureWatcher` (the worker catches `ImageImportError` and hands back the asset or the failure text; anything else is a bug and is left to crash), inserts on the UI thread, and wraps each request in one `Import Images` transaction. A request's callback is posted to the event loop too, so the caller goes on as a resumed Swift task does: after the transaction ended and, for the last request, after `isImporting` fell and the failures were published; for an earlier request while the next one decodes. Only a project that stops being busy frees project waiters: saying busy again leaves them in line. `showsBusy` is a single-shot `QTimer` of `busyIndicatorDelay` (250 ms), a `Qt::PreciseTimer` because a coarse one fires up to a twentieth early, and a child of the session so a test can read its remaining time; tests of time assert floors and states, never ceilings; `createDocument` clears its two flags and then resumes waiting file requests once, where Swift's two `didSet`s each try. The session therefore needs an event loop: every test that makes one uses `QTEST_GUILESS_MAIN`.
- Appearance: `allLayerBlendModes` stands for Swift's `LayerBlendMode.allCases`, the order of the blend menu. Swift's `refreshCanvasPreview` callback has no twin: `changed()` reaches the canvas, and `previewBlendMode` emits it only when the preview changes, so hovering over one entry redraws once. `blendPreview` is a small struct where Swift has a tuple. `opacityEditLayerID` is a private member: only the session reads it; an open opacity drag shows outside as an open transaction (`canUndo` is false). Inside a drag `setLayerOpacity` opens a nested transaction where Swift writes into the open one: the history counts depth, so the drag is still one step, and the branch Swift needs is gone.
- Projects in the session: Swift's `projectURL` is `projectPath`, a `QString`, since files are paths here; it gets a setter when `ProjectController` needs one. Swift builds layers and the document through memberwise initialisers in two places (`installProject`, `applyDocumentSize`); the twin has two constructors for that, `ImageLayer(record, asset, mask)` and `CanvasDocument(snapshot)`, so both places agree by construction; shapes and text set them apart: `installProject` loads both after `CanvasDocument(snapshot)`, `applyDocumentSize` does not, as Swift's rebuild leaves them out. `applyImageSize` and `applyDocumentSize` live in `IO/ImageResizer.cpp`, as in Swift; they build the new document and record the step on a copy of the history before either is published (`DocumentHistory::adopt` takes the copy's steps: its past, its future and its revision, all a closed step changes), then fit the view and signal once, so memory that runs out on the way changes nothing (Swift begins, sets, fits and ends, signalling twice; since 10.1). The twin's document is read-only from outside: a test that needs a state no session call can make (a painted mask, a record with every field set) installs a hand-built `ProjectSnapshot`, where Swift's tests write into `session.document`.
- Projects in the session: Swift's `projectURL` is `projectPath`, a `QString`, since files are paths here; it gets a setter when `ProjectController` needs one. Swift builds layers and the document through memberwise initialisers in two places (`installProject`, `applyDocumentSize`); the twin has two constructors for that, `ImageLayer(record, asset, mask)` and `CanvasDocument(snapshot)`, so both places agree by construction; since 14.22 the layer constructor also loads its live shape and text, Swift's `documentLayers`, so an install and a resize's rebuild keep them alike. `applyImageSize` and `applyDocumentSize` live in `IO/ImageResizer.cpp`, as in Swift; they build the new document and record the step on a copy of the history before either is published (`DocumentHistory::adopt` takes the copy's steps: its past, its future and its revision, all a closed step changes), then fit the view and signal once, so memory that runs out on the way changes nothing (Swift begins, sets, fits and ends, signalling twice; since 10.1). The twin's document is read-only from outside: a test that needs a state no session call can make (a painted mask, a record with every field set) installs a hand-built `ProjectSnapshot`, where Swift's tests write into `session.document`.
- Masks in the session (`LayerMask.cpp`, after the model): `isMaskSelected` has a getter only, since every Swift write of it sits in a session extension. `TransformEdit` gains `mask`. `commitTransform` hands a mask-alone edit to `commitMaskTransform`, which writes the placement inside one transaction and lets the history drop a change that changes nothing, where Swift compares placements first. In `copyMask` and `toggleMaskLink` Swift's `finishOpacityEdit()` after `commitTransform()` is left out: `commitTransform` has already finished the drag. `tests/SessionFixtures.h` holds `rewrite(session, change)`, which installs a changed snapshot where Swift's tests write a mask, a flip or a sampling into `session.document`; it resets the history as any install does, so steps are counted from there. `tests/SessionRecord.h` holds `described()`, the text of everything an observer can read, shared by the signal test files.
- Live masks in the session (`LiveLayerMask.cpp`): `adoptClipping` and `releaseDetachedClipping` are static members, as Swift's `static func`s. `removeLiveMask` and `releaseDetachedClipping` walk the layers once per parent in list order, where Swift builds sibling arrays; the runs they free are the same. `LiveMaskGraph::validate` already refuses a link to oneself, a folder at either end and a missing source, so `canLinkMask` asks only for editable layers, an existing target and a valid graph; Swift's own checks of those cases, both folder checks of `toggleClippingMask` and `canToggleClippingMask` (the layer's own and its lower sibling's: the store validates the graph on load and `canLinkMask` on every link, so no folder is ever clipped and none becomes a base), and the folder case of the run's base in `releaseDetachedClipping` (nothing is ever clipped to a folder) are dead and left out; both toggle functions take the base from one helper, `clippingBase`. `adoptClipping` keeps its folder check: it writes the link without asking the graph, and `LiveMaskEditTests` calls it directly, because `placeLayer`'s release hides the side of the rule that looks below. `deleteWithLiveMaskChoice` shows a `QMessageBox` from the session, because Swift's session shows an `NSAlert` there: three buttons with Swift's texts (accept, reject and destructive roles), and `LiveMaskDeleteTests` drives the real alert from a zero timer under `QTEST_MAIN`. The bake runs through `QtConcurrent::run` and a `QFutureWatcher` with the project busy; layers are deleted first and the project freed last, as Swift's `defer` does; a failure sets `brushError` (a getter and a setter, arriving here because this is its first writer) and logs a warning. `LiveMaskBaker::bake` draws the target in its own pixel grid and each source through the inverse of the target's pixel-to-document map; the target's own mask, opacity and blend mode stay on the layer. A snapshot that repeats a layer id is a `logic_error` once the target and its pixels are found, where Swift's `Dictionary(uniqueKeysWithValues:)` traps. The bake's worker catches `ExportError` alone, as the import's worker catches `ImageImportError`: anything else is a bug and is left to crash. `releaseDetachedClipping` keys its runs by the optional parent itself, because all zeros is a valid folder id. `PixelAdjust::thumbnail` came forward from 9.2 for the baker. Over the renderer's pixel budget a bake draws nothing, on macOS too.
- `ProjectController` is a `QObject`. Its `async` calls take a completion callback, posted to the event loop like the session's; steps inside continue inline. `newCanvas` and `close` take none: no caller awaits them. The store runs through `QtConcurrent::run` with one `QFutureWatcher` a call, a child of the controller, so an answer dies with it; the worker catches `std::runtime_error` (both of the store's error types) and anything else crashes. Panels are `QFileDialog`s and alerts `QMessageBox`es shown with `open()` over `window` (a `QPointer<QWidget>`), so nothing blocks; without a window they stand alone, where Swift runs the alert modally. A project is a folder: the open panel is in `Directory` mode and takes one project (no `QFileDialog` mode takes several folders, as Swift's workspace panel takes several packages); the save panel is a file panel with the `comp` suffix, and a typed name that is an existing project enters that folder, as any save dialog does (probed; GTK's does too), so Save As cannot land on an existing project. An accepted panel always holds a choice, read as `selectedFiles().value(0)`. A chosen name that does not end in `comp` (in any case) gets `.comp` appended, where `NSSavePanel` enforces the project type: `setDefaultSuffix` only fills in a missing suffix. The panel never saw that completed name, so if anything exists there (a link that leads nowhere counts) the save is refused with an alert and nothing is replaced. `open` trims the slashes at the end of its path, supplied or chosen in the panel: a shell completes a folder's name with a slash, and `QFileInfo` finds no name behind one, where Swift's `lastPathComponent` does. It trims only the end: `QDir::cleanPath` would also collapse `link/..`, which names another folder once `link` is a symbolic link. Callbacks that the session keeps (`waitForFileRequest`, `importImages`) hold the controller through a `QPointer`, because the session may outlive it; everything else hangs on the controller itself (connections, watchers, posted calls) and ends with it. The alert's roles carry the keys: Return saves, Escape cancels. Swift's `session.projectURL = …` is `setProjectPath`, which notifies; `markSaved()` needs no signal of its own, because every path from a save goes on to free the project, and that notifies. `samePlace` compares `canonicalFilePath()`s where Swift compares `resolvingSymlinksInPath()`. There are no recent documents (Qt has no API for the desktop's list) and no security scopes. `receive` keeps Swift's queue: requests drain one at a time from the event loop, each after `waitForFileRequest`; a project is known by its suffix in any case, a trailing slash aside. `tests/DialogDesk.h` answers the real dialogs from a timer: it types into a panel's name field (`selectFile` does nothing while that field has focus) and clicks or keys an alert. While a desk lives the working directory is a temporary folder of its own, so a panel never starts in the source tree (a mutant once saved `Untitled.comp` there and skewed the runs after it).
- `ProjectWorkspace` is a `QObject` with one signal, `changed()`, as the session has: it mutates itself, so the rule for `@Observable` classes that only the session writes does not cover it. `tabs`, `selectedID` and `isManaging` are read through getters. A `ProjectTab` holds its session and controller by value, the session first, and lives in a `std::shared_ptr`, as Swift's tabs are references: a flow that awaits a tab's controller or session (a load, a close, a quit, an import, a copy) keeps that tab alive in its callback until the answer has run, as Swift's `await` does, so a tab removed meanwhile still ends its flow. Every callback the workspace hands to a tab's controller or session holds the workspace through a `QPointer`. Swift's `async` calls take completion callbacks posted to the event loop; `close` takes one too, because its tests await it. The open panel takes one project (see the controller) and is titled `Open Project` like the controller's, where Swift's bare `NSOpenPanel` says `Open`: Qt's default for a folder panel is `Find Directory`. Managing ends last in every flow, after the sessions are free again, so that the workspace's last signal sees `canSwitch` as the call leaves it. A title cuts the suffix as Swift's `deletingPathExtension` does (`nameWithoutSuffix`: a name's first or last dot cuts nothing, so `.comp` stays whole), where `completeBaseName` would leave nothing. `samePlace` falls back on the absolute path (`absoluteFilePath`, which also cleans it) when a place no longer resolves, as `resolvingSymlinksInPath` does, because two missing places both canonicalise to the empty string. A loaded tab's default name is never shown (its title comes from the session's path, which the controller trims), so `loadProject` names it from the path as given. `receive` looks again every 30 ms while the workspace cannot switch, as Swift sleeps; `Task.isCancelled` has no twin. `receiveProviders` takes the drag's `QMimeData`: the layer-row type carries a UUID, judged by its text so that all zeros stays a valid id (a payload under another type is no row and goes to `ImageFileDrop` since 10.4, which reads nothing there); files and pictures arrive with 10.4. `copyLayer` bakes every mask that comes from outside the copy in one worker task (Swift awaits them one by one), then places the layers from a friend's seat in the target session, because Swift writes that document in place; `ProjectController::isProject` and `samePlace` are public statics so that both classes read paths alike. The anchor's `?? centre` fallback is dead (the copied layer is always among the copies) and is a checked read instead.
- `ProjectController` is a `QObject`. Its `async` calls take a completion callback, posted to the event loop like the session's; steps inside continue inline. `newCanvas` and `close` take none: no caller awaits them. The store runs through `QtConcurrent::run` with one `QFutureWatcher` a call, a child of the controller, so an answer dies with it; the worker catches `std::runtime_error` (both of the store's error types) and anything else crashes. Panels are `QFileDialog`s and alerts `QMessageBox`es shown with `open()` over `window` (a `QPointer<QWidget>`), so nothing blocks; without a window they stand alone, where Swift runs the alert modally. A project is a folder: the open panel is in `Directory` mode and takes one project (no `QFileDialog` mode takes several folders, as Swift's workspace panel takes several packages); the save panel is a file panel with the `comp` suffix, and a typed name that is an existing project enters that folder, as any save dialog does (probed; GTK's does too), so Save As cannot land on an existing project. An accepted panel always holds a choice, read as `selectedFiles().value(0)`. A chosen name that does not end in `comp` (in any case) gets `.comp` appended, where `NSSavePanel` enforces the project type: `setDefaultSuffix` only fills in a missing suffix. The panel never saw that completed name, so if anything exists there (a link that leads nowhere counts) the save is refused with an alert and nothing is replaced. `open` trims the slashes at the end of its path, supplied or chosen in the panel: a shell completes a folder's name with a slash, and `QFileInfo` finds no name behind one, where Swift's `lastPathComponent` does. It trims only the end: `QDir::cleanPath` would also collapse `link/..`, which names another folder once `link` is a symbolic link. Callbacks that the session keeps (`waitForFileRequest`, `importImages`) hold the controller through a `QPointer`, because the session may outlive it; everything else hangs on the controller itself (connections, watchers, posted calls) and ends with it. The alert's roles carry the keys: Return saves, Escape cancels. Swift's `session.projectURL = …` is `setProjectPath`, which notifies; a save marks the saved revision before `setProjectPath`, whose signal then shows the saved state (12.13b: the write lands after the project is free again). `samePlace` compares `canonicalFilePath()`s where Swift compares `resolvingSymlinksInPath()`. There are no security scopes; recent projects are 12.14b's. `receive` keeps Swift's queue: requests drain one at a time from the event loop, each after `waitForFileRequest`; a project is known by its suffix in any case, a trailing slash aside. `tests/DialogDesk.h` answers the real dialogs from a timer: it types into a panel's name field (`selectFile` does nothing while that field has focus) and clicks or keys an alert. While a desk lives the working directory is a temporary folder of its own, so a panel never starts in the source tree (a mutant once saved `Untitled.comp` there and skewed the runs after it).
- `ProjectWorkspace` is a `QObject` with one signal, `changed()`, as the session has: it mutates itself, so the rule for `@Observable` classes that only the session writes does not cover it. `tabs`, `selectedID` and `isManaging` are read through getters. A `ProjectTab` holds its session and controller by value, the session first, and lives in a `std::shared_ptr`, as Swift's tabs are references: a flow that awaits a tab's controller or session (a load, a close, a quit, an import, a copy) keeps that tab alive in its callback until the answer has run, as Swift's `await` does, so a tab removed meanwhile still ends its flow. Every callback the workspace hands to a tab's controller or session holds the workspace through a `QPointer`. Swift's `async` calls take completion callbacks posted to the event loop; `close` takes one too, because its tests await it. The open panel takes one project (see the controller) and is titled `Open Project` like the controller's, where Swift's bare `NSOpenPanel` says `Open`: Qt's default for a folder panel is `Find Directory`. Managing ends last in every flow, after the sessions are free again, so that the workspace's last signal sees `canSwitch` as the call leaves it. A title cuts the suffix as Swift's `deletingPathExtension` does (`nameWithoutSuffix`: a name's first or last dot cuts nothing, so `.comp` stays whole), where `completeBaseName` would leave nothing. `samePlace` falls back on the absolute path (`absoluteFilePath`, which also cleans it) when a place no longer resolves, as `resolvingSymlinksInPath` does, because two missing places both canonicalise to the empty string. A loaded tab's default name is never shown (its title comes from the session's path, which the controller trims), so `loadProject` names it from the path as given. `receive` looks again every 30 ms while the workspace cannot switch, as Swift sleeps; `Task.isCancelled` has no twin. `receiveProviders` takes the drag's `QMimeData`: the layer-row type carries a UUID, judged by its text so that all zeros stays a valid id (a payload under another type is no row and goes to `ImageFileDrop` since 10.4, which reads nothing there); files and pictures arrive with 10.4. `copyLayers` (since 12.10b; `copyLayer` hands it one id, and a drop still copies row by row, as Swift's) bakes every mask that comes from outside the copy in one worker task (Swift awaits them one by one), then places the layers from a friend's seat in the target session, because Swift writes that document in place; `ProjectController::isProject` and `samePlace` are public statics so that both classes read paths alike. The anchor is the first layer's centre, or with several the centre of the box round every copied layer that is no folder; the `?? centre` fallback is dead (the first id is always among the copies) and is a checked read instead. The copies end up selected, the first active; an id the source tab lacks (two tabs of one project share ids, and the first holding the first id is the source) is left out of the selection, as Swift's `compactMap`.
- `CanvasView` twins `EditorCanvas.swift`, split as `EditorCanvas.cpp` (the display) and `EditorCanvas+Navigation.cpp` (events, cursors, focus; keys in `EditorCanvas+Keys.cpp` since 12.2b). It follows Swift's `updateNSView`: `ContentView` calls `consumeFocusRequest` and `synchronizeDisplay` on every `changed()`, and a canvas alone follows nothing, as Swift's alone observes nothing. `paintEvent` clips to the dirty rect (the region's union, as `draw(_:)` receives it) and paints it through `AdjustmentSurface::draw`, so hand-blended modes and clip coverage meet an image-backed device and device-sized coverage costs only the dirty area; the smooth path draws its layers through a second surface, Swift's transparency layer, so blend modes never meet the checkerboard. `DisplayState` holds what Swift's holds (`brushRevision` came with 8.2, adjustments with 9.5, effects with 9.6c, the crop union in `renderBounds` with 10.1); with a live mask every layer counts, hidden ones included, since a hidden source still clips. The surround outside the document is the palette's `Base`, which the built-in dark sets to Swift's 0.105 gray and a light Omarchy theme to its light `darker_background`, so the welcome reads over it. The shadow is fourteen rings of black stacking to 35% at the edge (Qt has no blur); the border is antialiased, half in and half out, as CoreGraphics strokes it. A wheel without pixel deltas is read in lines, `QApplication::wheelScrollLines()` a notch, twelve points a line as Swift's non-precise scroll; `Qt::ZoomNativeGesture` is `magnify`. Geometry reaches the viewport from the event loop (`syncGeometry` is posted, as Swift defers past SwiftUI's layout) and notifies the session; `QWindow::screenChanged` stands in for `viewDidChangeBackingProperties`. A shown canvas takes the keys when a document exists and no sheet, importer or modal dialog holds them; `EditorSession::requestCanvasFocus` counts Swift's `canvasFocusRequest`, `NavigationToolHeader` asks on Return and Escape, and `ContentView` posts one when a document appears (Swift's `onChange`). Cursors: the open and closed hands for Hand and Space, a drawn magnifier for Zoom (Alt shows the minus), the I-beam for Type, the arrow for Idle and Move (the transform cursors arrive with 6.1), the cross for the rest; auto-repeat releases of Space and Alt are no release. Keys: the tool letters, Space, Escape and Return around a transform edit, arrows nudging in Move; the rest arrive with their tools. Swift's local monitors (`viewDidMoveToWindow`) are an application event filter installed while the canvas is shown: Shift-+ and Shift-− step the blend mode from any control of the window but a text field (`QLineEdit`) or the canvas holding open text (Swift's monitor stands aside for any `NSText` first responder, the inline text view included; since 8.9b), and Alt reaches the Zoom cursor from any window, an open menu's included (Swift's modifier monitor is application-wide); a blend key from another window, a dialog included, is that window's own.
- `BrushRaster::visibleRect` and `AdjustmentSurface::draw` work on widget painters as on images: a widget's `QPainter::device()` measures itself in logical pixels where an image's measures device pixels, `combinedTransform()` carries the device pixel ratio, and `deviceTransform()` also the widget's offset in its window's backing store, whose image is the engine's `paintDevice()`. `HighDpiCanvasTests` holds this at ratio 2 through a `main` of its own that sets `QT_SCALE_FACTOR`.
- `EditorSession::drawLiveComposite` lives in `LiveLayerMask.cpp`, as in Swift, and draws the document as the export does, in document pixels, through the displayed transforms, placements and blend modes; `LayerMaskSessionTests` compares it with the export on every render.
- `ContentView` is one project's editor area, a `QWidget` that compares what it shows with what the session holds on every `changed()`; the window around it is `ProjectWorkspaceView` (4.3c), which owns the toolbar that SwiftUI lets `ContentView` declare. `ToolHeaderBar` carries `Q_OBJECT`: Qt 6.11's `findChild` refuses a class without one at compile time. Every tool has a bar of `ToolHeaderStyle::height`, so the canvas never jumps: a tool whose bar has not arrived yet shows an empty `ToolHeaderBar`, the idle and Eyedropper bars are titles alone, Hand and Zoom share one `NavigationToolHeader`, and a bar put in late is shown at once (a layout shows a late child only from the event loop). The welcome is made anew each time the document goes, as Swift's `if document == nil` makes a fresh view, so its fields and its clipboard suggestion start over. A rail button is a `QToolButton` that toggles itself on a click before the session answers, so the click's slot sets every button again from the session, which a refused `selectTool` leaves silent. `NewCanvasSheet` takes Return and Enter from anywhere on the sheet through widget shortcuts, as SwiftUI's `.defaultAction` does: `setDefault` alone reaches only a `QDialog`. Fonts are set in pixels, Swift's points on a display of one: `setPointSize` would grow them by the screen's DPI. Swift's `.secondary` text is the palette's `PlaceholderText`, its orange warning the palette's `BrightText`, which the theme of 4.5 fills; dividers are `Mid`. Labels and hints say Ctrl and Alt for ⌘ and ⌥. The status bar's hint is an eliding label and the one item that gives way when the window is narrow, as SwiftUI truncates it; zoom and size print through the English locale, as Swift's interpolation groups digits (`1,200 × 800 px`). Swift's last branch gives the Eyedropper the Zoom tool's hint; the twin says the same. Hints and icons that follow tool state (marquee and lasso kinds, brush and smear modes, shape kinds) arrive with that state. `NavigationToolHeader` applies on Return and Escape directly and again when focus leaves, where Swift applies only on the loss of focus: a Qt widget in a window that is not active loses no focus. Text that is no number reads as zero through `toDouble`, which the `> 0` test refuses, so no `ok` flag is read; `inf` reads as a number and the viewport refuses it. `NewCanvasSheet::clipboardDimensions` reads a copied picture's size from its header through `QImageReader` (no pixel is decoded), PNG before TIFF as in Swift, sides swapped for a stored quarter turn. The importer is a `QFileDialog` for existing files, and the two alerts are `QMessageBox`es opened over the view; each is taken down again when the session's flag or error is cleared from elsewhere, as a SwiftUI binding would dismiss it.
- `LayersPanel` twins `LayersPanel.swift`: the heading with its count, `LayerAppearanceControls`, the rows or the empty words, and the footer's `QToolButton`s (new layer, folder, `LayerMaskMenu`, the effects menu since 9.6d, the adjustment menu since 9.5d, delete). Its width is `ContentView`'s `layersPanelWidth`, kept in `QSettings` under Swift's `AppStorage` key; a stored width outside `minimumWidth…maximumWidth` is the default, logged. `PanelResizeEdge` lives in `ContentView.cpp` as in Swift: an 8-point grip with the split cursor over a `Mid` hairline, dragged left to widen, whole points, within range. Tests enable `QStandardPaths::setTestModeEnabled`, which sends `QSettings` to `~/.qttest`. The delete button is Swift's `deleteLayerOrMask` (a chosen effect goes first since 9.6a, then a selected mask, then the selection; since 9.6d its words name a chosen effect first).
- `NativeLayerList` twins the `NSTableView` with a `QScrollArea` of `LayerCell`s, one a row, made anew only when the ids change (a selection or rename reconfigures the cells in place, Swift's "no reload" rule; `nativeSelectionDoesNotReloadRowsAndReorderKeepsIdentity` compares the cell pointers) and shown at once, since a layout shows late children from the event loop. Each cell lays its controls out by hand from Swift's constraints (eye, disclosure, thumbnail, mask link, mask thumbnail, name, size, the effect rows since 9.6e; the depth and a clipping mask each step 24 points in) and keeps a `ThumbnailKey` (asset identity, transform, canvas size) so a picture is redrawn only when what it shows changes. `EyeSwipeButton` and `LayerThumbnailButton` are `QToolButton`s that answer the press themselves. The eye begins a swipe and sets the rows under the drag, scrolling the list by the pointer's overshoot past the viewport as `autoscroll(with:)` does; Swift tracks the drag inside `mouseDown` with a `defer`, so the twin watches the left button's release application-wide (a menu opened mid-press takes it; another button's release leaves the swipe) and a row rebuilt mid-swipe takes the button with it, so its destructor ends the swipe from the event loop, never inside the rebuild that deletes it. The thumbnail follows Swift's `mouseDown` order: Alt on a mask chooses the mask on a release without a drag, Shift toggling it as `selectMask` does (the copy's drag arrives with 4.7), Alt elsewhere is the row's, Ctrl waits for 7.1's selection loading, Shift on a mask targets and toggles it, a plain press targets the pixels or the mask, and every press but Ctrl's then goes to the row with its modifiers, so Shift ranges from the active row without targeting first. A double click with a modifier is a second press of the button's own (Swift's `mouseDown` runs twice), and Alt's on the row's strip clips again; a plain one is left to the row, which renames as Swift's `renameClickedLayer` does for a pixel layer (text opens since 8.9a, an adjustment's settings since 9.5d). `QTest::mouseDClick` on a widget sends the double click alone; the window overload sends the press, release, double click and release a real click brings. The row's own press is `clickRow` (it first lets go of a chosen effect since 9.6e; Alt in the bottom 10 points of the whole row clips to the layer below, Ctrl extends, Shift ranges from the active row, a name click on a lone mask-selected layer commits and targets the pixels, a press on a selected row keeps the selection). A rename is typed in the row's `QLineEdit` (Return keeps, Escape cancels, leaving keeps) and the session's `renamingLayerID` is cleared before the rename, because the rename's own signal would reopen the editor; then the list takes focus, as Swift's `window.makeFirstResponder(table)`. A focus lost to the menu bar or a popup (`Qt::MenuBarFocusReason`, `Qt::PopupFocusReason`) is no leaving, for the editor and the opacity field alike: an `NSMenu` never ends editing. The name and size labels are `Qt::PlainText`, so a name is never read as markup. The context menu is the cell's `actions()`, named as Swift's menu items. Hidden rows fade through a `QGraphicsOpacityEffect`. The list's keys mirror the canvas's (tool letters, Escape and Return around a transform edit, arrows nudging under Move, where every edit sits) and Up and Down walk the rows otherwise, Shift ranging from the anchor as the table's (the far end of the selected run around the active row; the range shrinks back towards it), the row brought into view. A palette change redraws every icon from the event loop, once the widgets carry the new palette: the list's `update`, the panel's and the mask button's icons; a folder's icon keeps its ink in its `ThumbnailKey`. The canvas's application event filter already takes Shift-+ and Shift-− from the list; `BlendShortcutTests` holds both and the text-field exception.
- `NativeLayerList` twins the `NSTableView` with a `QScrollArea` of `LayerCell`s, one a row, made anew only when the ids change (a selection or rename reconfigures the cells in place, Swift's "no reload" rule; `nativeSelectionDoesNotReloadRowsAndReorderKeepsIdentity` compares the cell pointers) and shown at once, since a layout shows late children from the event loop. Each cell lays its controls out by hand from Swift's constraints (eye, disclosure, thumbnail, mask link, mask thumbnail, name, size, the effect rows since 9.6e; the depth and a clipping mask each step 24 points in) and keeps a `ThumbnailKey` (asset identity, transform, canvas size) so a picture is redrawn only when what it shows changes. `EyeSwipeButton` and `LayerThumbnailButton` are `QToolButton`s that answer the press themselves. The eye begins a swipe and sets the rows under the drag, scrolling the list by the pointer's overshoot past the viewport as `autoscroll(with:)` does; Swift tracks the drag inside `mouseDown` with a `defer`, so the twin watches the left button's release application-wide (a menu opened mid-press takes it; another button's release leaves the swipe) and a row rebuilt mid-swipe takes the button with it, so its destructor ends the swipe from the event loop, never inside the rebuild that deletes it. The thumbnail follows Swift's `mouseDown` order: Alt on a mask chooses the mask on a release without a drag, Shift toggling it as `selectMask` does (the copy's drag arrives with 4.7), Alt elsewhere is the row's, Ctrl waits for 7.1's selection loading, Shift on a mask targets and toggles it, a plain press targets the pixels or the mask, and every press but Ctrl's then goes to the row with its modifiers, so Shift ranges from the active row without targeting first. A double click with a modifier is a second press of the button's own (Swift's `mouseDown` runs twice), and Alt's on the row's strip clips again; a plain one is left to the row, which renames as Swift's `renameClickedLayer` does for a pixel layer (text opens since 8.9a, an adjustment's settings since 9.5d). `QTest::mouseDClick` on a widget sends the double click alone; the window overload sends the press, release, double click and release a real click brings. The row's own press is `clickRow` (it first lets go of a chosen effect since 9.6e; Alt in the bottom 10 points of the whole row clips to the layer below, Ctrl extends, Shift ranges from the active row, a name click on a lone mask-selected layer commits and targets the pixels, a press on a selected row keeps the selection). A rename is typed in the row's `QLineEdit` (Return keeps, Escape cancels, leaving keeps) and the session's `renamingLayerID` is cleared before the rename, because the rename's own signal would reopen the editor; then the list takes focus, as Swift's `window.makeFirstResponder(table)`. A focus lost to the menu bar or a popup (`Qt::MenuBarFocusReason`, `Qt::PopupFocusReason`) is no leaving, for the editor and the opacity field alike: an `NSMenu` never ends editing. The name and size labels are `Qt::PlainText`, so a name is never read as markup. The context menu is the list's since 12.10a (below). Hidden rows fade through a `QGraphicsOpacityEffect`. The list's keys mirror the canvas's (tool letters, Escape and Return around a transform edit, arrows nudging under Move, where every edit sits) and Up and Down walk the rows otherwise, Shift ranging from the anchor as the table's (the far end of the selected run around the active row; the range shrinks back towards it), the row brought into view. A palette change redraws every icon from the event loop, once the widgets carry the new palette: the list's `update`, the panel's icons (since 14.11) and the mask button's; a folder's icon keeps its ink in its `ThumbnailKey`. The canvas's application event filter already takes Shift-+ and Shift-− from the list; `BlendShortcutTests` holds both and the text-field exception.
- Drags in the list (`NativeLayerList+Drag.cpp`): a row pressed and moved past `startDragDistance` starts a `QDrag` carrying the row's id, or its whole selection's, one id a line under `ProjectWorkspace::layerType` (Swift writes one pasteboard item a row); Alt at the press offers `CopyAction` alone under `CanvasView::duplicateCursor`, as the table's source mask does, and the drop then duplicates. `dropTarget` is Swift's `validateDrop`: a folder's middle half takes the drop in, the rest lands above the nearer row edge or below every row, every row must pass `canPlaceLayer` (a copied folder carries its contents since 12.2a); `acceptDrop` is `acceptDrop`, the place worked out once, the order reversed into a folder, the moved rows kept selected, one `Move Layer(s)` or `Duplicate Layer(s)` step. Swift refuses a drag from another table by its source; the twin's drag carries its list's token under `sourceType` and a drop takes only its own (Qt's synthetic drop events carry no `source()`, and two projects opened from one file share their layers' ids, so neither would do). A press arms the drag with its modifiers, on the row or on a thumbnail (a thumbnail press that targets goes on to arm the row, as Swift's `mouseDown` falls through to the table); every left press first drops whatever an earlier press armed, then arms unless it clipped (Swift returns after clipping) or was Ctrl's or Shift's; only a move with the left button held past `startDragDistance` starts the drag. An Alt press on a mask watches the left button's release application-wide (a popup opened mid-press takes it), as the eye does; a release seen on a `QWindow` is not counted, since the window sees the button's own release before the button does, and another button's release leaves the press, as Swift waits for the left one. A plain drag offers Move and Copy with Move preferred, so a project target can take the Copy Swift proposes (a tab, the canvas and the New button set `CopyAction` on a valid enter, move and drop and ignore the rest); the list itself duplicates only when a copy is offered alone (Alt at the press), whatever Ctrl makes Qt propose, and sets Move or Copy itself. A drag held near the viewport's edge scrolls the list by the overshoot into a 16-point margin and keeps scrolling on a timer until the pointer moves back inside or the drag ends (a leave or a drop stops it, so no tick reads a drag that is gone), as the table autoscrolls (Wayland sends no moves while the pointer holds still); `autoscrolling()` says whether the timer runs, for the tests. An Alt click that clips sets the cursor for the next click at once, as Swift's refresh after toggling. `LayerColumn` paints the indicator: a two-point accent line between rows or the accent-filled folder row. A mask's Alt-drag carries its layer's id under `maskType`, read back through `ProjectWorkspace::layerID` (a payload that is no id names no mask, an all-zero id included), and lands on the row under the pointer through `canCopyMask`. `QDrag::exec` needs the platform's drag loop, which `offscreen` has not: `LayerDragGestureTests` defines both `QDrag::exec` overloads in the test executable, where they take libQt6Gui's place (as `ProjectReplaceTests` does with libc), record what the drag offered and return `IgnoreAction`, so presses, thresholds and actions are tested to the call; `LayerDragTests` holds the drops and `LayerDragFixtures.h` what both share. The eye and thumbnail buttons live in `NativeLayerList+Buttons.cpp`. With Alt held the viewport's cursor follows the pointer (`cursorFor`): the clipping cursor over a row's strip (create or release, drawn as paths like the symbols, refused where `canToggleClippingMask` says no), the duplicate cursor over the rest of a row (a folder's strip included, since a folder clips nothing, 12.2a) and over a mask thumbnail, the arrow otherwise and with Ctrl; Alt or Ctrl pressed anywhere, and hover over any row or control (every cell and its children track the mouse), reach an application event filter installed while the list is shown, as Swift's `flagsChanged` monitor and tracking area; the list keeps the last hover point, so a modifier change is judged where the pointer sits (`QCursor::pos` is no witness offscreen), and a hover's own modifiers are the event's.
- `EditorSession::duplicateActiveLayer` and `duplicateLayer` (`Document/SelectionClipboard.cpp`, the rest of that extension arrives with 7.4) came forward from 5.1 for the Alt-drag: the copy is the layer with a new id and " copy" after its name, inserted above it, one `Duplicate Layer` step; since 12.2a a folder copies with every layer inside it, new ids throughout, parents and clipping links inside the copy pointing inside it and those outside kept, a collapsed folder's copy collapsed, and nothing past 10,000 layers; `duplicateLayer` selects, duplicates and places the copy inside one transaction, refusing a place `canPlaceLayer` refuses (which reads `canEditLayers`, Swift's first term) and answering false when the limit made no copy.
- `LayerMerge.cpp`, `LayerFlip.cpp` and `Filters.cpp` twin the Swift files of those names (`Filters.cpp` holds `PixelFilter::trimmed` alone until 9.x). `trimmed` returns a `Trimmed` struct where Swift returns a tuple, and reads the alpha bounds straight from a premultiplied `RGBA8888` image, drawing any other format into one first, where Swift always draws into a context. `mergePlan` is a private `MergePlan` struct. `mergeLayers` renders through `drawLiveComposite` into a document-sized context and takes the trimmed image's own size through `ImageLayer(asset, origin)`, where Swift also assigns `trimmed.transform`: at 1:1 the two are equal by construction. Its insertion index counts the kept layers before the anchor, so Swift's `?? layers.count` and the clamp are dead and left out; the hierarchy check stays (a folder selected together with its own child would put the result in a removed folder), and Swift's beeps are warnings on `omaphoto.rendering`, as is a context that cannot be allocated (Swift returns in silence). `flipLayers` reads the group box and the active layer through `value()`, since `canTransform` has proved both, and drops Swift's `ids.isEmpty` guard for the same reason; `commitTransform` has already run `finishOpacityEdit`, so Swift's second call in `mergeLayers` and `flipLayers` is left out. `LayerTransform::mirrored` is declared in `LayerTransform.h` and defined in `LayerFlip.cpp`, as Swift's extension. `beginDuplicateTransform` keeps a private `TransformDuplicate`; a selected folder carries its selected contents, which are no targets of their own, and since the layer limit can refuse a copy, Swift's `copies.isEmpty` branch (the step closed, the selection back) and its `copy != id` test are kept (12.2a); a cancel removes each copy with its contents and their collapsed marks. `commitTransform` ends the duplicate after its own transaction (`endDuplicateTransform`, Swift's `defer`) on the layer path only: `selectLayers` has just made a copy active, so no mask is selected and the mask path never carries one. `cancelTransform` removes the copies, restores the selection and ends it. The Layer menu's Duplicate Layer entry calls `duplicateActiveLayer`, what Swift's `layerViaCopy` does without a selection (7.4 brings the selection); Merge shows `mergeTitle` and Flip Layer follows `canTransform`.
- `EditorSession::duplicateActiveLayer` and `duplicateLayer` (`Document/SelectionClipboard.cpp`, the rest of that extension arrives with 7.4) came forward from 5.1 for the Alt-drag: the copy is the layer with a new id and " copy" after its name, inserted above it (`insertCopy`); since 12.10b `duplicateActiveLayer` is `duplicateLayers` of every selected layer (none inside a selected folder, in document order: `copiedLayerIDs`; Swift's union with the active layer is dead and left out, since `selectLayers` always keeps the active layer selected), one step, where several copies stack together above the topmost original in the panel's order, in its parent, and end up selected, the active one's copy active; since 12.2a a folder copies with every layer inside it, new ids throughout, parents and clipping links inside the copy pointing inside it and those outside kept, a collapsed folder's copy collapsed, and nothing past 10,000 layers; `duplicateLayer` selects, duplicates and places the copy inside one transaction, refusing a place `canPlaceLayer` refuses (which reads `canEditLayers`, Swift's first term) and answering false when the limit made no copy.
- `LayerMerge.cpp`, `LayerFlip.cpp` and `Filters.cpp` twin the Swift files of those names (`Filters.cpp` holds `PixelFilter::trimmed` alone until 9.x). `trimmed` returns a `Trimmed` struct where Swift returns a tuple, and reads the alpha bounds straight from a premultiplied `RGBA8888` image, drawing any other format into one first, where Swift always draws into a context. `mergePlan` is a private `MergePlan` struct. `mergeLayers` renders through `drawLiveComposite` into a document-sized context and takes the trimmed image's own size through `ImageLayer(asset, origin)`, where Swift also assigns `trimmed.transform`: at 1:1 the two are equal by construction. Its insertion index counts the kept layers before the anchor, so Swift's `?? layers.count` and the clamp are dead and left out; the hierarchy check stays (a folder selected together with its own child would put the result in a removed folder), and Swift's beeps are warnings on `omaphoto.rendering`, as is a context that cannot be allocated (Swift returns in silence). `flipLayers` reads the group box and the active layer through `value()`, since `canTransform` has proved both, and drops Swift's `ids.isEmpty` guard for the same reason; `commitTransform` has already run `finishOpacityEdit`, so Swift's second call in `mergeLayers` and `flipLayers` is left out. `LayerTransform::mirrored` is declared in `LayerTransform.h` and defined in `LayerFlip.cpp`, as Swift's extension. `beginDuplicateTransform` keeps a private `TransformDuplicate`; a selected folder carries its selected contents, which are no targets of their own, and since the layer limit can refuse a copy, Swift's `copies.isEmpty` branch (the step closed, the selection back) is kept (12.2a; the copies are found by the selection since 12.10b); a cancel removes each copy with its contents and their collapsed marks. `commitTransform` ends the duplicate after its own transaction (`endDuplicateTransform`, Swift's `defer`) on the layer path only: `selectLayers` has just made a copy active, so no mask is selected and the mask path never carries one. `cancelTransform` removes the copies, restores the selection and ends it. The Layer menu's Duplicate Layer entry calls `duplicateActiveLayer`, what Swift's `layerViaCopy` does without a selection (7.4 brings the selection); Merge shows `mergeTitle` and Flip Layer follows `canTransform`.
- The Move tool on the canvas (6.1): `TransformOverlay` is no widget. Swift's overlay view exists so that a selection never redraws the pixels; a transparent Qt child would repaint the canvas beneath it anyway, so the canvas draws the handles and guides itself, after its pixels, and `synchronizeDisplay` repaints only the union of the old and the new `drawnRect` (a guide spans the canvas) when the geometry or `snapGuides` changed, leaving its return value to the pixel state. `TransformOverlayGeometry` keeps Swift's handles, rotation grip and `hit`; `showsRotation` and the corner constructor arrive with 6.2. `resizeCursor` maps Swift's frame positions onto Qt's four resize shapes (right, bottom right, bottom, top right). `EditorCanvas+Transform.cpp` holds the drags (`beginTransformDrag`, `dragTransform`, `endTransformDrag`, `cancelTransformDrag`, Swift's `resignFirstResponder`), `transformPressLayer` and `transformCursor`; Ctrl stands for Cmd in picking, Ctrl+Shift extends the selection. The drag's cursor is kept in `m_dragCursor` until the release and outranks Space, where Swift disables the window's cursor rects; hover reaches `updateCursor` through mouse tracking and the last hover point, the application filter reads Alt and Ctrl as it read Alt, and a shown canvas reads both from the keyboard. The release synchronizes the display, so a Ctrl+T edit's guides go without a commit. `moveCursor` (the arrow with Swift's four-way badge) and `rotationCursor` (two arrows round a ring, for the SF symbol) are drawn as paths; the distort cursor arrives with 6.2. `SnapGuides` is a struct where Swift has a tuple, a public member like `viewport`, unannounced; `commitTransform` and `cancelTransform` clear it. `transformSnapTargets` and `snappedMove` live in `Document/Crop.cpp`, Swift's file. `TransformEdit.persistent` sits last, so the positional initialisers keep their order; a drag's edit and a duplicate's are not persistent. `transformAutoSelect`, `showsTransformControls` and `locksTransformRatio` are getters and setters that notify. Escape and Return end a drag with its edit and put the hover cursor back. A press that never moves commits an unchanged edit, which the history drops. The pixel tests hide the controls (`CanvasDisplayTests`, `HighDpiCanvasTests`) and preview through a drag's edit, since Swift's overlay never reaches its `draw` tests.
- Distort (6.2): `DistortWarp` keeps Swift's tests of usability and convexity, `homography` (a projective `QTransform` where Swift returns a closure, Qt dividing by the third row as Swift by `w`), `warp`, `warpTrimmed`, `carried` and `warpMask`. Core Image's perspective filter is `QTransform::quadToQuad` on the image's pixel rect and a `drawImage` under it, antialiased and smooth; a folded shape is two triangles under their affine maps, each a hard device-pixel region of the pixels whose centres the triangle holds, built per scanline from each edge's ends ordered by y, the left bound inclusive, so the halves partition the diagonal and meet a fractional shared edge with one x (a path clip turns soft once the image draws antialiased, and `QRegion`'s integer polygons round the vertices), copied through `BrushRaster::draw` without interpolation, as Swift's. In `distortPreview` a mask warp that throws is caught on its own, as Swift's `try?`: a linked mask covering its layer then draws none, a placed one falls back, with a folded shape, to its stored placement clipped over the warp. Its `Warped` and `Trimmed` structs stand for Swift's tuples; `mapPath` arrived with 8.2b and `distortedEffects` arrived with 9.6c. Swift's `DistortTests` agree since 1.2.1 (`7111885`): a bow tie is usable and folds as two triangles, not convex, and a collapsed corner is refused by both tests; the preview takes a folded shape. `TransformEdit.corners` sits last, after `persistent`. The session's `distortPreview` and `maskDistortPreview` are `const` with mutable caches (`DistortPreviewCache` by layer, `MaskDistortPreviewCache`), keyed by `ImageIdentity` where Swift compares `CGImage` identity, because the canvas reads them from a const session; a warp that throws leaves a cached empty result, as Swift's `try?`. `commitDistort` catches `ProjectError` and `ExportError` into `brushError` as Swift catches its errors; `commitTransform` runs it after the mask path, before the plain one, and ends a duplicate after it. `commitMaskTransform` takes the corners path with `Distort Layer Mask`, `displayedMaskPlacement` its corners cases, `nudgeLayer` moves the corners with the draft. The overlay's corners constructor sets `showsRotation` false, `hit` skips the grip then, and the drawing leaves the stem and disc out; `geometry` returns the corners' geometry for a group or the edited layer. The canvas's `DisplayState` carries the edit's corners, so a moved corner repaints; `drawOwn` draws `distortPreview` first and takes `maskDistortPreview` for the mask. A Ctrl-drag on a handle begins the distortion and drags in `distort` mode with the white `distortCursor`; the cursor shows it over a handle under Ctrl or once distorted. The inspector's numbers rest while corners are set, as Swift's `.disabled`.
- `TransformInspector` is the Move tool's `ToolHeaderBar`: Auto Select and Show Controls as `QCheckBox`es, the numbers in a `QScrollArea` without bars (Swift's horizontal `ScrollView`, which takes the row's spare room and keeps its content at its own size: the row's end stretch rests, the content ends in a stretch of its own), the ratio lock a checkable `QToolButton` with the panel's link icon, the sampling a `QComboBox`, Flip H and V and Cancel and Apply as `QPushButton`s. `TransformValueField` applies as typed (Swift's `isFinite` test is left out: `previewTransform` refuses every transform an infinite number makes), shows the session's number while unfocused and puts it back on `FocusOut`, steps with Up and Down (Shift ten) writing the number it applied, keeps its typing while a menu or popup borrows the focus (`MenuBarFocusReason`, `PopupFocusReason`), and on Return or Escape hands the canvas the keys as Swift's `onSubmit` and `onExitCommand`; the pending edit waits there for the canvas's Return or Escape. From any other control of the bar, Return applies and Escape cancels, Swift's default and cancel actions. The lock's icon is redrawn on `PaletteChange`, as the panel's. Swift's `.id(activeLayerID)` remake is `synchronize` clearing any focus of its own when the active layer changes. `resizeBox` is Swift's `resize`, whose name would hide `QWidget::resize`; its `number >= 1` guard and the scale's `number > 0` are left out: a side under a pixel is a box `previewTransform` refuses. The Layer menu's Transform Layer entry calls `beginTransform` (Swift's `transformCommand` does the same without a selection; 7.4 brings the selection) and the View menu's Show Transform Controls is checkable under Ctrl+H, enabled in the Move tool with a document.
- Selections (7.1): `DocumentSelection` holds a `QPainterPath` where Swift holds a `CGPath`, `SelectionClip` a `QImage` coverage; `SelectionClip::apply` has no twin, because QPainter has no soft clip: `PixelAdjust::coverage` draws the clip's coverage through the layer's inverse `pixelToDocument` into a mask surface (smooth, as CoreGraphics filters a mask clip) and `PixelAdjust::blend` mixes two images through it byte for byte, rounded, where Swift runs `CIBlendWithMask`. Qt's `united`, `subtracted` and `intersected` honour each path's own fill rule and return odd-even paths whose fill is the region they name (probed, `QPathClipper`), so an outline made here is `WindingFill` (a lasso crossing itself fills like Swift's `.winding`, a traced mask keeps its holes) and a result keeps the rule Qt gave it; every fill and `contains` reads the path's own rule, and `QTransform::map` keeps it. Expand and Contract stroke the outline with `QPainterPathStroker` (round cap and join, Swift's `copy(strokingWithWidth:)`; Swift's miter limit is idle under a round join and left out), and a contraction past the middle leaves Qt's empty path, `isEmpty`. `DragBox::rect` rounds with `std::round`, Swift's `rounded()`. The session's selection state is private with getters and setters (`lassoKind`, `marqueeKind`, `selectionModeChoice`, `selectionAntialiased`, the two amounts), `lassoDraft` and `heldSelectionMode` getters alone; `selectTool` cancels a lasso, as Swift's does. `pressMarqueeKey` and `pressLassoKey` arrive with the canvas keys of 7.2; `displayedSelection` with the ants. `PixelInvert::run` is a byte loop where Swift calls vImage (alpha − colour keeps premultiplied transparency), reading any other format as premultiplied first; `invertPixels` takes a completion callback, runs the job through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, and writes back only when the layer still holds the pixels it computed from (identities compared, Swift's `===`); the worker catches `ExportError` alone, the UI side `std::runtime_error` (the clip, the expanded 1×1 mask, the thumbnail), each a warning on `omaphoto.app` and `brushError`. `invertPixels` moves `brushRevision` after its step, as Swift's does. `addMask` from a selection draws the outline through the inverse of `pixelToDocument` into the layer's own grid; a blank layer's grid is its rounded size, as Swift's. `deleteLayerOrMask` is the session function the panel's trash, the Layer menu's Delete and (from 8.x) the Delete key share. The loaders in `MaskTracing.cpp` warn on `omaphoto.app` where Swift beeps, and fetch the pixels inside a try: a painted asset flattens on first use, and a flatten that cannot allocate loads nothing, where Swift's `CGImage` already exists. The Select menu and the Image menu's Invert sit between View and Layer, as macOS places `CommandMenu`s; Select All in a text field is the field's, like Undo (`QLineEdit` takes Ctrl+A itself through `ShortcutOverride`, so the entry's own branch matters only when triggered); Swift agrees since 1.2.0 (`90ee29f`, the responder chain first), and the entry never rests, as Swift's: without a canvas or a field it does nothing. The focus takes it first: a text field, open text, then a focused layer list, which selects every row with the top one primary, as `NSTableView`'s `selectAll(_:)` does in Swift's chain (`NativeLayerList::selectAllRows`), and only then the canvas. The panel's Ctrl-click on a thumbnail loads it (Ctrl-Shift adds, Ctrl-Alt subtracts, as Swift's `loadMode`), the row untouched; with Ctrl held over an enabled thumbnail the list shows `CanvasView::loadSelectionCursor`, a pointing hand drawn as paths with Swift's dashed badge, since `QCursor::pixmap` is empty for a shape cursor. `LayerMaskMenu` calls `addMask` and says in its tooltip what the selection becomes.
- Selections (7.1): `DocumentSelection` holds a `QPainterPath` where Swift holds a `CGPath`, `SelectionClip` a `QImage` coverage; `SelectionClip::apply` has no twin, because QPainter has no soft clip: `PixelAdjust::coverage` draws the clip's coverage through the layer's inverse `pixelToDocument` into a mask surface (smooth, as CoreGraphics filters a mask clip) and `PixelAdjust::blend` mixes two images through it byte for byte, rounded, where Swift runs `CIBlendWithMask`. Qt's `united`, `subtracted` and `intersected` honour each path's own fill rule and return odd-even paths whose fill is the region they name (probed, `QPathClipper`), so an outline made here is `WindingFill` (a lasso crossing itself fills like Swift's `.winding`, a traced mask keeps its holes) and a result keeps the rule Qt gave it; every fill and `contains` reads the path's own rule, and `QTransform::map` keeps it. Expand and Contract stroke the outline with `QPainterPathStroker` (round cap and join, Swift's `copy(strokingWithWidth:)`; Swift's miter limit is idle under a round join and left out), and a contraction past the middle leaves Qt's empty path, `isEmpty`. `DragBox::rect` rounds with `std::round`, Swift's `rounded()`. The session's selection state is private with getters and setters (`lassoKind`, `marqueeKind`, `selectionModeChoice`, `selectionAntialiased`, the two amounts), `lassoDraft` and `heldSelectionMode` getters alone; `selectTool` cancels a lasso, as Swift's does. `pressMarqueeKey` and `pressLassoKey` arrive with the canvas keys of 7.2; `displayedSelection` with the ants. `PixelInvert::run` is a byte loop where Swift calls vImage (alpha − colour keeps premultiplied transparency), reading any other format as premultiplied first; `invertPixels` takes a completion callback, runs the job through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, and writes back only when the layer still holds the pixels it computed from (identities compared, Swift's `===`); the worker catches `ExportError` alone, the UI side `std::runtime_error` (the clip, the expanded 1×1 mask, the thumbnail), each a warning on `omaphoto.app` and `brushError`. `invertPixels` moves `brushRevision` after its step, as Swift's does. `addMask` from a selection draws the outline through the inverse of `pixelToDocument` into the layer's own grid; a blank layer's grid is its rounded size, as Swift's. `deleteLayerOrMask` is the session function the panel's trash, the Layer menu's Delete and (from 8.x) the Delete key share. The loaders in `MaskTracing.cpp` warn on `omaphoto.app` where Swift beeps, and fetch the pixels inside a try: a painted asset flattens on first use, and a flatten that cannot allocate loads nothing, where Swift's `CGImage` already exists. The Select menu and the Image menu's Invert sit between View and Layer, as macOS places `CommandMenu`s; Select All in a text field is the field's, like Undo (`QLineEdit` takes Ctrl+A itself through `ShortcutOverride`, so the entry's own branch matters only when triggered); Swift agrees since 1.2.0 (`90ee29f`, the responder chain first), and the entry never rests, as Swift's: without a canvas or a field it does nothing. The focus takes it first: a text field, open text, and then the canvas, a focused layer list included: Swift's table hands Select All to the canvas since 1.4 (`451281e`), so the list's own Select All of every row (12.5b) is gone. The panel's Ctrl-click on a thumbnail loads it (Ctrl-Shift adds, Ctrl-Alt subtracts, as Swift's `loadMode`), the row untouched; with Ctrl held over an enabled thumbnail the list shows `CanvasView::loadSelectionCursor`, a pointing hand drawn as paths with Swift's dashed badge, since `QCursor::pixmap` is empty for a shape cursor. `LayerMaskMenu` calls `addMask` and says in its tooltip what the selection becomes.
- The Marquee and the Lasso on the canvas (7.2): Swift's lasso gestures live in `EditorCanvas+Selection.cpp`, the selection cursors in `EditorCanvas+Cursors.cpp`. The canvas tracks Shift as it tracks Alt and Ctrl (the application filter sees the key from any window, as Swift's `flagsChanged` monitor), and a key change reshapes a Marquee draft and the cursor's badge at once. A press with a selection tool is `lassoMouseDown`; a move is `moveSelectionTool` (a hover moves a polygonal draft's rubber band and refreshes the held keys, as Swift's `mouseMoved`); a release is `releaseSelectionTool`; the draft's keys and the outline's nudges are `selectionKey`, ahead of every other key; losing focus is `endSelectionGestures`. A double click closes a polygonal outline from `mouseDoubleClickEvent`, since Qt delivers the second press there (its first press adds the corner, as Swift's first `mouseDown` does), never while Space pans. A held M or L's repeats are swallowed, as Swift ignores them, so a tool chosen meanwhile stays. An outline drag keeps `moveSelectionCursor` under Space, as the transform drag keeps its own. The ants are a 120 ms `QTimer` stepping `TransformOverlay::antsPhase`, repainting `selectionRect` alone (the outline's own rect, not the handles beside it); `drawnRect` spans the handles, the outline and the draft, the last two stroked antialiased with butt caps (`Qt::FlatCap`, as CoreGraphics dashes: Qt's square caps would stretch each dash by the pen's width) as CoreGraphics strokes them (a 1-point line on whole coordinates blurs over two rows at 1×, on macOS too); `synchronizeDisplay` repaints the overlay when the displayed selection or draft changes, refreshes the overlay's cached rect with every full repaint (a pan moves the outline) and refreshes the cursor when a kind or the displayed mode changes. The two timers are named children of the canvas (`antsTimer`, `marqueeAutoscroll`), as the session's busy timer is, so tests read whether they run. Autoscroll is a 16 ms `QTimer` with Swift's speed curve; each step translates the viewport, drags the draft or the outline again under the still pointer, notifies and synchronizes. `moveSelectionCursor`, `selectionCursor` and `wandCursor` are drawn as paths: the crosshair, the icon haloed by eight white offsets and the badge, and the wand with Swift's own strokes (hot spot 7, 7 at the sparkle, the stick down right, the badge at 17, 5), since `QCursor::pixmap` is empty for a shape cursor; the icons are `Rendering/SelectionIcons`, which the rail's `ToolIcons` and the cursors share, so nothing in `Rendering/` includes `UI/`. `SelectionIcon` is that header's enum, not nested in `CanvasView`. Ctrl over the selection shows the move pointer until 7.4 brings the scissors; the wand's click and cursor are 7.3's. `pressMarqueeKey` and `pressLassoKey` choose their tools and nothing more, as Swift's functions do: Swift's tests once expected a second press to switch the kind, which no Swift code does (its `cycleToolMode` on Tab does, 8.x); since 1.2.0 (`b5cb4fa`) they test the code's rule, as the twin does (`…AndKeepsTheShapeLastSet`), and the twin's tooltips name no key. `LassoControls` is the selection tools' `ToolHeaderBar`: Swift's segmented pickers are checkable `QToolButton`s in a `QButtonGroup`, the amounts are `SelectionAmountField`s (a `QLineEdit` taking digits alone, every typed value clamped to 1–500 as Swift's binding clamps it, stepped by arrows within the bounds, Return and Escape handing the canvas the keys, a menu's borrowed focus keeping the typing as `TransformValueField` does), the wand's controls arrive with 7.3. `ContentView::hint` takes a `ToolIconKind` for the ellipse Marquee's and the polygonal Lasso's words, and the rail's buttons repaint with the kind.
- The Magic Wand (7.3): `WandPixels.c` compiles in place beside the other kernels, and `Document/MagicWand.cpp` wraps it as Swift's `MagicWand` does: `select` draws the image into a premultiplied `RGBA8888` context (the kernel's byte order, rows top down), floors the point, clamps the tolerance and hands the mask to `wand_mask`; `outline` frees the kernel's arrays through `unique_ptr` and builds a `WindingFill` path from its loops. `MagicWandError` carries Swift's two messages (`what()`), `tooDetailed` for the kernel's −2 and `memory` for −1. The session's `magicWand` takes a completion callback and runs the job through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, like the invert; the worker catches `MagicWandError` and `ExportError`, and the UI side applies the outline only while the same document is open (its id, as Swift compares), New with no match deselecting, New with a match `setSelection` without the canvas clip (Swift's shortcut), the other modes `applySelection`. `wandSample` draws the composite through `drawLiveComposite` or the active layer's own pixels through `LayerRenderer::draw` at 1:1, a folder or blank layer as clear; a context that cannot be allocated logs and refuses, as Swift's `try?` returns nil. `wandSettings` is a struct with a setter that notifies; `title` and `radius` stand for Swift's computed properties of `WandSampleSize`. The canvas hands a wand press to `magicWand` with the press's mode, and a click inside the selection to `magicWand` in New mode where another tool would deselect. `LassoControls` shows the wand's controls with the wand alone: the tolerance is a `SelectionAmountField` bounded 0–255, the sample size a `QComboBox` of Swift's titles, the source two checkable buttons, Contiguous a `QCheckBox`; `changeWand` rewrites one field of the settings. The mutant that pins `m_thisLayer` checked is equivalent, since the exclusive group's later `m_allLayers` line decides, as with the marquee's kinds.
- The clipboard (7.4): `SelectionClipboard.cpp` twins Swift's extension as far as the raster edits exist. `PixelClipboard` and `CopiedPixels` (Swift's tuple) live in `Document/SelectionClipboard.h`. `renderSelectedPixels` and `renderMergedPixels` draw into the copy region and multiply the selection's coverage in with `DestinationIn` through `BrushRaster::alphaView` (`clipTo`), where Swift clips the context (`SelectionClip::apply` has no twin); a mask copies as opaque gray with `LayerMask::background` for its ground when placed apart and `LayerRenderer::drawCoverage` for its white, each pass multiplied on its own (the white in a second surface), because CoreGraphics clips both and a soft edge then stacks: half coverage under a white mask is alpha 192, not 128. `store` gives the system clipboard the image and a PNG through `QMimeData`, and counts the clipboard's `dataChanged` signals from the first copy as Swift reads the pasteboard's change count: a paste puts our pixels back in place while the count still matches, else takes another app's image through `QMimeData::imageData`, converted to sRGB first when it carries another colour space (CoreGraphics converts as it draws; `QPainter` copies the numbers), then to the working format, tagged sRGB and centred. A cleared clipboard has no `QMimeData` at all, so `canPaste` checks the pointer. A working copy that cannot be allocated is a warning, as Swift's `try?` beeps there, where a failed Layer via Copy sets `brushError` as Swift's `catch` does. The clipboard is touched only by a copy or a paste, so session tests without one run under `QTEST_GUILESS_MAIN` still; `SelectionClipboardTests` runs under `QTEST_MAIN`. `addPixelLayer`'s shape and text arrived with 8.8 and 8.9a. Swift's `cutSelection`, `PixelMove` and the whole `FloatingSelection` extension need `clearSelectedPixels` and the brush raster, so the cut arrived with 8.2 and the rest with 8.2b, where `canEditLayers` and `ProjectWorkspace::canSwitch` gained their `pixelMove` terms. The Edit menu's Copy and Paste check when chosen, as Swift's (a text field keeps its own, as with Select All); Copy Merged follows `canCopyMerged`; the Layer menu's Ctrl+J is `layerViaCopy`, titled by the selection and enabled as Swift's: `canCopyPixels`, or no selection on an editable layer that is no folder. Swift's beeps are warnings on `omaphoto.app`.
- The clipboard (7.4): `SelectionClipboard.cpp` twins Swift's extension as far as the raster edits exist. `PixelClipboard` and `CopiedPixels` (Swift's tuple) live in `Document/SelectionClipboard.h`. `renderSelectedPixels` and `renderMergedPixels` draw into the copy region and multiply the selection's coverage in with `DestinationIn` through `BrushRaster::alphaView` (`clipTo`), where Swift clips the context (`SelectionClip::apply` has no twin); a mask copies as opaque gray with `LayerMask::background` for its ground when placed apart and `LayerRenderer::drawCoverage` for its white, each pass multiplied on its own (the white in a second surface), because CoreGraphics clips both and a soft edge then stacks: half coverage under a white mask is alpha 192, not 128. `store` gives the system clipboard the image and a PNG through `QMimeData`, and keeps a `QPointer` to the `QMimeData` it hands the clipboard where Swift reads the pasteboard's change count (since 12.10b; Qt's `dataChanged` is no count, since Wayland announces one copy twice): while this app owns the clipboard Qt returns that very object (offscreen, X11's client clipboard, Wayland's selection source), any other copy, a history manager's restore of the same bytes included, is another object, and a replaced object is deleted, which nulls the pointer, so a copy once replaced never counts again, as a change count never returns; a paste puts our pixels back in place while the clipboard still holds that object (`clipboardHolds`), else takes another app's image through `QMimeData::imageData`, converted to sRGB first when it carries another colour space (CoreGraphics converts as it draws; `QPainter` copies the numbers), then to the working format, tagged sRGB and centred. A cleared clipboard has no `QMimeData` at all, so `canPaste` checks the pointer. A working copy that cannot be allocated is a warning, as Swift's `try?` beeps there, where a failed Layer via Copy sets `brushError` as Swift's `catch` does. The clipboard is touched only by a copy or a paste, so session tests without one run under `QTEST_GUILESS_MAIN` still; `SelectionClipboardTests` runs under `QTEST_MAIN`. `addPixelLayer`'s shape and text arrived with 8.8 and 8.9a. Swift's `cutSelection`, `PixelMove` and the whole `FloatingSelection` extension need `clearSelectedPixels` and the brush raster, so the cut arrived with 8.2 and the rest with 8.2b, where `canEditLayers` and `ProjectWorkspace::canSwitch` gained their `pixelMove` terms. The Edit menu's Copy and Paste check when chosen, as Swift's (a text field keeps its own, as with Select All); Copy Merged follows `canCopyMerged`; the Layer menu's Ctrl+J is `layerViaCopy`, titled by the selection and enabled as Swift's: `canCopyPixels`, or no selection on an editable layer (a folder too since 12.10b). Swift's beeps are warnings on `omaphoto.app`.
- Content-Aware Fill (7.5): `ContentFill.c` compiles in place beside the other kernels; `Document/ContentFill.cpp` includes its header in angle brackets, because the twin's own header shares the name. `ContentFill::run` draws the job's image into a premultiplied context and takes the pixels to make from `PixelAdjust::coverage` (the selection through the inverse mapping, as Swift clips the mask context and fills white), the kernel's 0 is `ContentFillError(noSource)` with Swift's message and its −1 `ExportError(render)`. `Document/Filters.h` holds what Swift's `Filters.swift` holds as far as it exists: `FilterKind` (`rawValue` and `isAutomatic` are free functions; the other kinds came with 9.4a), `FilterJob` (its settings, scale and seed came with 9.4a), `PixelFilter::run` (the kind's filter, then `PixelAdjust::blend` through the selection) and `FilterEdit`, a plain class in a `std::optional` of the session, since only the session writes it; its `id` stands for Swift's object identity, so a preview lands on the edit it was made for. `FilterEdit` grows through `grow` (the blur's growth and the preview copy came with 9.4a). The session's extension is `Filters+Session.cpp`: Swift's file holds 473 lines, its twin's two halves about 500. `canContentAwareFill` without its `isMaskSelected`, `filterEdit` and `isEmpty` terms (`canAdjustColors` holds all three; it sits in `HueSaturation.cpp`, the twin of its Swift file, alone until 9.3, with the terms that exist), `beginFilter` (`cancelCrop` waits for the crop), `updateFilter` (its settings came with 9.4a), `cancelFilter`, `commitFilter` with a completion callback, `finishFilterCommit` (Swift's guards, the mask carried through `LayerMask::clipImage`). Swift's `previewTask` is one `QFutureWatcher`: `renderFilterPreview` marks the edit preparing and starts the worker, or leaves the job pending behind the one running and runs it when that finishes, a run counting as running until its finish has run (`m_filterRendering`, as Levels' queue); a commit that awaits the preview (`isAutomatic`) is kept in `m_filterCommitWaiting` with its edit's id and called again from `finishFilterPreview` (every waiter is, and one whose edit has gone just returns); the wait is on `preparing`, which only the delivery clears; Preview off is 9.4a's; the workers catch `ContentFillError` and `ExportError` alone. `brushRevision` moves where Swift's does (a delivered preview, Preview switched either way, a cancel, a commit's end), so the canvas's `DisplayState` sees each new preview. `displayedTransform` and `displayedMaskPlacement` take the grown transform while the edit previews, and the canvas draws `previewImage` in the layer's place. `canEditLayers` and `ProjectWorkspace::canSwitch` read `filterEdit`. `FilterSheet` (a `filterPanel`'s content since 9.4b): the kind's words, Preview, the error in `BrightText`, "Limited to the selection" while a selection exists (never for an adjustment layer's editor, since 9.5d), Cancel, the spinner with "Working…" or "Applying…", and OK as the default button, disabled while an automatic kind prepares or failed, so Return then does nothing; Escape and the close button cancel, as Swift's `cancelAction` and `onClose`. The Edit menu's Content-Aware Fill… is Shift+Backspace (Swift's `.delete` is that key) and follows `canContentAwareFill`; the canvas takes Shift with Backspace or Delete ahead of the selection keys, as Swift's `keyDown` does.
- The brush on the CPU (8.1): `Rendering/BrushCoverage` twins `MetalBrushCoverage`: the shader's maths in float, pixel for pixel (the centre sample, the hard tip's antialiased silhouette over `max`, the soft tip's optical density under Gauss–Legendre quadrature per segment at the deposition spacing, the 20 cap, the permanent paint apart from the replaceable tail), one band of 32 rows a QtConcurrent task where Metal dispatched one tile a thread group. A `Tile` keeps the permanent density alone: Swift's preview buffer is the gray coverage context itself, written row by row, so nothing is copied back. A tile that cannot be allocated is `ExportError(render)`. The class with its `shared` device is a namespace. `Document/BrushStroke` keeps Swift's continuous path alone: the dab fallback for machines without Metal (`walk`, `dab`, `tip`, `stamp`, `gridTip`, `falloff`, `drawTail`, `removeTail`, `curve`, `tailBackup`, `distanceToNext`, `dirtyTiles`) is dead where the CPU coverage always exists, and `spacingFraction` stays because the integral reads it. `BrushStroke.swift` runs to 899 lines, so the twin splits: `BrushStroke.cpp` (`BrushRaster`, with `fill`, source-over of a colour at coverage × clip × alpha byte by byte on RGBA and gray alike, 1:1 by construction), `BrushStroke+Stroke.cpp` (the grid, samples, the centripetal Catmull–Rom subdivided within 0.2 px, keys, tiles, coverage and `publish`) and `BrushStroke+Commit.cpp` (`transform(for:)`, `paintSnapshot`, `commitInput`, `BrushCommit` as a namespace, since Swift's actor only serialises). The grid is worked out first (`Grid`) so the const members can follow Swift's `let`s; Swift's guards throw `ProjectError(tooLarge)` as one, and the extent is judged as a double before it becomes an `int`, because past `int` the conversion is undefined where Swift's `Int` just holds the number. The stroke's source is the asset itself (`m_source`, an `ImportedImage`), so a painted layer draws from its tiles and never flattens; `commitInput` hands the asset on, and `BrushCommit::render` draws it the same way. A selection clips the context in Swift; `fill` takes the selection's `PixelAdjust::coverage` on the tile's grid as its clip and rounds coverage × clip × alpha once, as CoreGraphics composites a clipped context. A commit whose crop cannot be allocated is `ExportError(render)`, as Swift's `cropping` returning nil; so is a tile's context that cannot be copied on publish (`QImage::detach` leaves a null image where CoreGraphics' `makeImage` returns nil). Tile keys (row × columns + column) are `qint64`, Swift's `Int`: a small layer scaled to a point across a big canvas passes `int`. A tile is rebuilt whole on every publish (`dirtyTiles` only ever narrowed the dab fallback's rebuilds); `base` is the tile's first pixels, kept apart by QImage's copy on write. `BrushSettings` holds the tip and colour; `erasing`, `healing` and `healingMode` arrive with 8.2 and 8.4, `selectionClip` is here since `publish` reads it, `clone`, `isBlur`, `replacesWithClone`, `editName`, `fillGradient`, `fill`, `clearPixels`, `paintCanvas`, `liftSelection`, `moveLifted` and `heal` with their items. `BrushIntersectionTests` runs the one path where Swift loops over `useGPU`; `BrushTests` holds the stroke-level halves of Swift's session tests, driven on a `BrushStroke` made from a layer, with `LayerRenderer::drawBrushPreview` as the live view and `BrushCommit::render` as the commit; the session halves arrive with 8.2. The early return of a segment past the tip's radius is an equivalent mutant: the density there is zero anyway.
- The session's brush (8.2): `EditorSession+Brush.cpp` twins Swift's extension as far as the tools exist: `canPaint` with the terms that exist (the adjustment term since 9.5a), `makeRasterEdit` (the budgets, the selection clip; `pixelLimit` is `qint64`, Swift's `Int`, and may run below zero when other layers use more than the budget), `beginBrush` for the Brush (Spot Healing joins with 8.4, the other brush tools with their items; erase and the mask's black or white as Swift sets them), `continueBrush`, `shiftLineStart`, `cancelBrush`, `finishBrushImmediately` (busy, it waits; otherwise it flushes and commits the snapshot), `finishBrush` (Swift's `async` wrapper is a plain call here), `commitPaintSnapshot` (the tiles land at once with their undo entry; a mask that covered the old grid is regrown through `RasterSnapshot::replacing`), `commitRasterEdit` (a completion callback, the render through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, the worker catching `ExportError` alone, the UI side keeping Swift's guards and the mask's `isEnabled` as it stands at commit), `usesOpacityKeys`, `typeOpacityDigit` (Swift's uptime is `std::chrono::steady_clock` unless the caller passes a time, as the tests do), `changeBrushHardness`, `changeBrushSize`. `brushStroke` is a `std::unique_ptr` read through a pointer, since a stroke's const members forbid copying; `brushSettings`, `brushMode` (`BrushToolMode` in `Document/SmudgeLiquify.h`, whose smear and liquify arrive with 8.6) and `maskPaintWhite` are getters and setters that notify; `lastBrushPoint` and `pendingOpacityDigit` are private structs. `brushRevision` exists after all, Swift's counter, because the canvas's `DisplayState` must change with every step of a stroke, whose patches it does not hold; a step that changes nothing else repaints the stroke's `dirtyDocumentRect` alone, in view points two points out (`strokeDirtyRect`), as Swift's `setNeedsDisplay` does, and any other change repaints the view. While a folder's mask is painted, the clip for the layers inside comes from the edit's tiles (`liveFolderMaskClip`, Swift's `liveMaskEdit`): `drawBrushPreview` draws the old mask and the patches, nearest, into gray coverage the size of the device, hidden outside the mask's grid, and that multiplies into the folder coverage; the old mask, or a painted mask's base, is halved near the size drawn first, as Swift's `displayImage` does, so a fine mask keeps its shade while painted. The session's private structs (`Decoded`, `Wanded`, `RasterMade` and the rest) live in `EditorSession+Jobs.h` as `SessionJobs` and are aliased in the class, so the header stays under 500 lines. The gates (`canInvert` and `canAdjustColors` among them), `selectLayer`, `selectLayers`, `selectTool` and `selectLayerTarget` refuse while a stroke lasts, `canStartProjectOperation` too, and `importImages` finishes a stroke first, as Swift's do; the tips' parking by tool family arrived with 8.5. `SelectionEdits.cpp` gains `canEditPixels`, `fillSelection` (the palette's colours are black and white until 8.10's `ColorPalette`; on a mask the background is the opposite of `maskPaintWhite`, as Swift's `paletteColor`), `clearSelectedPixels`, `deleteKeyPressed` (its effects term since 9.6a) and `applyPixelEdit`; `cutSelection` sits in `SelectionClipboard.cpp`. `BrushStroke` gains `erasing` in its settings (`BrushRaster::fill` takes `DestinationOut` for it, Swift's blend mode on the context; a mask never erases, so that mode on a gray context is a `logic_error`), `paintCanvas` (the base redrawn, the body under the inverse mapping and a hard canvas clip, then `PixelAdjust::blend` through the selection where Swift clips), `fill` and `clearPixels`; `placedMaskPreview` is declared in `BrushStroke.h` and defined in `LayerMask.cpp`, as Swift's extension, and a preview that cannot be made logs on `omaphoto.rendering` where Swift's `try?` is silent. On the canvas a pixel stroke draws its whole grid placed by `paintTransform` through `TiledLayerRenderer::drawStroke`, a mask stroke through `drawMaskStroke` under the layer's transform, and a mask on its own placement draws the layer through `placedMaskPreview`; the mask's clip takes the stroke's owner as Swift's `base` does. Delete and Backspace without Ctrl, Alt or Meta are `deleteKeyPressed` on the canvas and, plain, in the layer list; Shift with them stays the fill's. The Edit menu gains Cut (Ctrl+X, a text field's own), Fill with Foreground Color (Alt+Backspace) and Fill with Background Color (Ctrl+Backspace), which in a text field delete a word or the line as Swift's `deleteWordBackward` and `deleteToBeginningOfLine` (the selection when there is one, else backwards from the caret, so nothing goes at the start) (reachable from the opacity field alone: a rename or an empty document disables them, as `canEditPixels` does), and Clear Selection Pixels. `tests/BrushFixtures.h` shares the fixtures; `BrushSessionTests` and `PixelEditTests` hold the session halvLine truncated
- The session's brush (8.2): `EditorSession+Brush.cpp` twins Swift's extension as far as the tools exist: `canPaint` with the terms that exist (the adjustment term since 9.5a), `makeRasterEdit` (the budgets, the selection clip; `pixelLimit` is `qint64`, Swift's `Int`, and may run below zero when other layers use more than the budget), `beginBrush` for the Brush (Spot Healing joins with 8.4, the other brush tools with their items; erase and the mask's black or white as Swift sets them), `continueBrush`, `shiftLineStart`, `cancelBrush`, `finishBrushImmediately` (busy, it waits; otherwise it flushes and commits the snapshot), `finishBrush` (Swift's `async` wrapper is a plain call here), `commitPaintSnapshot` (the tiles land at once with their undo entry; a mask that covered the old grid is regrown through `RasterSnapshot::replacing`), `commitRasterEdit` (a completion callback, the render through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, the worker catching `ExportError` alone, the UI side keeping Swift's guards and the mask's `isEnabled` as it stands at commit; its continuation runs where the commit ends, not from the event loop, because Swift's caller resumes from `await` in the same job: `commitGradient` cancels the gradient and runs its caller's next step (a filter, Levels, Hue/Saturation or Invert begun on the same layer), a fill or clear hands on to its caller (`beginSelectionTransform` installs the floating edit), and the pixel move clears itself, all before any event lands; only early paths (nothing to commit, a busy project) post, as Swift's `Task` hops (since 13.19g: a press could arrive with the project free and the finished gradient still pending, which refused it; `CommitContinuationTests`)), `usesOpacityKeys`, `typeOpacityDigit` (Swift's uptime is `std::chrono::steady_clock` unless the caller passes a time, as the tests do), `changeBrushHardness`, `changeBrushSize`. `brushStroke` is a `std::unique_ptr` read through a pointer, since a stroke's const members forbid copying; `brushSettings`, `brushMode` (`BrushToolMode` in `Document/SmudgeLiquify.h`, whose smear and liquify arrive with 8.6) and `maskPaintWhite` are getters and setters that notify; `lastBrushPoint` and `pendingOpacityDigit` are private structs. `brushRevision` exists after all, Swift's counter, because the canvas's `DisplayState` must change with every step of a stroke, whose patches it does not hold; a step that changes nothing else repaints the stroke's `dirtyDocumentRect` alone, in view points two points out (`strokeDirtyRect`), as Swift's `setNeedsDisplay` does, and any other change repaints the view. While a folder's mask is painted, the clip for the layers inside comes from the edit's tiles (`liveFolderMaskClip`, Swift's `liveMaskEdit`): `drawBrushPreview` draws the old mask and the patches, nearest, into gray coverage the size of the device, hidden outside the mask's grid, and that multiplies into the folder coverage; the old mask, or a painted mask's base, is halved near the size drawn first, as Swift's `displayImage` does, so a fine mask keeps its shade while painted. The session's private structs (`Decoded`, `Wanded`, `RasterMade` and the rest) live in `EditorSession+Jobs.h` as `SessionJobs` and are aliased in the class, so the header stays under 500 lines. The gates (`canInvert` and `canAdjustColors` among them), `selectLayer`, `selectLayers`, `selectTool` and `selectLayerTarget` refuse while a stroke lasts, `canStartProjectOperation` too, and `importImages` finishes a stroke first, as Swift's do; the tips' parking by tool family arrived with 8.5. `SelectionEdits.cpp` gains `canEditPixels`, `fillSelection` (the palette's colours are black and white until 8.10's `ColorPalette`; on a mask the background is the opposite of `maskPaintWhite`, as Swift's `paletteColor`), `clearSelectedPixels`, `deleteKeyPressed` (its effects term since 9.6a) and `applyPixelEdit`; `cutSelection` sits in `SelectionClipboard.cpp`. `BrushStroke` gains `erasing` in its settings (`BrushRaster::fill` takes `DestinationOut` for it, Swift's blend mode on the context; a mask never erases, so that mode on a gray context is a `logic_error`), `paintCanvas` (the base redrawn, the body under the inverse mapping and a hard canvas clip, then `PixelAdjust::blend` through the selection where Swift clips), `fill` and `clearPixels`; `placedMaskPreview` is declared in `BrushStroke.h` and defined in `LayerMask.cpp`, as Swift's extension, and a preview that cannot be made logs on `omaphoto.rendering` where Swift's `try?` is silent. On the canvas a pixel stroke draws its whole grid placed by `paintTransform` through `TiledLayerRenderer::drawStroke`, a mask stroke through `drawMaskStroke` under the layer's transform, and a mask on its own placement draws the layer through `placedMaskPreview`; the mask's clip takes the stroke's owner as Swift's `base` does. Delete and Backspace without Ctrl, Alt or Meta are `deleteKeyPressed` on the canvas and, plaLine truncated
- Moving and floating selected pixels (8.2b): `PixelMove` is a class in `Document/SelectionEdits.h`, Swift's `final class`. Its raster is a `std::shared_ptr<BrushStroke>`, because the canvas draws it while the commit runs, so `commitRasterEdit` takes a `std::shared_ptr<const BrushStroke>` (the brush hands its stroke over); its `offset` starts at zero, since a default `QSizeF` is −1 by −1. `beginPixelMove`, `movePixels`, `finishPixelMove` (a completion callback; the move stays set until the commit lands, so pixels and outline never show at the old place), `cancelPixelMove` and `nudgePixels` (Swift's beep is a warning on `omaphoto.app`) live in `SelectionEdits.cpp`; `displayedSelection` follows a pixel move and a floating edit. `BrushStroke::liftSelection` draws the source into the region, from its tiles when painted, and multiplies the selection's coverage in (`PixelAdjust::coverage` under `DestinationIn`) where Swift clips; `moveLifted` rebuilds each tile from its base, cuts the hole through the same coverage under `DestinationOut` (not for a copy) and places the lifted pixels on whole pixels: a landing between pixels (a scaled or turned layer) is resampled first by its fraction, bilinear and rows in parallel (`BrushRaster::shiftedByFraction`, shared with Clone Stamp), because QPainter snaps an unscaled image to whole pixels (probed on 6.4), where Swift draws it with `.high`. `FloatingSelection.cpp` twins Swift's file: `FloatingTransform` rides in `TransformEdit.floating` (last) as a `std::shared_ptr<const FloatingTransform>`, because the edit is copied freely and holds the whole document from before; `canTransformSelection`, `transformCommand`, `beginSelectionTransform` (a completion callback: the outer `Transform Selection` edit opens, `clearSelectedPixels` runs, and its callback inserts the `Floating Selection` layer), `floatingSelectionTransform`, `mergeFloatingTransform`, `cancelFloatingTransform`, and `FloatingMerge::merge`, a namespace with a `Merged` struct for Swift's tuple. The merge writes pixels, transform and mask into the source layer in place, where Swift rebuilds the layer from listed fields: the fields that list leaves out do not exist yet (`TASKS.md`, "Session hooks owed"). `DistortWarp::mapPath` came forward from 6.2's list: it walks the path's elements (Qt stores a quad as a cubic) and keeps the fill rule. `beginPixelMove` and `canTransformSelection` leave out Swift's `pixelMove == nil`, `transformEdit == nil` and `!selection.isEmpty`, which `canPaint` reads already (through `canEditPixels`). `displayedMaskPlacement`'s `floating == nil` term is dead and left out: the floating layer is made without a mask, and the open edit blocks adding one; `beginDistort` edits the edit in place, so `floating` carries over. The session's private structs sit in `SessionJobs`, now a private base struct of `EditorSession` (the aliases went; `MergePlan` and `TransformDuplicate` joined it), so the header stays under 500 lines. On the canvas a Ctrl-press inside the selection with a selection tool lifts and moves its pixels, Ctrl-Alt copies them, under `movePixelsCursor` (the arrow with scissors drawn as paths and haloed, where Swift badges the SF symbol) or the duplicate cursor, kept under Space as every drag keeps its own; `updateCursor` picks the outline and pixel drags' cursors ahead of Space, so `lassoCursor`, its only caller's helper, checks neither (Swift's has other callers); Ctrl-arrows nudge the selected pixels in any tool, Shift ten, never with Alt or Meta (macOS's Option and Control), ahead of the Move tool's arrows; losing focus cancels a pixel drag; `drawOwn` draws the move's raster as a stroke, and a step repaints the whole view, as Swift's (only a brush stroke repaints its rect). The Layer menu's Ctrl+T is `transformCommand`, titled `Transform Selection` while `canTransformSelection` holds and enabled by either gate. `canEditLayers`, `canInvert`, `canAdjustColors` and `ProjectWorkspace::canSwitch` read `pixelMove`.
- The brush on the canvas (8.3): `EditorCanvas+Brush.cpp` holds Swift's gestures. A press begins a stroke, Shift drawing on from `shiftLineStart` through the press point; a drag continues it, and Shift holds an axis that the first three pixels from its anchor settle (the press under Shift, else the last pixel reached), the pixel waiting at the anchor until then. Swift clears the axis when Shift is let go and again when a new anchor is set; the first is dead and left out. The release reaches its own point, then `finishBrushImmediately` commits. A busy project holds the stroke: a drag, the release, Escape and a lost focus leave it. A stroke goes on only under a drag with the left button, as Swift's `mouseDragged` (a hover after a busy release paints nothing), and wheels and pinches neither pan nor zoom while it lasts, as Swift's `scrollWheel` and `magnify` refuse. A right-drag with a brush tool, no stroke and no Space, sizes the tip from the press (two view points a pixel, rounded, 1 to 2000), or with Shift sets hardness over 200 points, each putting the other back; the circle stays at the press. `BrushCursorOverlay` is no widget, like `TransformOverlay`: the canvas draws the circle last, white 2.5 under black 1, and while hardness is dragged a dashed inner ring (four points on, three off, given in pen widths as Qt counts dashes); `update` returns the old and new rings three points out, as Swift's `setNeedsDisplay`. Swift's `hardness > 0` is dead (a zero ellipse strokes nothing) and left out. The circle follows the pointer, hover included but not a pan (Swift's `mouseDragged` pans first), hides under Space, on leaving and on a lost focus, and shows with the brush tools alone. The canvas's `event` takes Tab before the focus chain (Shift-Tab stays the chain's) for `cycleToolMode`, which switches the Marquee's and the Lasso's shapes, the Brush's mode and Spot Healing's type (the other tools' modes arrive with them); B and E choose the Brush and its mode; a digit reaches `typeOpacityDigit` only where `usesOpacityKeys` holds, else it goes on to the parent, as Swift's `super.keyDown`; brackets step size and hardness, and the application filter takes them from any control but a text field unless the canvas has focus, so a focused canvas's `keyPressEvent` re-reads the modifiers as Swift's `keyDown` does. The layer list takes Tab and Shift-Tab (Swift's table cycles on both), B, E and digits alike. `BrushControls` is every brush tool's `ToolHeaderBar`: Paint and Erase in an exclusive group, size (1–2000), hardness and opacity as a slider in thousandths beside a whole-percent field (opacity from 1%), the sliders following the session under `QSignalBlocker` so nothing rounded is written back, the fields stepping with Up and Down from the session's exact value as Swift's `arrowSteps` does (a slider's 33.3% steps to 34.3%; `SelectionAmountField` takes Swift's `value` and `change` closures, and the Lasso and Wand fields round in `change` as theirs do, an equivalent mutant since their values are whole), and the mask's black-or-white picker and note while a mask is selected; the colour swatch arrived with 8.10a. `ContentView+Status.cpp` holds `percent`, `iconKind` and `hint`, the eraser's words and icon among them; `iconKind` checks its tool although every consumer ignores a kind that is not its own (an equivalent mutant). `updateCursor` and the modifier helpers moved to `EditorCanvas+Cursors.cpp`. `ContentView`'s destructor stops listening first: `~QWidget` closes a shown window after the members are gone, and the canvas's focus-out then notifies (`cancelBrush` bumps `brushRevision`, as Swift's does).
- Brush speed (8.3's gate: 4K at a scale of 2, an 800 px soft brush, median frame at most 8 ms): 6.8–7.4 ms measured, from 47. The soft tip's density comes from a table of 8192 steps over d²/r² from h² to the rim, in radii because hardness alone shapes it, linear between steps; inside h the tip is solid, the table's first value, so a near-hard tip keeps every step across its fade (from 0.9995 on, a table over the whole radius missed the falloff by up to 46 levels). Once the table's steps are finer than float can tell apart ((1 − h²) / 8192 under float's epsilon, from about 0.9995), even that table misses by a few levels: there `tipDensity` evaluates the shader's own formula, float for float, for dabs and segments alike, its solid and empty ends returning at once what the formula yields, so only samples in the thin fade pay for `exp` and `log`. `BrushCoverageTests` holds dabs and strokes on both sides of the switch against the shader's own quadrature, replayed in float. The kernel keeps the shader's order of float operations to the letter (`midpoint ± halfLength · node − projection`, never regrouped): 8192 pixels along a near-hard stroke a regrouping moved a pixel three levels, and a reference written from the twin's own code repeated it. Its one read per lookup is checked at the upper step, so a lost bound aborts. Only a segment passing through the solid core can look up inside it, so the lower clamp runs for those alone (`quadrature<true>`): for every segment it cost 0.3 ms a frame. Bands of 32 rows are the tasks. The halving's bands take their rows, sums and source row from the caller's scratch, so a worker allocates nothing and a failed allocation throws where the caller catches it (QtConcurrent wraps a worker's exception in `QUnhandledException`). `publish` copies each tile and makes its selection coverage on the calling thread, so a failure throws there and a worker's malloc arena never meets a starved allocator, then fills side by side. Pieces compose and halve side by side (`sideBySide`), each halving unthreaded: Qt 6.4 deadlocks on a `blockingMap` inside a map task. The Lanczos colour rows sum four channels in a GCC vector (`vector_size(16)`), each lane as the scalar sum, and the vertical pass runs tap by tap over whole rows; `BrushCoverage.cpp` and `DownsampleCache.cpp` build at `-O3`, where GCC vectorises them. The shadow is skipped when the dirty rect lies inside the document, whose opaque fill covers it. Equivalent mutants: that skip, `fill`'s skip of a zero weight, the colour row's right clamp (it only ever reads padding), the table's interpolation (under a third of a level), its lower clamp at step 1 (the first steps of every fade sit at the density's cap), the clamped quadrature for every segment, the formula for every hardness and `tipDensity`'s two early returns (each only costs time). `BrushPerformanceTests` runs under `BRUSH_BENCHMARK=1`.
- Canvas speed (11.1): `CanvasPerformanceTests` (under `CANVAS_BENCHMARK=1`, at a backing scale of two, as the brush benchmark) pans a 1000-point canvas by trackpad deltas through the canvas's own wheel path, at fit and at Actual Pixels (`zoom(1)`, as the menu sets it), and zooms it in 5% steps around its middle, over a 3840 × 2160 document of ten noise layers (photographs and halves, opacities, multiply, screen, overlay and two Hue layers). Measured on the dev container (32 cores): pan at fit 30 ms median (p95 37), pan at Actual Pixels 68 ms (p95 74), zoom 77 ms (p95 86); with every layer normal 16, 15 and 29 ms. The hand-blended HSL modes are the cost: `HslBlend::blend` ran one thread over the layer's device area, 40 ms a Hue layer at fit; its rows now blend side by side (pointers taken first, since `scanLine` detaches), the same bytes, 3× faster (pan at fit 85 → 30 ms), and the mode is checked before the rows start, since a worker's exception comes back wrapped. At Actual Pixels a Hue layer still costs about 25 ms at 2× (the backdrop's copy and conversion, the enlarged surface and its draw back); the rest is QPainter's single-threaded smooth scaling, about 40 megapixels a frame at this size. Painting the canvas in parallel bands would need its caches (pieces, strokes, effects previews) made safe across threads, and is not done. The guards sit at half again these medians (45, 100 and 115 ms), so a regression fails. Compared with `docs/brush-performance.md`: Swift measures strokes only (2.6–2.8 ms median on macOS); the twin's stroke gate is 8.3's (6.8–7.4 ms at 4K, scale two), and Swift has no pan or zoom figures to compare. `Rendering/PoolMap` (Linux-only) wraps every `QtConcurrent::blockingMap`: Qt 6.4's deadlocks when a pool thread waits on a full pool (a worker's blur or blend on one core, or two workers on two), so a pool thread lends its place while it waits (`releaseThread`, then `reserveThread`, Qt's documented remedy); `PoolMapTests` runs the blend, the blur and a map on a pool of one thread.
- Desktop integration (11.2): the app id is `io.github.ZacharyZhang_NY.OmaPhoto` (Compositor's was its bundle identifier, `com.wonderassembly.compositor`), which names the desktop entry, the icon and the MIME package, and which `main` gives Qt as the desktop file name, so a Wayland compositor matches the window to its entry and icon; on X11 Qt 6.4 names the window class after the application (`omaphoto`, `OmaPhoto`), so the entry's `StartupWMClass` is `OmaPhoto`. `packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop` launches `omaphoto %F` (files named at launch reach `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate) and lists Swift's two document types: its projects (`application/x-compositor-project`) and JPEG, PNG, HEIC and TIFF. `io.github.ZacharyZhang_NY.OmaPhoto.xml` declares the project type for shared-mime-info, a folder (`inode/directory`) named `*.comp`, commented "OmaPhoto Project", where Swift says "Compositor Project"; a file manager may still open such a folder as a folder, since a project is a directory on Linux. `cmake --install` lays down the app, the entry, the MIME package, Compositor's app icon (`packaging/icons`) at six hicolor sizes (16 to 512) and U²-Net's licence under `share/licenses/omaphoto`; `OMAPHOTO_MODEL`, a path, installs the model as `share/omaphoto/u2net.onnx` whatever its own name, the file itself when given a link (`file(REAL_PATH)`), where `SubjectRemoval` looks, and packages pass it (11.3). A direct install (no `DESTDIR`) runs `update-mime-database` on its prefix, required, so the project type registers at once; a staged install leaves that to the package's hooks. `DesktopIntegrationTests` reads both files, stages an install and compares the whole file list (the model included when the build was configured with one, read from its cache), and installs directly with a stand-in `update-mime-database` first on the path and any `DESTDIR` of the caller cleared; the call in `main` is not under test.
- Desktop integration (11.2): the app id is `io.github.ZacharyZhang_NY.OmaPhoto` (Compositor's was its bundle identifier, `com.wonderassembly.compositor`), which names the desktop entry, the icon and the MIME package, and which `main` gives Qt as the desktop file name, so a Wayland compositor matches the window to its entry and icon; on X11 Qt 6.4 names the window class after the application (`omaphoto`, `OmaPhoto`), so the entry's `StartupWMClass` is `OmaPhoto`. `packaging/io.github.ZacharyZhang_NY.OmaPhoto.desktop` launches `omaphoto %F` (files named at launch reach `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate) and lists Swift's two document types: its projects (`application/x-compositor-project`) and its pictures (JPEG, PNG, HEIC, TIFF, WebP since 14.23, and the types later items add). `io.github.ZacharyZhang_NY.OmaPhoto.xml` declares the project type for shared-mime-info, a folder (`inode/directory`) named `*.comp`, commented "OmaPhoto Project", where Swift says "Compositor Project"; a file manager may still open such a folder as a folder, since a project is a directory on Linux. `cmake --install` lays down the app, the entry, the MIME package, Compositor's app icon (`packaging/icons`) at six hicolor sizes (16 to 512) and U²-Net's licence under `share/licenses/omaphoto`; `OMAPHOTO_MODEL`, a path, installs the model as `share/omaphoto/u2net.onnx` whatever its own name, the file itself when given a link (`file(REAL_PATH)`), where `SubjectRemoval` looks, and packages pass it (11.3). A direct install (no `DESTDIR`) runs `update-mime-database` on its prefix, required, so the project type registers at once; a staged install leaves that to the package's hooks. `DesktopIntegrationTests` reads both files, stages an install and compares the whole file list (the model included when the build was configured with one, read from its cache), and installs directly with a stand-in `update-mime-database` first on the path and any `DESTDIR` of the caller cleared; the call in `main` is not under test.
- The acceptance run (11.5): `AcceptanceTests`, `AcceptanceColorTests` and `AcceptanceProjectTests` drive the app's own window as `main` builds it (`AcceptanceFixtures.h`'s `App`: an `OmarchyTheme` on a folder of the test's own, the workspace, the window at 1180 × 780), one scenario for each row of the README's feature list, through menu entries, canvas gestures, sheets and the real panels (`DialogDesk`), and check pixels, layers, sizes and files; `AcceptanceNewTests`, `AcceptanceEditTests` and `AcceptanceFinishTests` (13.19h) do the same for each row new in 1.4.5. With `ACCEPTANCE_SHOTS` naming a folder, each scenario saves the window with its shown floating panels drawn over it at their places (offscreen draws no title bars), after 300 ms of events, since posted geometry and previews land from the event loop; `docs/acceptance/` holds the committed shots and `docs/acceptance.md` the table. A fix the run found: `~CompositorMenus` disconnects from `QApplication::focusChanged` first, because its Keyboard Shortcuts panel, a member, hides as it goes and moves the focus into the half-destroyed menus (quitting with that panel active crashed); `KeyboardShortcutsSheetTests::theWindowGoesWhileThePanelHoldsTheKeys` holds it. So does `~ProjectWorkspaceView` with its session watch: a floating panel whose field holds typing (the colour picker, Levels, Hue/Saturation, a filter) commits as it hides, and the session's signal reached the window's toolbar and tab strip, already gone; `WindowTeardownTests` holds all four.
- Packages (11.3): one CMake install serves every package. It lays down the app, its entry, MIME package, icons and both licences (the app's MIT `LICENSE` and U²-Net's, under `share/licenses/omaphoto`), and `OMAPHOTO_MODEL` adds the model. `OMAPHOTO_BUNDLE_ORT` (off by default) installs ONNX Runtime beside the app in `lib/omaphoto` (under the library folder) with the app's run path set there, and Microsoft's `LICENSE` and `ThirdPartyNotices.txt` with it (`OMAPHOTO_ORT_NOTICES`, where the images keep them from the release; a configure that cannot find them fails). Every package bundles (11.4d, issue #5): ONNX Runtime stamps each release's symbols with its version (`VERS_1.20.1`), so a package linked against a system's runtime refuses to install, or to start, once that system updates it. The DEB, the RPM and the Arch package carry Microsoft's 1.21.0 build, pinned by checksum, which the dev, Fedora and Arch images install into `/usr/local` too, so the tests run against the runtime that ships; the flake builds from nixpkgs' own, since Nix rebuilds its closure with it. `DesktopIntegrationTests` expects the runtime (in the configured library folder), its version link and its notices in a bundling build, as it expects the model in a build given one, reading what CMake itself evaluated through compile definitions (both checked by hand in a second build). CPack makes the DEB (Ubuntu) and the RPM (Fedora), each on its own system: the DEB's library dependencies come from `dpkg-shlibdeps`, which finds the bundled runtime through the run path, and it names what no library does (`libheif-plugin-libde265` for HEVC, `qt6-image-formats-plugins` for TIFF, `shared-mime-info`, `hicolor-icon-theme`); the RPM asks for `qt6-qtimageformats`, `shared-mime-info` and `hicolor-icon-theme`, its libraries found by rpmbuild but the bundled runtime left out of what it provides and requires (`__provides_exclude_from`, `__requires_exclude` in `CPACK_RPM_SPEC_MORE_DEFINE`), and suggests RPM Fusion's `libheif-freeworld`, since Fedora's own libheif decodes no HEVC. Packages stage their install (`DESTDIR`), so their systems' hooks and triggers refresh the MIME and icon caches. `Dockerfile.dev` gains `dpkg-dev`, `file` and `fakeroot` for the DEB. `packaging/arch/PKGBUILD` builds this checkout for Arch and Omarchy (`makepkg` there), fetching the model and Microsoft's runtime by their checksums; its `prepare()` lays the runtime out as that build's CMake config expects (`lib64`, `include/onnxruntime` beside `lib/cmake`) and the package bundles it. `flake.nix` sits at the repository root; it builds the app alone and installs staged (`DESTDIR=/`) so the store holds no MIME cache. The DEB was built and read back in the dev image; the RPM, the PKGBUILD and the flake are built on their own systems in 11.4.
- Distro checks (11.4): `scripts/distro-check.sh arch|fedora|nixos|resolute` builds `scripts/distro/<name>/Dockerfile` (for `resolute`, `Dockerfile.dev` on Ubuntu 26.04; Fedora is 43 since 11.4d) and runs dev.sh's configure, build and full test run in it, warnings as errors, as uid 1000 (root ignores the read-only folders and unreadable files the tests make), the tree mounted read-only and `HOME` and `TMPDIR` on a tmpfs (a sync on the container's overlay waits on the host's disk); NixOS builds the flake's package (nixpkgs pinned by the committed `flake.lock`) as root, since Nix writes its store, then runs the tests in its `devShells.default` as uid 1000 (`chroot --userspec`, with `HOME` and `TMPDIR` put back, since `nix develop` points `TMPDIR` at a folder root made); the shell sets the plugin path, the model's data path and a fontconfig file of DejaVu with fontconfig's own rules from the store, since NixOS keeps them in `/etc/fonts/conf.d`, which a container lacks. Every image carries DejaVu Sans, Serif and Mono, the faces the tests name (Fedora splits them into three packages). Ubuntu 24.04 is the dev image itself. What newer Qt and GCC asked for: `QImage::flipped` from Qt 6.9 where 6.4 has `mirrored`; `Q_OBJECT` on every class a test finds with `findChild` (Qt 6.11 refuses one without); `DistortWarp` projects points itself (`project` in `Distort.cpp`), since `QTransform::map` clamps w for points past the horizon from Qt 6.10 (6.10.2 and 6.11.2 probed; 6.4 divides), where Swift divides whatever its sign, and `DistortTests` pins it past the horizon (on 6.4 the two agree, so a mutant there is equivalent); a local pragma for GCC 16's false `-Wmismatched-new-delete` in `DownsampleTests`' replaced operators. Tests follow the systems where only a message or a count differs: libheif 1.23's longer messages, Qt 6.9's TIFF plugin logging libtiff's errors as criticals, fonts named rather than counted, the Type icon's letters in the system's own sans (Fedora's is Noto: the icon sheet compares them only under DejaVu Sans), and `QT_FORCE_STDERR_LOGGING` for the launched app, since a journald-built Qt logs to the journal when stderr is no console. The five starvation tests place their failure by Qt 6.4's own allocations and skip on any other Qt (`placesStarvation` in `AddressSpaceLimit.h`); the limit itself holds there. `ProjectExportFailureTests` opens a panel and an alert once in `initTestCase`, before any limit, as the app's window has before any export: a snapshot that fails opens its alert while the limit holds, and on every system that alert was the process's first font lookup and the panel's first open, costs the app never meets there. In the NixOS shell, which reads fontconfig's whole `conf.d` without a cache, the lookup's failed allocation segfaulted inside fontconfig (`FcHashTableFind`, found under gdb); on the dev image a panel test run alone ran out inside the panel's first open. `theJPEGRenderTakesTheSnapshotWithoutACopy` holds the JPEG render's snapshot to a move under a 2 MB limit, as `thePanelOpensWithoutCopyingTheSnapshot` holds the PNG panel's. The flake leaves out `dev.sh`'s `build/`, which nixpkgs' CMake hook would take for its own build folder.
- Distro checks (11.4): `scripts/distro-check.sh arch|fedora|fedora44|nixos|resolute` builds `scripts/distro/<name>/Dockerfile` (for `resolute`, `Dockerfile.dev` on Ubuntu 26.04; `fedora` is 43 since 11.4d, `fedora44` the same file on 44 since 12.16) and runs dev.sh's configure, build and full test run in it, warnings as errors, as uid 1000 (root ignores the read-only folders and unreadable files the tests make), the tree mounted read-only and `HOME` and `TMPDIR` on a tmpfs (a sync on the container's overlay waits on the host's disk); NixOS builds the flake's package (nixpkgs pinned by the committed `flake.lock`) as root, since Nix writes its store, then runs the tests in its `devShells.default` as uid 1000 (`chroot --userspec`, with `HOME` and `TMPDIR` put back, since `nix develop` points `TMPDIR` at a folder root made); the shell sets the plugin path, the model's data path with shared-mime-info's beside it (a NixOS system has the database in its profile through `xdg.mime.enable`; without it Qt knows no camera RAW type, missed until 12.16) and a fontconfig file of DejaVu with fontconfig's own rules from the store, since NixOS keeps them in `/etc/fonts/conf.d`, which a container lacks. Every image carries DejaVu Sans, Serif and Mono, the faces the tests name (Fedora splits them into three packages). Ubuntu 24.04 is the dev image itself. What newer Qt and GCC asked for: `QImage::flipped` from Qt 6.9 where 6.4 has `mirrored`; `Q_OBJECT` on every class a test finds with `findChild` (Qt 6.11 refuses one without); `DistortWarp` projects points itself (`project` in `Distort.cpp`), since `QTransform::map` clamps w for points past the horizon from Qt 6.10 (6.10.2 and 6.11.2 probed; 6.4 divides), where Swift divides whatever its sign, and `DistortTests` pins it past the horizon (on 6.4 the two agree, so a mutant there is equivalent); a local pragma for GCC 16's false `-Wmismatched-new-delete` in `DownsampleTests`' replaced operators; GCC 16.2's false `-Warray-bounds` on `qFromBigEndian` over a `QByteArray`'s bytes past a size guard (Arch, 14.31): the PSD adjustments read their big-endian words byte by byte; Qt 6.12 (Arch, 14.31) adds a `QPointF` constructor for drag events and deprecates the `QPoint` one, which 6.4 alone has, so the tests build theirs through `DragAt` (`tests/DragAt.h`), the type the Qt at hand takes; from Qt 6.10 a `QComboBox` emits `highlighted` when its current item changes with the popup closed and closes on Escape without calling `hidePopup` (both probed in 13.19i), so `TypeFontPicker` and `BlendModePicker` preview only while the popup shows and end the preview on the popup window's `Hide` event, which every version sends on every close (on 6.10 a programmatic highlight had saved an original that a later close put back over typed text, and Escape had kept the preview). Tests follow the systems where only a message or a count differs (`GridSettingsSheetTests` sets an English default locale, since Qt 6.10's C locale groups no digits): libheif 1.23's longer messages, Qt 6.9's TIFF plugin logging libtiff's errors as criticals, fonts named rather than counted, the Type icon's letters in the system's own sans (Fedora's is Noto: the icon sheet compares them only under DejaVu Sans), and `QT_FORCE_STDERR_LOGGING` for the launched app, since a journald-built Qt logs to the journal when stderr is no console. The six starvation tests place their failure by Qt 6.4's own allocations and skip on any other Qt (Dither's since 13.19i, where Qt 6.10 converted on threads the limit starved first) (`placesStarvation` in `AddressSpaceLimit.h`); the limit itself holds there. `ProjectExportFailureTests` opens a panel and an alert once in `initTestCase`, before any limit, as the app's window has before any export: a snapshot that fails opens its alert while the limit holds, and on every system that alert was the process's first font lookup and the panel's first open, costs the app never meets there. In the NixOS shell, which reads fontconfig's whole `conf.d` without a cache, the lookup's failed allocation segfaulted inside fontconfig (`FcHashTableFind`, found under gdb); on the dev image a panel test run alone ran out inside the panel's first open. `theJPEGRenderTakesTheSnapshotWithoutACopy` holds the JPEG render's snapshot to a move under a 2 MB limit, as `thePanelOpensWithoutCopyingTheSnapshot` holds the PNG panel's. The flake leaves out `dev.sh`'s `build/`, which nixpkgs' CMake hook would take for its own build folder, and the `Compositor-Ref` spec, which a `path:` flake would copy whole.
- Spot Healing (8.4): `HealPixels.c` compiles in place beside the other kernels. `SpotHealingMode`, `allSpotHealingModes` and `rawValue` (Swift's names) sit in `BrushStroke.h`, and `BrushSettings` gains `healing` and `healingMode`. While the stroke lasts its tiles show Swift's wash, gray 0.12 at 45% through the coverage, never erasing. `heal` takes the painted bounds from the kernel's `heal_coverage_bounds`, draws the source into a region about three spots wider, and hands the kernel the coverage packed row by row, as Swift's gray context holds it (the twin copies each tile's rows where Swift draws each coverage image); the seed is `QRandomGenerator::global()`, Swift's `UInt32.random`. Each touched tile is then its base with the healed region copied over, blended with the base through `PixelAdjust::blend` under a selection, where Swift clips the context. The kernel's failure is `ProjectError(tooLarge)`; pixels or packed coverage that cannot be allocated are `ExportError(render)`, as Swift's contexts. The session's `beginBrush` takes Spot Healing on pixels alone, never on a mask, as Swift's guard; `finishBrushImmediately` heals before the commit, one `Spot Healing` step; `spotHealingMode` is a getter and a setter that notifies; Tab steps the type. `isBrushTool` is Swift's four tools: the circle, the right-drag tip, the brackets, the digits and `BrushControls` serve each, and a press with Clone Stamp or the Smear begins nothing until 8.5 and 8.6 bring them. `ContentView` keeps one bar across the four, as Swift's single `isBrushTool` branch keeps its view; the bar titles itself by tool (Spot Healing, Clone Stamp, Smear, Eraser, Brush), shows Paint and Erase for the Brush alone and the three types for Spot Healing alone, and names opacity Strength for the Smear; the clone's Aligned and Sample arrive with 8.5, the Smear's modes with 8.6. Equivalent mutant: the healed tile starting from its context rather than its base, since the region holds the whole wash and the copy replaces it.
- Clone Stamp (8.5): `Document/CloneStamp.h` holds `CloneSettings`, `CloneStamp.cpp` Swift's extension: `setCloneSource` (a point that is no number is refused; a new source restarts the alignment), `cloneStrokeOffset`, `cloneSamplePoint` and `cloneSample`, whose context that cannot be allocated logs on `omaphoto.app` where Swift's `try?` is silent. The session keeps `cloneSource`, `cloneSettings` (a setter that notifies), `cloneOffset` and `parkedBrushTips` by `tipFamily` (Clone Stamp 1, the Smear 2, the rest 0; the two parked tips start at Swift's 40, 0, 1). `selectTool` parks size, hardness and opacity when the family changes and reads the tip it restores with `at`: the session starts in family 0 and leaving a family parks it, so Swift's `if let` always finds one. `beginTransform` and Transform Selection set the Move tool directly, as Swift's do, and park nothing. `beginBrush` takes Clone Stamp on pixels alone, asks for a source first (`brushError`: "Alt-click where Clone Stamp should copy from first."), samples the document as the stroke starts and fixes the offset; Tab toggles the sample; the step is `Clone Stamp`. `BrushStroke::Clone` is Swift's tuple. `publish` makes each tile's view of the sample on the calling thread (`clonePixels`): under a scale or turn QPainter draws it bilinear, Swift's medium quality; a whole translation copies it; a fractional one is drawn at the floor and shifted by `BrushRaster::shiftedByFraction`, since QPainter snaps an unscaled image. `fillClone` then composites it source-over through coverage × opacity × selection, each byte rounded once, where Swift clips the context to the coverage. Swift's `!isMask || isBlur` holds since 8.6: a mask takes a clone only from Blur. The bar shows Aligned (a `QCheckBox` written on `clicked`, so the session's own changes write nothing back), This Layer and All Layers, and "Alt-click to set the source" in `PlaceholderText` while there is no source. On the canvas an Alt-press with Clone Stamp sets the source and paints nothing; Swift's cursor refresh there is dead (Alt is held, so the crosshair stays) and left out. With a source the pointer hides unless Alt or Space is held. `updateBrushCursor` gives the overlay the source crosshair in view points and, between strokes and without Alt, `clonePreview` (the source around the stamped point at screen resolution, at most 1024 pixels, kept under Swift's key; Swift's `.medium` there is overridden by `LayerRenderer`'s own quality and left out) with `cloneTip` (one click painted by the brush engine, kept by size and hardness). `BrushCursorOverlay` gains `marker`, `preview`, `previewOpacity` and `tip`, images compared by `cacheKey` as Swift's `!==`; under an antialiased ellipse clip at the brush's opacity it draws the preview and cuts it to the tip inside `AdjustmentSurface::draw`, Swift's transparency layer, so the tip applies at the drawing's resolution however far a capped preview is enlarged, and draws the crosshair white 3 under black 1 with round caps. A modifier change refreshes the brush cursor, as Swift's monitor does. Equivalent mutants: `displayedTransform` in `cloneSample` and `clonePreview` (it differs from the stored transform only in a transform edit, which leaves Clone Stamp for Move, or a filter edit, which refuses strokes; the preview would show it only with a filter sheet open over Clone Stamp).
- The Smear (8.6): `SmudgeLiquify.h/.cpp` hold `BlurToolMode` and `WarpStroke`, Swift's float arithmetic to the letter (the positive guards of `weight` included) on a document-sized copy drawn through the layer's displayed transform; `image()` is that copy itself, where Swift snapshots it after each append, since the twin reads it only between appends. `beginWarp` and `finishWarp` twin the extension: a warp takes pixels alone (`brushError`: "Smudge and Liquify work on a layer's pixels, not its mask."), and the commit paints the copy along the stroke with a hard tip four pixels wider, `replacesWithClone` so clear pixels clear, one step named by the mode. `BlurTool.cpp` holds `blurSample`: sigma a tenth of the brush within 1.5 and 30; the layer blurred with clear past the document's edge; a mask on its edge tone, its placed box black under its coverage, blurred with the edge repeated. Core Image's Gaussian is `PixelAdjust::gaussianBlur`: separable, weights exp(−i²/2σ²) over three sigmas, normalised, in float on the stored premultiplied values (Swift's Core Image context is unmanaged), each byte rounded once, rows in parallel. Where Core Image approximates a large blur, the twin's is exact; it costs 20–110 ms for a 4K document on 32 cores (sigma 1.5 to 30), paid as a Blur stroke begins, since Swift's sample is taken whole. `BrushStroke` gains `isBlur` (a mask takes a clone only from Blur), `replacesWithClone` (a copy by coverage: `fillClone` keeps 1 − coverage of the old pixel) and `editName` (the step's name when set). `clonePixels` resamples every clone, a gray one too, into premultiplied colour, so a sample's edge keeps the share of the pixel the image covers, and `fillClone` composites that alpha into a mask as CoreGraphics draws a gray image's edge: a white mask half a pixel past the canvas stays white. The session keeps `blurMode` and `warpStroke`; `beginBrush` hands Liquify and Smudge to `beginWarp`; `continueBrush`, `cancelBrush` and `finishBrushImmediately` take the warp first; exactly Swift's six places read it: `canEditLayers`, `canUseHistory`, `canStartProjectOperation`, `selectLayer`, `selectTool`, `cycleToolMode` (`selectLayers` and `selectLayerTarget` read the brush stroke alone, as Swift's). Tab steps the Smear's modes. The bar shows the three modes with Swift's help; the status line says "Drag to soften", "Drag to smudge" or "Drag to push pixels" (`hint` takes the Smear's mode). On the canvas the warp's copy stands in for its layer across the canvas, through the layer's mask at 2048, its opacity and blend mode, and a step repaints the view (no dirty rect, as Swift's); drags, releases, Escape, wheels, pinches and the tip drag treat a warp as a stroke. Equivalent mutants: `displayedTransform` in `beginWarp` and `blurSample` (painting refuses transform and filter edits), the radius rounded to nearest (pixels past its floor lie outside the brush), `u >= h` for `u > h` (weight 1 either way), a warp without dabs going on to commit (it appends nothing), copy mode weighing by coverage times opacity (a warp commits at full opacity), `fillClone`'s format guard (a clone is always sampled in colour), and the Gaussian's branch-free interior moved by a pixel (it reads one tap weighing under 1% of the centre).
- The Smear (8.6): `SmudgeLiquify.h/.cpp` hold `BlurToolMode` and `WarpStroke`, Swift's float arithmetic to the letter (the positive guards of `weight` included) on a document-sized copy drawn through the layer's displayed transform; `image()` is that copy itself, where Swift snapshots it after each append, since the twin reads it only between appends. `beginWarp` and `finishWarp` twin the extension: a warp takes pixels alone (`brushError`: "Smudge and Liquify work on a layer's pixels, not its mask."), and the commit paints the copy along the stroke with a hard tip four pixels wider, `replacesWithClone` so clear pixels clear, one step named by the mode. `BlurTool.cpp` holds `blurSample`, in the stroke's own grid since 13.11 and in pieces since 13.16 (see those bullets). Core Image's Gaussian is `PixelAdjust::gaussianBlur`: separable, weights exp(−i²/2σ²) over three sigmas, normalised, in float on the stored premultiplied values (Swift's Core Image context is unmanaged), each byte rounded once, rows in parallel. Where Core Image approximates a large blur, the twin's is exact. `BrushStroke` gains `isBlur` (a mask takes a clone only from Blur), `replacesWithClone` (a copy by coverage: `fillClone` keeps 1 − coverage of the old pixel) and `editName` (the step's name when set). `clonePixels` resamples every clone, a gray one too, into premultiplied colour, so a sample's edge keeps the share of the pixel the image covers, and `fillClone` composites that alpha into a mask as CoreGraphics draws a gray image's edge: a white mask half a pixel past the canvas stays white. The session keeps `blurMode` and `warpStroke`; `beginBrush` hands Liquify and Smudge to `beginWarp`; `continueBrush`, `cancelBrush` and `finishBrushImmediately` take the warp first; exactly Swift's six places read it: `canEditLayers`, `canUseHistory`, `canStartProjectOperation`, `selectLayer`, `selectTool`, `cycleToolMode` (`selectLayers` and `selectLayerTarget` read the brush stroke alone, as Swift's). Tab steps the Smear's modes. The bar shows the three modes with Swift's help; the status line says "Drag to soften", "Drag to smudge" or "Drag to push pixels" (`hint` takes the Smear's mode). On the canvas the warp's copy stands in for its layer across the canvas, through the layer's mask at 2048, its opacity and blend mode, and a step repaints the view (no dirty rect, as Swift's); drags, releases, Escape, wheels, pinches and the tip drag treat a warp as a stroke. Equivalent mutants: `displayedTransform` in `beginWarp` (painting refuses transform and filter edits), the radius rounded to nearest (pixels past its floor lie outside the brush), `u >= h` for `u > h` (weight 1 either way), a warp without dabs going on to commit (it appends nothing), copy mode weighing by coverage times opacity (a warp commits at full opacity), `fillClone`'s format guard (a clone is always sampled in colour), and the Gaussian's branch-free interior moved by a pixel (it reads one tap weighing under 1% of the centre).
- The Gradient (8.7): `Document/Gradient.cpp` twins Swift's extension. `GradientEdit` is a struct in a `std::optional`, where Swift's is a class; its raster is a `std::shared_ptr<BrushStroke>`, as a pixel move's, because the canvas draws it while the commit renders, and the raster's pointer stands for Swift's `gradientEdit === edit` (a new line on the same target keeps the raster, as Swift keeps the edit). `commitGradient` takes a completion callback, posted on every path; `resolveGradient` posts the commit, Swift's `Task`, so a switch of tool, layer or target applies it once the caller returns; `invertPixels` and `beginFilter` commit it first and call themselves again from its callback, as Swift awaits it. `fillGradient` fills the canvas through `paintCanvas` with a `QLinearGradient`, or a `QRadialGradient` centred on the start with the line as its radius; Qt's pad spread is CoreGraphics' `drawsBeforeStartLocation` and `drawsAfterEndLocation`, and two stops interpolate alike in both. `gradientColors` makes a mask's colours gray from the palette's red, where Swift makes `DeviceGray` colours. `undo` first discards a pending gradient and `canUndo` counts one, as Swift's. The palette's model came forward from 8.10 into `ColorPalette.cpp` (`PaletteColor`, `foregroundColor` with `setForegroundColor` through `setBrushSettings`, `backgroundColor`, `canEditPalette`, `paletteColor`, `setPaletteColor`, `swapPaletteColors`, `resetPaletteColors`), and `fillSelection` reads it; the Type bar's share of `setPaletteColor` came with 8.9a, the picker and the swatches came with 8.10a. On the canvas (`EditorCanvas+Gradient.cpp`) a press within ten points of the end, then the start, grabs it, else begins a line; Shift turns the dragged end to a multiple of 45° around the other; Escape cancels and Return or Enter applies, after the selection's keys; losing focus drops the drag. `TransformOverlay` draws the line in place of the handles while one is pending, as Swift's: a radial's dashed rim at the line's length, the line black 3 under white 1, each end a white disc with a black rim, a gray ring and its colour. A pending gradient on a folder's mask clips the folder's layers at once through `liveFolderMaskClip`, as a brush stroke does. `GradientControls` is the tool's `ToolHeaderBar`: the shapes as checkable `QToolButton`s in a `QButtonGroup`, `GradientSwatch` (white, gray checks of four points, the colours, clipped to a rounded rect, the rim unclipped as Swift's `strokeBorder` overlay), the style `QComboBox`, Reverse, the slider in thousandths from 1% and the percent field (a `SelectionAmountField`, whose own clamp stands for the binding's), the Mask note in `PlaceholderText`, and Cancel and Apply while pending. Equivalent mutants: the gray branch of `gradientColors` and of `fillSelection` (a mask's palette is black or white), and the swatch's rim clipped too (3/255 at its corner pixels, measured). A press that marks a drag without an edit was equivalent until 8.10b's Alt eyedropper read the drag; a test holds it now.
- The Shape tool (8.8): `Document/ShapeTool.h/.cpp` twin Swift's file, `ShapeTool+Session.h` its session half. `ShapeKind`'s raw values, `shapeKind(text)` for the manifest and `path` stand for the enum and its `path(in:cornerRadius:)`; `LayerShapeStyle`, `LayerShape` and `ShapeDraft` keep Swift's fields, `ShapePreview` is Swift's tuple. A `LayerShape` holds its image's `ImageIdentity`, Swift's `===`, since `ImportedImage::image()` is never cached in a model type; `ImageLayer.shape` joins equality, and `liveShape()` compares identities, so any other change to the pixels leaves a shape that is no longer live. `addPixelLayer` takes the style and ties it to the asset it makes, where Swift passes a `LayerShape` made from the same image. `shapeKind`, `shapeCornerRadius` and `shapeLineWidth` are getters and setters that notify, `shapeDraft` a getter. `finishShape` truncates each side before multiplying, as Swift's `Int()`, in doubles; a Line click leaves a dot as wide as the line, since the box grows by the thickness, as in Swift. `strokedLine` gives a line's round-capped outline, filled on the layer and on the draft, because QPainter draws a pen a pixel wide through its thin-line path, whose diagonals cover less than CoreGraphics' stroke; a zero-length line is a disc, since CoreGraphics caps it as a dot (PDF's rule for round caps) where Qt draws nothing. `redrawShape` pins any mask where it shows (Swift's `placement == nil` test changes nothing: a placed mask's `maskTransform` is its placement) and logs on `omaphoto.app` where Swift's `try?` is silent; `shapeTransformPreview` is `const` with a mutable cache, reads the pixel size from the asset a live shape shares, and logs on `omaphoto.rendering`. Left out as dead or redundant: `path`'s `max(0, …)` (its `radius > 0` test sends a negative radius to the plain rectangle), `shapeLineEnds`'s kind test and the draft drawing's `guard let ends` (only a line's drag sets an end, and its box with it), `finishShape`'s zero-width fallback in `unit` (the box is a pixel wide by then), `redrawShape`'s `max(1, …)` (a valid transform is a pixel wide), and the canvas drag's `lastDragPoint == nil` (a pan never begins a draft). Hooks: `selectTool` cancels a draft, Tab steps the kind, `commitTransform` redraws each moved shape, the record carries a live shape's style (`hierarchyRecord`), `installProject` loads shapes and `applyDocumentSize` does not, as Swift's rebuild leaves them out after a crop or a resize; where Swift rebuilds a layer (a brush or raster commit, Invert, a filter, a floating merge) the twin drops the shape in place; the canvas resizer passes it on, the image resizer not; a duplicate or a copied layer keeps it. The manifest's `shape` key is Swift's `Codable`: the kind's raw value, the numbers, `lineWidth`, `start` and `end` only when set, points as two numbers; a wrong type or a missing key refuses the project. On the canvas (`EditorCanvas+Shape.cpp`) a press begins, a move drags (Shift squares or snaps a line, Alt grows from the centre), the release finishes, Escape cancels after the selection's keys, Shift-U steps the kind with the Shape tool and chooses it otherwise, losing focus cancels. The draft draws above the active layer, inside the live mask renderer's `drawOwn`, composed aside through `LayerRenderer::composite` so folder masks clip it; a line at its width times the zoom, a point at least, with round caps. Swift's draw reads the draft under AppKit's observation tracking; the twin's `synchronizeDisplay` compares `ShapeShown` (draft, colour, width, the layer it sits above) and redraws the view as that observation does, where the brush repaints its stroke's rect alone; `DisplayState` counts a blank layer while a draft sits on it, so moving or hiding the target redraws too. A rounded rectangle resized with the Move tool draws `shapeTransformPreview` in place of its pixels. `ShapeControls` is the tool's `ToolHeaderBar`: the kinds as checkable `QToolButton`s in a `QButtonGroup` (a choice drops the draft first), Width for lines (a slider of 1–100 showing at most 100, a `SelectionAmountField` of 1–5000, "px") and Radius for rectangles (0–200 and 0–5000, with Swift's help); the sliders show the session's number without writing it back; the Fill swatch came with 8.10a. The status hint follows the kind. Known difference: a Line layer's box follows the dragged point, so it can sit between pixels, and at 1:1 and in exports QPainter draws it on whole pixels, where CoreGraphics interpolates. Equivalent mutants: Qt's `addRoundedRect` adds a plain rectangle at radius 0, so `radius > 0` and `>= 0` agree; an undragged draft's box is its anchor, so `finishShape`'s `value_or` may take either; the draft drawing's empty-box test for a rectangle or ellipse only spares an empty surface, since a path of no area paints nothing.
- The Type tool (8.9a): `Document/TypeTool.h/.cpp` twin Swift's file, `TypeTool+Session.h` its session half. `TextAlignment`'s raw values and `textAlignment(text)` serve the manifest; `LayerTextStyle`, `LayerText` and `TextDraft` keep Swift's fields. A `LayerText` holds its image's `ImageIdentity`, as a `LayerShape` does; `ImageLayer.text` joins equality and `liveText()` compares identities. TextKit has no Linux twin, so `Rendering/TextLayout` is Linux-only: `TextLines` lays a style out with `QTextLayout` as `textBoxSize` and `textImage` lay it out with TextKit. Lines have a fixed `lineHeight` with the baseline at their bottom, so extra leading sits above the letters (Swift's equal minimum and maximum line heights); they wrap at word boundaries, a word longer than the line anywhere (`byWordWrapping`); a line that would pass the container's bottom is not laid, nor anything after it (`overflows`); a final newline adds no line to a measure (its line holds the caret alone, 8.9b), and an empty paragraph lays one, the caret's room. Paragraphs part at Cocoa's separators, LF, CR, CRLF and U+2029, and Qt breaks a line at U+2028 by itself. Tabs stop every 28 points, as `NSParagraphStyle`'s twelve default stops do up to 336. Past the twelfth Cocoa has no stop and moves a tab down a line (a line of tabs wraps after 336 points in an `NSTextView`); here the stops go on every 28 points (known difference): moving such a tab down would lay the rest of its paragraph again for each one, quadratic in a run of tabs, and measure bidi lines by hand, while `QTextLine::setNumColumns` leaves the tab hanging on the line above. Faces go by PostScript name, as macOS names them: fontconfig lists each installed face's `FC_POSTSCRIPT_NAME` with its family and style (`availableFonts`, the bar's list). A name it does not list is Qt's match for that family, where Swift falls back to the system font; fontconfig's aliases put Helvetica, the default, on a metric twin where one is installed. The size is the style's in pixels, as a CoreGraphics context counts points, rounded: Qt draws a face in whole pixels (`QFontPrivate::engineForScript` rounds a point size's pixels too), so a fractional size draws at the nearest whole pixel where CoreText draws it exactly (known difference; the default is whole, and the bar's steps keep a whole size whole). Kerning is off, since Swift always sets `.kern`; tracking is absolute letter spacing after every letter, the last included, as CoreText kerns; hinting is off, so Qt lays out design advances by itself (`QFontEngineFT::shouldUseDesignMetrics`) and the layout sets no flag for them. Qt 6.4 scales absolute letter spacing by the device's dpi over its own in whole numbers, so on a 72-dpi image it vanishes (probed): lines are laid out without a device, where that scale is one. Letters differ from macOS's where the faces differ; the rules are the same. `Dockerfile.dev` installs `libfontconfig-dev`; CMake links `Fontconfig::Fontconfig`. In the session `textDraft` has a public setter, as Swift writes it freely, and `textDefaults` a getter. `applyText` takes its draft by value, as Swift's struct is passed: `finishText` hands it the session's own draft, which it clears. It clears the draft first (the gates read it) and puts it back on refusal, silently, and before an error is announced, so the error's signal sees the draft; blank new text and an unchanged edit close the draft, announced, with no step and no canvas focus request. `layerName` keeps 40 graphemes (`QTextBoundaryFinder`), Swift's `prefix(40)` of Characters; `simplified()` makes whitespace runs one space. `recolorText` logs a failed redraw where Swift's `try?` is silent. Left out as dead: the `isFinite` terms of `isValid` and `boxIsValid` (the ranges refuse NaN and infinities); the draft and document guards of `beginText`, `editActiveText` and `recolorText` (`canEditLayers` reads both); `textBoxSize`'s `max(16, …)` on both sides (the padding alone makes 24) and its caret line (an empty paragraph lays a line); `textImage`'s `width >= 1` terms; `recolorText`'s validity guard (`textImage` refuses an invalid style, and the refusal is logged); `setPaletteColor`'s Type-tool test (a draft lives only under the Type tool: every way in sets the tool, and a switch finishes text first); `fillSelection`'s `liveText` test (`recolorText` asks it); the store rule's folder term (text needs pixels, and `LayerHierarchy::validate` refuses a folder that has them). Hooks: the gates and `canStartProjectOperation` read `textDraft`; `selectLayer`, `selectLayers` and `selectTool` finish text first when the choice changes, and stop when that is refused; the foreground swatch colours an open draft; Fill recolours live text when neither a selection nor a mask is in play; `addPixelLayer` takes the style, as it takes a shape's; `installProject` loads text and `applyDocumentSize` does not; the record carries live text; every rebuild that drops a shape drops text; the canvas resizer passeLine truncated
- The inline editor (8.9b): `Rendering/InlineTextEditor.h/.cpp` twin Swift's file; `InlineTextEditor+Editing.cpp` holds its text view's keys, moves and undo, `InlineTextEditor+Input.cpp` its input methods and clipboard. Swift's editor hosts an `NSTextView` that AppKit turns and mirrors with the layer; a Qt child widget cannot turn, so the canvas paints the editor itself, as it paints `TransformOverlay`, and hands it its keys and input methods, and what `NSTextView` gives for free (selection, typing, deletions, movement, undo, the clipboard, marked text) is written here on `TextLines`, the layout `textImage` draws. `synchronize` follows Swift's: the logical size is `textBoxSize`, point text on a layer grows by the width's factor with its corner kept, handles are six view points (two at least), a style changed elsewhere clamps the selection unless text is marked, and a fresh draft clears the undo steps and takes the keys, posted, unless a text field (`QLineEdit`) holds them. The canvas synchronizes on every `changed()`, so a draft that ends takes its editor with it; Swift's `fresh` branch is reached here only when the session's `setTextDraft` sets one draft over another, as SwiftUI's coalesced updates reach it, and a test does that. The box maps layer pixels through the layer's unit square without flips; the text surface mirrors about its middle. The frame (accent, a sixth of a handle wide, inset a twelfth), the eight white handles with accent rims and the black overflow plus draw first and the text over them, as a subview draws over its view; the caret is a unit wide and a line tall in the text's colour, shown with an empty selection while the canvas has focus, blinking at half the system's flash time; a selection is `Highlight`, the inactive one without focus, drawn per line from Qt's own visual runs (a `FormatRange` background, the line's own height stretched to the fixed one), so mixed directions highlight what is selected, and where it passes a line's start or end it runs on to that line's edge on the side the paragraph reads, as TextKit's block selection; marked text is underlined unless the input method styles it. `TextLines` gains `TextRange`, `lineOf`, `caret`, `lineStart`, `lineEnd` (a line that ends short of its paragraph ends before its breaking space, laid next line or not: the caret has no affinity, where TextKit's upstream caret sits after it), `position` and a draw with a selection and marked text; it flags overflow only when a character is left unlaid, TextKit's glyph test, so an empty box shows no plus. After the text's final break (LF, CR, U+2029 or U+2028) it lays TextKit's extra line for the caret, even past the container's bottom (point text's measure, `boundingRect`'s, leaves it out), so Home, Up and typing there stay on it; measures and overflow ignore it, and the editor's repaint reaches a line below the box. A U+2028 that ends any paragraph gets the empty line Qt's own `createLine` makes after it, as TextKit breaks there, and a line ends before the space or separator that breaks it. A position past the laid text has no line (`lineOf` is empty past `m_laidEnd`, where Qt's `lineForTextPosition` would answer the last laid line for a paragraph's end): there the caret hides, Home and End go to the text's start and end, and Up and Down stay put. Keys: Swift's own (Escape cancels, Alt with the arrows sets spacing, Ctrl+Return finishes), the fills' text meanings (Alt+Backspace and Alt+Delete a word, Ctrl+Backspace and Ctrl+Delete to the line's start or end; Shift adds nothing, as `NSTextView`'s Shift-Delete deletes back, and the keypad's Delete deletes), then Qt's standard keys for undo, redo, the clipboard, select all and movement, as the app's text fields take them; X11 binds Alt+Backspace to Undo and Shift+Delete to Cut, so deletions come first and Shift+Delete stays Cut. Return types a newline, Tab a tab, and typing is what `QInputControl::isAcceptableInput` accepts, term for term (a format character before the Ctrl test, AltGr's Ctrl with Alt typing). Characters are graphemes and words `QTextBoundaryFinder`'s; in a paragraph that reads right to left (its first strong letter, as Qt judges), Right goes back and Left on, words alike, and a plain arrow collapses a selection to its own side, as Qt's fields move in their logical style; Up and Down keep their first x and reach the start or end past the lines. Undo is the editor's own stack: typing and deleting each coalesce until the caret moves, paste and cut are steps of their own, and a fresh draft starts empty (Swift's `removeAllActions`). Marked text lives in the draft's content, as `NSTextView` keeps it in its storage, but history follows Qt's input method contract: the preedit records nothing, and what commits or removes text makes a typing step from the text before the preedit (joining a typing run, else its own), so a cancelled composition keeps the redo and undo while composing takes the last step back. Each event follows `QWidgetLiLine truncated
- The Shape tool (8.8): `Document/ShapeTool.h/.cpp` twin Swift's file, `ShapeTool+Session.h` its session half. `ShapeKind`'s raw values, `shapeKind(text)` for the manifest and `path` stand for the enum and its `path(in:cornerRadius:)`; `LayerShapeStyle`, `LayerShape` and `ShapeDraft` keep Swift's fields, `ShapePreview` is Swift's tuple. A `LayerShape` holds its image's `ImageIdentity`, Swift's `===`, since `ImportedImage::image()` is never cached in a model type; `ImageLayer.shape` joins equality, and `liveShape()` compares identities, so any other change to the pixels leaves a shape that is no longer live. `addPixelLayer` takes the style and ties it to the asset it makes, where Swift passes a `LayerShape` made from the same image. `shapeKind`, `shapeCornerRadius` and `shapeLineWidth` are getters and setters that notify, `shapeDraft` a getter. `finishShape` truncates each side before multiplying, as Swift's `Int()`, in doubles; a Line click leaves a dot as wide as the line, since the box grows by the thickness, as in Swift. `strokedLine` gives a line's round-capped outline, filled on the layer and on the draft, because QPainter draws a pen a pixel wide through its thin-line path, whose diagonals cover less than CoreGraphics' stroke; a zero-length line is a disc, since CoreGraphics caps it as a dot (PDF's rule for round caps) where Qt draws nothing. `redrawShape` pins any mask where it shows (Swift's `placement == nil` test changes nothing: a placed mask's `maskTransform` is its placement) and logs on `omaphoto.app` where Swift's `try?` is silent; `shapeTransformPreview` is `const` with a mutable cache, reads the pixel size from the asset a live shape shares, and logs on `omaphoto.rendering`. Left out as dead or redundant: `path`'s `max(0, …)` (its `radius > 0` test sends a negative radius to the plain rectangle), `shapeLineEnds`'s kind test and the draft drawing's `guard let ends` (only a line's drag sets an end, and its box with it), `finishShape`'s zero-width fallback in `unit` (the box is a pixel wide by then), `redrawShape`'s `max(1, …)` (a valid transform is a pixel wide), and the canvas drag's `lastDragPoint == nil` (a pan never begins a draft). Hooks: `selectTool` cancels a draft, Tab steps the kind, `commitTransform` redraws each moved shape, the record carries a live shape's style (`hierarchyRecord`), a snapshot's layers load shapes, an install and a resize's rebuild alike (since 14.22, Swift's `documentLayers`); where Swift rebuilds a layer (a brush or raster commit, Invert, a filter, a floating merge) the twin drops the shape in place; the canvas resizer passes it on, the image resizer not; a duplicate or a copied layer keeps it. The manifest's `shape` key is Swift's `Codable`: the kind's raw value, the numbers, `lineWidth`, `start` and `end` only when set, points as two numbers; a wrong type or a missing key refuses the project. On the canvas (`EditorCanvas+Shape.cpp`) a press begins, a move drags (Shift squares or snaps a line, Alt grows from the centre), the release finishes, Escape cancels after the selection's keys, Shift-U steps the kind with the Shape tool and chooses it otherwise, losing focus cancels. The draft draws above the active layer, inside the live mask renderer's `drawOwn`, composed aside through `LayerRenderer::composite` so folder masks clip it; a line at its width times the zoom, a point at least, with round caps. Swift's draw reads the draft under AppKit's observation tracking; the twin's `synchronizeDisplay` compares `ShapeShown` (draft, colour, width, the layer it sits above) and redraws the view as that observation does, where the brush repaints its stroke's rect alone; `DisplayState` counts a blank layer while a draft sits on it, so moving or hiding the target redraws too. A rounded rectangle resized with the Move tool draws `shapeTransformPreview` in place of its pixels. `ShapeControls` is the tool's `ToolHeaderBar`: the kinds as checkable `QToolButton`s in a `QButtonGroup` (a choice drops the draft first), Width for lines (a slider of 1–100 showing at most 100, a `SelectionAmountField` of 1–5000, "px") and Radius for rectangles (0–200 and 0–5000, with Swift's help); the sliders show the session's number without writing it back; the Fill swatch came with 8.10a. The status hint follows the kind. Known difference: a Line layer's box follows the dragged point, so it can sit between pixels, and at 1:1 and in exports QPainter draws it on whole pixels, where CoreGraphics interpolates. Equivalent mutants: Qt's `addRoundedRect` adds a plain rectangle at radius 0, so `radius > 0` and `>= 0` agree; an undragged draft's box is its anchor, so `finishShape`'s `value_or` may take either; the draft drawing's empty-box test for a rectangle or ellipse only spares an empty surface, since a path of no area paints nothing.
- The Type tool (8.9a): `Document/TypeTool.h/.cpp` twin Swift's file, `TypeTool+Session.h` its session half. `TextAlignment`'s raw values and `textAlignment(text)` serve the manifest; `LayerTextStyle`, `LayerText` and `TextDraft` keep Swift's fields. A `LayerText` holds its image's `ImageIdentity`, as a `LayerShape` does; `ImageLayer.text` joins equality and `liveText()` compares identities. TextKit has no Linux twin, so `Rendering/TextLayout` is Linux-only: `TextLines` lays a style out with `QTextLayout` as `textBoxSize` and `textImage` lay it out with TextKit. Lines have a fixed `lineHeight` with the baseline at their bottom, so extra leading sits above the letters (Swift's equal minimum and maximum line heights); they wrap at word boundaries, a word longer than the line anywhere (`byWordWrapping`); a line that would pass the container's bottom is not laid, nor anything after it (`overflows`); a final newline adds no line to a measure (its line holds the caret alone, 8.9b), and an empty paragraph lays one, the caret's room. Paragraphs part at Cocoa's separators, LF, CR, CRLF and U+2029, and Qt breaks a line at U+2028 by itself. Tabs stop every 28 points, as `NSParagraphStyle`'s twelve default stops do up to 336. Past the twelfth Cocoa has no stop and moves a tab down a line (a line of tabs wraps after 336 points in an `NSTextView`); here the stops go on every 28 points (known difference): moving such a tab down would lay the rest of its paragraph again for each one, quadratic in a run of tabs, and measure bidi lines by hand, while `QTextLine::setNumColumns` leaves the tab hanging on the line above. Faces go by PostScript name, as macOS names them: fontconfig lists each installed face's `FC_POSTSCRIPT_NAME` with its family and style (`availableFonts`, the bar's list). A name it does not list is Qt's match for that family, where Swift falls back to the system font; fontconfig's aliases put Helvetica, the default, on a metric twin where one is installed. The size is the style's in pixels, as a CoreGraphics context counts points, rounded: Qt draws a face in whole pixels (`QFontPrivate::engineForScript` rounds a point size's pixels too), so a fractional size draws at the nearest whole pixel where CoreText draws it exactly (known difference; the default is whole, and the bar's steps keep a whole size whole). Kerning is off, since Swift always sets `.kern`; tracking is absolute letter spacing after every letter, the last included, as CoreText kerns; hinting is off, so Qt lays out design advances by itself (`QFontEngineFT::shouldUseDesignMetrics`) and the layout sets no flag for them. Qt 6.4 scales absolute letter spacing by the device's dpi over its own in whole numbers, so on a 72-dpi image it vanishes (probed): lines are laid out without a device, where that scale is one. Letters differ from macOS's where the faces differ; the rules are the same. `Dockerfile.dev` installs `libfontconfig-dev`; CMake links `Fontconfig::Fontconfig`. In the session `textDraft` has a public setter, as Swift writes it freely, and `textDefaults` a getter. `applyText` takes its draft by value, as Swift's struct is passed: `finishText` hands it the session's own draft, which it clears. It clears the draft first (the gates read it) and puts it back on refusal, silently, and before an error is announced, so the error's signal sees the draft; blank new text and an unchanged edit close the draft, announced, with no step and no canvas focus request. `layerName` keeps 40 graphemes (`QTextBoundaryFinder`), Swift's `prefix(40)` of Characters; `simplified()` makes whitespace runs one space. `recolorText` logs a failed redraw where Swift's `try?` is silent. Left out as dead: the `isFinite` terms of `isValid` and `boxIsValid` (the ranges refuse NaN and infinities); the draft and document guards of `beginText`, `editActiveText` and `recolorText` (`canEditLayers` reads both); `textBoxSize`'s `max(16, …)` on both sides (the padding alone makes 24) and its caret line (an empty paragraph lays a line); `textImage`'s `width >= 1` terms; `recolorText`'s validity guard (`textImage` refuses an invalid style, and the refusal is logged); `setPaletteColor`'s Type-tool test (a draft lives only under the Type tool: every way in sets the tool, and a switch finishes text first); `fillSelection`'s `liveText` test (`recolorText` asks it); the store rule's folder term (text needs pixels, and `LayerHierarchy::validate` refuses a folder that has them). Hooks: the gates and `canStartProjectOperation` read `textDraft`; `selectLayer`, `selectLayers` and `selectTool` finish text first when the choice changes, and stop when that is refused; the foreground swatch colours an open draft; Fill recolours live text when neither a selection nor a mask is in play; `addPixelLayer` takes the style, as it takes a shape's; a snapshot's layers load text, an install and a resize's rebuild alike (since 14.22); the record carries live text; every rebuild that drops a shape drops text; Line truncated
- The inline editor (8.9b): `Rendering/InlineTextEditor.h/.cpp` twin Swift's file; `InlineTextEditor+Editing.cpp` holds its text view's keys, moves and undo, `InlineTextEditor+Input.cpp` its input methods and clipboard. Swift's editor hosts an `NSTextView` that AppKit turns and mirrors with the layer; a Qt child widget cannot turn, so the canvas paints the editor itself, as it paints `TransformOverlay`, and hands it its keys and input methods, and what `NSTextView` gives for free (selection, typing, deletions, movement, undo, the clipboard, marked text) is written here on `TextLines`, the layout `textImage` draws. `synchronize` follows Swift's: the logical size is `textBoxSize`, point text on a layer grows by the width's factor with its corner kept, handles are six view points (two at least), a style changed elsewhere clamps the selection unless text is marked, and a fresh draft clears the undo steps and takes the keys, posted, unless a text field (`QLineEdit`) holds them. The canvas synchronizes on every `changed()`, so a draft that ends takes its editor with it; Swift's `fresh` branch is reached here only when the session's `setTextDraft` sets one draft over another, as SwiftUI's coalesced updates reach it, and a test does that. The box maps layer pixels through the layer's unit square without flips; the text surface mirrors about its middle. The frame (accent, a sixth of a handle wide, inset a twelfth), the eight white handles with accent rims and the black overflow plus draw first and the text over them, as a subview draws over its view (clear glyphs since 12.12a); the caret is a unit wide and a line tall in the text's colour, shown with an empty selection while the canvas has focus, blinking at half the system's flash time; a selection is `Highlight`, the inactive one without focus, drawn per line from Qt's own visual runs (a `FormatRange` background, the line's own height stretched to the fixed one), so mixed directions highlight what is selected, and where it passes a line's start or end it runs on to that line's edge on the side the paragraph reads, as TextKit's block selection; marked text is underlined unless the input method styles it. `TextLines` gains `TextRange`, `lineOf`, `caret`, `lineStart`, `lineEnd` (a line that ends short of its paragraph ends before its breaking space, laid next line or not: the caret has no affinity, where TextKit's upstream caret sits after it), `position` and a draw with a selection and marked text; it flags overflow only when a character is left unlaid, TextKit's glyph test, so an empty box shows no plus. After the text's final break (LF, CR, U+2029 or U+2028) it lays TextKit's extra line for the caret, even past the container's bottom (point text's measure, `boundingRect`'s, leaves it out), so Home, Up and typing there stay on it; measures and overflow ignore it, and the editor's repaint reaches a line below the box. A U+2028 that ends any paragraph gets the empty line Qt's own `createLine` makes after it, as TextKit breaks there, and a line ends before the space or separator that breaks it. A position past the laid text has no line (`lineOf` is empty past `m_laidEnd`, where Qt's `lineForTextPosition` would answer the last laid line for a paragraph's end): there the caret hides, Home and End go to the text's start and end, and Up and Down stay put. Keys: Swift's own (Escape cancels, Alt with the arrows sets spacing, Ctrl+Return finishes), the fills' text meanings (Alt+Backspace and Alt+Delete a word, Ctrl+Backspace and Ctrl+Delete to the line's start or end; Shift adds nothing, as `NSTextView`'s Shift-Delete deletes back, and the keypad's Delete deletes), then Qt's standard keys for undo, redo, the clipboard, select all and movement, as the app's text fields take them; X11 binds Alt+Backspace to Undo and Shift+Delete to Cut, so deletions come first and Shift+Delete stays Cut. Return types a newline, Tab a tab, and typing is what `QInputControl::isAcceptableInput` accepts, term for term (a format character before the Ctrl test, AltGr's Ctrl with Alt typing). Characters are graphemes and words `QTextBoundaryFinder`'s; in a paragraph that reads right to left (its first strong letter, as Qt judges), Right goes back and Left on, words alike, and a plain arrow collapses a selection to its own side, as Qt's fields move in their logical style; Up and Down keep their first x and reach the start or end past the lines. Undo is the editor's own stack: typing and deleting each coalesce until the caret moves, paste and cut are steps of their own, and a fresh draft starts empty (Swift's `removeAllActions`). Marked text lives in the draft's content, as `NSTextView` keeps it in its storage, but history follows Qt's input method contract: the preedit records nothing, and what commits or removes text makes a typing step from the text before the preedit (joining a typing run, else its own), so a cancelled composition keeps the redo and undo while composing takes the last step back. ELine truncated
- The pointer on open text (8.9c): `InlineTextEditor+Pointer.cpp` holds the pointer half of Swift's file: the canvas's text gestures (the `CanvasView` extension there: `beginTextGesture`, `dragTextGesture`, `finishTextGesture`, `drawTextBoxDraft`) and what the editor view and its `NSTextView` do with the mouse. A Type-tool press goes to the open editor first (`press`, Swift's `hitTest`): a handle's band (Swift's `handle(at:)`: ten points to a six-point handle, a third of the box at most) resizes; a press inside the box but in the text's padding does nothing, as Swift's editor view; inside the text it is the text view's click; elsewhere `beginTextGesture` finishes the open text, then opens the topmost visible live text under the point, its click forwarded to the text view wherever it lands (`pressText`, as Swift forwards `mouseDown`), or starts a box. A release under four pixels both ways is point text, else `beginText(in:)`; Escape (ahead of the editor's keys) and another tool drop the box, drawn a point wide in the accent, last; QPainter strokes a one-point pen without joins (its cosmetic stroker, probed), so the box's four outer corner pixels stay clear where CoreGraphics' miter covers a quarter (known difference). Swift's `needsDisplay` at the end of `beginTextGesture` is left out as dead: opened text repaints the canvas, and a box of no size draws nothing until its drag repaints it. A box whose document goes mid-drag (an undone New Canvas) draws and drags nothing and makes nothing on release, Swift's guards in `dragTextGesture` and `drawTextBoxDraft` (the twin's in `textBoxDraftRect`, which both read). Clicks follow `NSTextView`: one places the caret at the nearest position, two select the word under the pointer (`QTextBoundaryFinder` words; the character under it by `CursorOnCharacter`, `TextLines::character`), three the paragraph with its break, and a drag extends by the click's unit from the unit it began on, only while the left button is held. Above the lines is the text's start and below them the end of the laid text, where `NSTextView` puts a click or drag past its lines. An empty last line has no word or paragraph (the unit is empty there, where a lookup at the last character took the line before); past the text's end a double click takes its last word. Shift extends by the last click's unit (`NSTextView`'s kept granularity, `QWidgetTextControl`'s double-clicked word) while the selection that click made stands: its press drags on from that unit, and a selection set otherwise brings the caret's own unit back, extending from the anchor: keys, and any change of style or text, since Swift's style sync sets the selection anew (`setSelectedRange`, which resets the granularity); a fresh draft of the same style keeps it, as Swift's. The row under the pointer is judged as a double before it becomes an `int`: a far drag over a squashed layer passes `int`'s range. Qt has no triple click: the press right after a double click counts three when it comes within the double-click interval and the drag distance, as `QWidgetTextControl` times it; a press elsewhere ends the wait, as AppKit counts each click from the one before. Clicks map through the text's own mirror, so the caret lands where the letters show; Swift's `NSTextView` takes them unmirrored, because AppKit's hit testing ignores the layer transform that mirrors it. A click while a preedit shows asks the input method to commit, Qt's rule for a moved cursor (`QInputMethod::commit`, a no-op offscreen, so the call and the guard before it are equivalent mutants there), and lands the preedit as typed if none does, as `NSTextView` unmarks. A handle's press makes point text a box of the size it shows (Swift's `mouseDown`, whose `fixed.origin` is left out as dead: new point text shows at its own origin, and text on a layer commits its transform); its drag sets the box in its own turned frame, 16 source pixels a side at least, rounded, the far edges kept, and refuses a box or transform that is not valid. The cursor over a band is the frame's resize cursor turned with the box by eighths (Swift's `handleCursor` on Qt's four resize shapes; the rotation is reduced by 360 before the eighths are counted, since Swift's `Int` holds turn counts an `int` cannot), the I-beam elsewhere; a resize keeps its cursor, and its release shows the one under the pointer, as every release on the canvas does. AppKit always sends `mouseUp` to the view that took `mouseDown`; Qt can lose a release (a popup or modal dialog opened mid-drag, a grab the compositor takes), so a move without the left button, a lost focus or the next press ends a text gesture: a box being drawn goes, repainted away, and a selection or resize stops where it is. An edit (typing, an input method, undo, the spacing keys) ends a drag or a resize as a press would: Swift's resize would write back the draft it began with, dropping the edit, and AppKit's tracking loop holds a selection drag's keys until the release (knownLine truncated
- The palette and its picker (8.10a): `Document/ColorPalette.h/.cpp` twin Swift's file. `PaletteColor` gains `quantized`, `hex` and `fromHex` (Swift's `init?(hex:)`): spaces and tabs trimmed as Swift's `.whitespaces` trims them (Unicode space separators and the tab, never a line break), one leading `#`, three digits doubled, six required, then Swift's `UInt32(_:radix:)` read by hand: an optional sign, a minus holding zero alone, no `0x` (Qt's `toUInt` takes `0x` and white space). `PickerHSB` keeps Swift's maths to the letter (`fmod` for `truncatingRemainder`, `Int(h)` truncating) and its `setRGB`: grays keep the hue, black the saturation. `ColorPickerTarget` is a struct with a `Kind`, as `TransformDrag::Mode` twins an enum with payloads: the palette and text, the Gradient Map's ends since 9.4a, an effect's colour with 9.6. `ColorPickerState` is a plain struct in the session with an `id` for Swift's object identity (a new picker opens its panel anew); Swift's unused `background` and `PaletteColor(NSColor)` are left out. The sheet writes the working colour through `setColorPickerHSB`, which notifies, where Swift writes `colorPicker.hsb` in place; `closeColorPicker` clears the picker before applying it, so each signal sees it closed. `sampleCompositeColor` draws the live composite into one premultiplied pixel at the floored point and undoes the premultiplication as Swift does; a composite that throws logs on `omaphoto.app` and samples nothing. `UI/FloatingPanel` is Swift's `FloatingPanelController`, brought forward from 9.1: a `QDialog` with `Qt::Tool`, non-modal as a dialog is unless asked, fixed to its content's size, remembering its top-left by name for the run: every show and every move while shown remember it, so a panel dropped without closing keeps its place and a close has nothing to add (Swift's `close` and `windowWillClose` remember again); first shown over the canvas's middle (the owner window's `CanvasView`), else centred on the window, as a `QDialog` centres itself on its parent; a shown panel keeps its place, since Qt keeps a window's top-left as its size changes where AppKit keeps the bottom-left (Swift's re-pin has no twin); every show raises it and hands it the keys, Swift's `makeKeyAndOrderFront`; its close button and Escape report through `onClose`, as `windowWillClose` does, and `close` hides without reporting (Qt's `hide` sends no close, so Swift's `dismissing` has no twin); `refocus` activates the shown panel of a name; new content is shown at once, so the panel takes its size at once (a layout shows late children from the event loop), and old content is hidden at once and deleted later, since a signal from it may still run (the layout lets it go then). `QDialog::closeEvent` would reject, and so close, again: the panel's close accepts directly. Known differences: a Wayland compositor places windows itself, so the remembered places hold on X11; `hidesOnDeactivate` has no Linux twin. `UI/ColorPickerSheet` twins Swift's file: the 256-point field (white to the hue across, clear to black down, a 60% black frame, the marker's black ring behind its white one), the hue strip (360 on top, arrows in the text colour), the preview (a 60% black rim), OK as the default button and Cancel, the R, G, B and hex entries and "Click the canvas to sample" (sampling arrives with 8.10b). The field and the strip pick with the left button alone, as SwiftUI's `DragGesture`. The strip's arrows reach five points past its ends, as SwiftUI lets them overflow; a widget clips its painting, so the sheet draws them over the strip's place and repaints that room. Every control carries Swift's accessibility label as its accessible name (Saturation and brightness, Hue, New color, Red, Green, Blue, Hex color) and every entry Swift's title as its placeholder; Qt's plain widgets hold no settable accessible value, so the hue's rounded degrees are the strip's description. The entries are `PickerField`s, committing on Return and on leaving, where a menu or popup borrowing the focus keeps the typing; an entry keeps its typing only while it holds the keys and was typed in, and a pick on the field or the strip, which take no focus, replaces the channels' typing (9.6f): a channel takes digits alone, clamped as Swift's binding (an overflow reads as 255, an empty or untouched entry changes nothing), Up and Down stepping one, or ten with Shift, from the colour's own value; the hex commits on every leave, as Swift's `onChange(of: hexFocused)`, which snaps the working colour to its 8 bits. Return in an entry commits it and goes on to OK, since a `QLineEdit` passes Return to its dialog; Escape cancels. The channels are made before the hex entry, so the focus chain reads the grid and the keys start at R, which follows the colour until typed in; the hex entry follows only while it lacks the keys, and holding them first it would put its old text back on leaving. `ColorPickerPanelController` shows a new sheet per picker, titled by its target; itLine truncated
- The palette and its picker (8.10a): `Document/ColorPalette.h/.cpp` twin Swift's file. `PaletteColor` gains `quantized`, `hex` and `fromHex` (Swift's `init?(hex:)`): spaces and tabs trimmed as Swift's `.whitespaces` trims them (`trimmedWhitespaces` in `EditorSession+Model.h`, shared with `validDimension` and New Canvas since 14.3: the tab, Unicode's space separators and U+200B, as Swift 6.0.3's Foundation lists them, never a line break), one leading `#`, three digits doubled, six required, then Swift's `UInt32(_:radix:)` read by hand: an optional sign, a minus holding zero alone, no `0x` (Qt's `toUInt` takes `0x` and white space). `PickerHSB` keeps Swift's maths to the letter (`fmod` for `truncatingRemainder`, `Int(h)` truncating) and its `setRGB`: grays keep the hue, black the saturation. `ColorPickerTarget` is a struct with a `Kind`, as `TransformDrag::Mode` twins an enum with payloads: the palette and text, the Gradient Map's ends since 9.4a, an effect's colour with 9.6. `ColorPickerState` is a plain struct in the session with an `id` for Swift's object identity (a new picker opens its panel anew); Swift's unused `background` and `PaletteColor(NSColor)` are left out. The sheet writes the working colour through `setColorPickerHSB`, which notifies, where Swift writes `colorPicker.hsb` in place; `closeColorPicker` clears the picker before applying it, so each signal sees it closed. `sampleCompositeColor` draws the live composite into one premultiplied pixel at the floored point and undoes the premultiplication as Swift does; a composite that throws logs on `omaphoto.app` and samples nothing. `UI/FloatingPanel` is Swift's `FloatingPanelController`, brought forward from 9.1: a `QDialog` with `Qt::Tool`, non-modal as a dialog is unless asked, fixed to its content's size, remembering its top-left by name for the run: every show and every move while shown remember it, so a panel dropped without closing keeps its place and a close has nothing to add (Swift's `close` and `windowWillClose` remember again); first shown over the canvas's middle (the owner window's `CanvasView`), else centred on the window, as a `QDialog` centres itself on its parent; a shown panel keeps its place, since Qt keeps a window's top-left as its size changes where AppKit keeps the bottom-left (Swift's re-pin has no twin); every show raises it and hands it the keys, Swift's `makeKeyAndOrderFront`; its close button and Escape report through `onClose`, as `windowWillClose` does, and `close` hides without reporting (Qt's `hide` sends no close, so Swift's `dismissing` has no twin); `refocus` activates the shown panel of a name; new content is shown at once, so the panel takes its size at once (a layout shows late children from the event loop), and old content is hidden at once and deleted later, since a signal from it may still run (the layout lets it go then). `QDialog::closeEvent` would reject, and so close, again: the panel's close accepts directly. Known differences: a Wayland compositor places windows itself, so the remembered places hold on X11; `hidesOnDeactivate` has no Linux twin. `UI/ColorPickerSheet` twins Swift's file: the 256-point field (white to the hue across, clear to black down, a 60% black frame, the marker's black ring behind its white one), the hue strip (360 on top, arrows in the text colour), the preview (a 60% black rim), OK as the default button and Cancel, the R, G, B and hex entries and "Click the canvas to sample" (sampling arrives with 8.10b). The field and the strip pick with the left button alone, as SwiftUI's `DragGesture`. The strip's arrows reach five points past its ends, as SwiftUI lets them overflow; a widget clips its painting, so the sheet draws them over the strip's place and repaints that room. Every control carries Swift's accessibility label as its accessible name (Saturation and brightness, Hue, New color, Red, Green, Blue, Hex color) and every entry Swift's title as its placeholder; Qt's plain widgets hold no settable accessible value, so the hue's rounded degrees are the strip's description. The entries are `PickerField`s, committing on Return and on leaving, where a menu or popup borrowing the focus keeps the typing; an entry keeps its typing only while it holds the keys and was typed in, and a pick on the field or the strip, which take no focus, replaces the channels' typing (9.6f): a channel takes digits alone, clamped as Swift's binding (an overflow reads as 255, an empty or untouched entry changes nothing), Up and Down stepping one, or ten with Shift, from the colour's own value; the hex commits on every leave, as Swift's `onChange(of: hexFocused)`, which snaps the working colour to its 8 bits. Return in an entry commits it and goes on to OK, since a `QLineEdit` passes Return to its dialog; Escape cancels. The channels are made before the hex entry, so the focus chain reads the grid and the keys start at R, which follows the colour until typed in; the hex entry follows only while it lacks the Line truncated
- The Eyedropper and picking (8.10b): `EditorCanvas+Picking.cpp` holds Swift's sampling. `palettePicking` is the Eyedropper, or Alt with the Brush, Spot Healing or the Gradient while no stroke runs and no gradient end is dragged; `picking` adds an open picker (the Levels and hue sample modes arrive with 9.1 and 9.3). A press while picking, Space aside, samples at once and keeps the colour it began from; a drag samples on; the release ends it and, with a picker open, hands the picker the keys back (`ColorPickerPanelController::refocus`, which the canvas includes, as Swift's calls it). `sampleColor` samples into the open picker, else sets the foreground through `setForegroundColor` while the palette can be edited (a chosen mask too, as Swift sets `foregroundColor`), and rings the point. Qt can lose a release: a move without the left button, a lost focus or the next press ends a sampling. Picking outranks every tool's press, a double click (the second click is a press, so the inline editor and a polygonal Lasso stand aside while the picker is open, Swift's `hitTest`), a hover with no button held (the eyedropper alone: the brush circle neither moves nor shows, as Swift's `mouseMoved` returns first; a right-button drag still sizes the tip, as Swift's `rightMouseDragged` ignores picking) and the cursor (after the drag cursors, before Space). `syncPicking` ends a sampling when picking begins or ends: `synchronizeDisplay` runs it, as does every modifier change (Swift's monitor), a sampling press before it begins (Alt may first show in the press, and the foreground's signal would otherwise end the new sampling) and every move (Alt let go unseen). `pickingRelease` returns nothing, where Swift returns from `mouseUp`: a sampling press begins no other gesture, so the rest of the release finds nothing to end. `SampleRingOverlay` is no widget, like `BrushCursorOverlay`: 116 points round the pointer, a 24-point ring of gray 0.45 under a 16-point one, the sampled colour on the top half and the original below, each half replacing the clip and one restore dropping it, drawn over the overlays and under the inline editor; `showsSampleRing` (a getter and a setter that notifies) hides it. The Eyedropper's bar is Swift's inline one, built in `ContentView`: the title and a "Sample Ring" `QCheckBox` 16 points on, following the session under a `QSignalBlocker`. `eyedropperCursor` draws the symbol into Swift's 20-point box of a 24-point image, sixteen white offsets of 1.25 and then black; its hot spot is the drawn tip, (5, 19), as Swift's (3, 21) is its symbol's tip. The glyph is `Rendering/EyedropperIcon`, which the rail and the cursor share, as they share `SelectionIcons`. A mounted canvas leaves the keys to an open picker (Swift's `viewDidMoveToWindow`). The application event filter moved, unchanged, beside the modifier helpers in `EditorCanvas+Cursors.cpp`, keeping the navigation file under 500 lines. Equivalent mutant: the ring's `setBrush(Qt::NoBrush)` (the painter reaches it with no brush).
- Levels in the session (9.1a): `Document/Levels.h/.cpp` twin Swift's file, `Levels+Session.h` its session half; `LevelsAutomatic.h/.cpp` and `LevelsAutomatic+Session.h` the other file. `LevelsPixels.c` compiles in place beside the other kernels. `LevelsChannel`, `LevelsSample` and `LevelsAuto` keep Swift's raw values (`rawValue`) and case order (`allLevelsChannels`, `allLevelsSamples`, `allLevelsAutos`); `LevelsSample` is declared opaquely in `Levels.h`, since `LevelsEdit` holds one, and defined with the automatic levels, as Swift's. `LevelsSettings::sampling` is declared in `Levels.h` and defined in `LevelsAutomatic.cpp`, as Swift's extension; `settings(mode, histogram)` stands for `LevelsAuto.settings(histogram:)`; `setCurrent` is Swift's `current` setter, storing the range normalized. A histogram is `LevelsHistogram`, four arrays of 256 bins where Swift has `[[Double]]`, and `LevelsHistogramDisplay::scale` takes one channel's bins. `LevelsFilter::run` leaves soft edges to the kernel. Swift's cc5aac4 once unpremultiplied each soft edge before `levels_apply`, which unpremultiplies again; Swift agrees with the twin since 1.2.0 (`f6a74c9` removed that pass), and its `inputClippingGammaOutputInversionAndAlpha` holds (64, 96, 128, 128) as the twin does. The histogram hands the kernel one row at a time with the mask's own row, since Qt pads a gray image's rows and the kernel accumulates; neither it nor the filter's tables take heap memory of their own, whose failure would throw `std::bad_alloc` past the worker's catch. `LevelsEdit` is a plain class in a `std::optional` of the session, like `FilterEdit`, its `id` standing for Swift's object identity; past 8000 pixels a side its preview source is a scaled copy (sides truncated, one pixel at least), drawn from a painted layer's tiles without flattening them. The session keeps a watcher each for the histogram, the preview and the commit, with the edit each serves: where Swift cancels a task, the twin forgets whose it is (`m_levelsPreviewFor`; a histogram needs no token, since a new edit's count replaces the watcher's future, whose signal Qt then drops), so a result for a turned-off, closed or committing edit drops; one preview runs at a time, the newest job waiting, and a commit drops the waiting job too. A run counts as running until its finish has run (`m_levelsRendering`), as Swift's task clears itself only after its await: Qt's `setFuture` drops a replaced future's pending signal, and `isRunning` and `isFinished` both end with the worker (probed on 6.4.2). A histogram or preview that fails logs on `omaphoto.app` where Swift's `try?` is silent; a failed histogram leaves the bins empty and ready, as Swift's. `setLevelsSampleMode` is the sheet's write (Swift's sheet sets `sampleMode` and bumps `brushRevision` itself); it notifies. The commit writes the layer in place and drops shape and text, as the filter's does. `beginLevels` leaves out Swift's `levels == nil` and its `activeLayer` and `document` guard (`canAdjustColors` reads all three); `hueSaturation` arrives with 9.3, `cancelCrop` with 10.1, the adjustment layer hooks of `updateLevels`, `cancelLevels` and `commitLevels` came with 9.5c. The gates, `canAdjustColors` and `canStartProjectOperation` read `levels`, `selectLayer` and `selectTool` refuse while it is open, and closing it resumes file requests, Swift's `didSet`; the commit's end resumes them through `setIsProjectBusy(false)`, after the edit is gone. `commitLevels` hands its worker the asset, so a painted layer flattens there, where a failure is caught (Swift's lazy image flattens as its task draws). Left out as dead: the display scale's zero-peak guard (with no bin counted the interior is empty too), the preview flag in `previewImage` and in a landing preview (turning it off clears the preview and forgets the run), and in the automatic levels contrast's `low < high` (each channel's low lies below its high, so theirs do), neutral's `0 < mean < 1` and its 9.99 cap (weight at both ends of a range keeps the mean a thousandth inside, so gamma stays under 9.97), and the sample's 0 and 255 clamps (a sample is 0 to 1). Equivalent mutants: the white sample's `max(black + 1, …)` (`normalized` keeps white above black), `sampleLevels`' bounds on the layer's own pixels (a pixel off the image draws clear, which the alpha test refuses), the histogram's `committing` check (a count finishing during a commit, a race no test orders), the commit's check that the layer kept its pixels and place (nothing edits a layer while the project is busy), the tables' reservation and the warnings. `LevelsFailureTests` starves allocations through `AddressSpaceLimit` in a binary of its own: a canvas-wide stroke leaves hundreds of MB free inside malloc's arenas, which `malloc_trim` cannot unmap, so its painted layers are rasters built directly; worker threads allocate within their own reserved arenas, so only asks over 64 MB fail there.
- The Levels sheet (9.1b): `UI/LevelsSheet.h/.cpp` twin Swift's sheet; `Histogram`, `Gradient`, `Track` and `Handle` are private widgets of that file, and the histogram and the tracks follow the session's `changed()` themselves. Each handle is a 22 by 20 widget of its own, a child of the sheet placed over its track, as Swift's `ForEach` makes a view each: a later handle lies on top (white over gamma over black), a press drags only its own, each carries Swift's label, and at either end half of it lies in the sheet's padding, where a child of the track would be clipped. The output gradient is made before the output handles, so they lie on its last row as SwiftUI stacks the later view. A drag reads the pointer along its track, Swift's named coordinate space. The triangle is a path (apex 3.75, base 14.25, 12 wide, Swift's 12-point `triangle.fill`); its half-point gray shadow is a gray rim, since Qt has no blur. The histogram's colours are SwiftUI's gray and, from Apple's HIG for macOS 26 (the Swift target), its dark red, green and blue: (142, 142, 147), (255, 66, 69), (48, 209, 88), (0, 145, 255), data colours like the canvas's grays, whatever the theme. The entries are the picker's `PickerField`s: a number applies on Return or on leaving, as Swift's value binding, other text goes back, and an entry keeps its typing until then, unless its own handle is dragged, which takes no focus and replaces the typing with its value (9.6f); numbers are read and shown in the widget's locale with Swift's decimals, as Swift's `.number` format follows the user's (a German gamma reads `2,5`; no level reaches a thousand, so nothing is grouped). A click on OK takes the focus first, so a typed entry applies before the commit, where AppKit's buttons take no focus and the typing is lost; Return in an entry goes on to OK, the panel's default, as in the picker. The eyedroppers are checkable buttons set from the session, the chosen glyph in the accent and the others in the secondary ink, for Swift's `.tint`, redrawn on `PaletteChange`. The channel picker is named by its label (`setBuddy`): on Linux Qt names a combo box by its label relation, never by `accessibleName`. Alt+P is the Preview box's own shortcut in the panel and the canvas's key when the canvas has the keys. For an adjustment layer's editor the caption says the underlying pixels (9.5d). `ContentView+Panels.cpp` holds `synchronizePanels`, Swift's `onChange(of: levels == nil)`, and since 10.1 the importer and the alerts, because `ContentView.cpp` runs to 500 lines: a `FloatingPanel` named `levelsPanel` shows a new sheet when an edit opens, its close cancelling as Swift's `onClose`, and hides when the edit closes. The canvas draws `previewImage` in the layer's place after a filter's; a press in a sample mode is `sampleLevels` and hands the panel the keys (`FloatingPanel::refocus`); other presses wait for Space, the Hand or the Zoom while Levels is open; Escape, Return, Enter and Alt+P are its keys and every other key but Space passes on; `picking` reads the sample mode, so the eyedropper cursor shows; the application filter's brackets rest. `ContentView::acceptsDrop` needs no term of its own: `canSwitch` reads `canStartProjectOperation`, which reads `levels`. The Image menu's Levels… is Ctrl+L and follows `canAdjustColors` (its `hueSaturation` term arrives with 9.3); Import Images and the New canvas button rest while Levels is open. Undo and Redo are bare, since Levels closes the history, and enabled only while a field has focus, as for a busy project: Swift's bare entries act only on a text view. Equivalent mutants: the histogram's zero-peak guard and its `max(0, …)`, each standing in for the other (with no bin counted, 0/0), its `min(1, …)` (the widget clips a taller bar) and the bars' `+ 0.1` (Qt covers abutting rectangles of one path exactly); the drag's clamp of the tone, white's `max(black + 1, …)` and gamma's `min(0.999, …)` (`normalized` does each); either of a track's move and resize events alone (a layout moves and sizes it together); the canvas's `!preview` before the Levels preview (a filter edit and Levels never overlap); passing an unhandled key on (nothing above the canvas takes keys) and the base `changeEvent` (the sheet paints nothing of its own). Qt sizes the loading note itself: a shown child without a layout is adjusted to its hint.
- Curves and the image adjustments (9.2): `Document/Curves.h/.cpp` and `Document/ImageAdjustments.h/.cpp` twin Swift's files, `UI/CurvesControls.h/.cpp` Swift's view. `AdjustPixels.c` was already compiled for the downsample cache; Curves and Exposure run `levels_apply` from `LevelsPixels.c`, as Swift's do. `CurvesSettings.channels` is a vector of point vectors, as Swift's array, so `isValid` still counts four. `value` finds Swift's `lastIndex(where:)` with a loop; a NaN input takes the first segment at t = 0, as Swift's `max(0, .nan)` gives 0. `ImageAdjustmentPixels::run` hands its body the premultiplied RGBA context `BrushRaster::context` makes and returns that context. `AdjustmentColor` keeps Swift's two initialisers as constructors; the palette's has its callers since 9.4a (a Gradient Map starts from the palette, its picker) and gains 9.5's (new layers), as `normalized` serves the filter's settings since 9.4a and the adjustment's with 9.5; `ends`, which `apply` reads, gains the filter sheet as a reader with 9.4b and returns an `Ends` struct for Swift's tuple. `GrainSettings::apply` takes an optional seed that replaces the stored one and hands the same image back when the amount is 0. Left out as dead: the `isFinite` terms of `AdjustmentColor.isValid` and of the curves' points (the ranges refuse NaN and infinities), and the points' x range (ends at 0 and 255 with x strictly rising keep every x inside). `CurvesControls` takes Swift's binding as a value and a change function and shows the settings on `synchronize`, which each of its own edits calls and its owner will (9.4's filter sheet); a channel shown anew drops the selection and the drag, Swift's `onChange`. The graph (`curvesGraph`) is a private widget 260 tall: black at 35%, white quarter lines at 12%, the curve two wide, 8-point dots with the chosen one in the accent; presses and drags follow Swift's gesture (a point within 14 curve units is taken, else one is added between x 1 and 254, more than a unit from the others, 32 at most; an inner point keeps a unit from its neighbours; the left button alone). Remove point keeps Swift's guard (an inner point that is still there), so a selection another change has left behind removes nothing. The readout (`curvesReadout`) truncates as Swift's `Int(…)`; SwiftUI's `monospacedDigit` has no Qt 6.4 twin (font features arrive in 6.6), so the digits are the font's own. Carried from 9.1b's review: the Type bar's size and tracking read and print through its locale, as Swift's `.number` fields, without group separators, as every field of the twin; leading keeps `Double(text)`'s period, as Swift's. Equivalent mutants: the curves' `2…` lower count (one point cannot hold both ends), `<` for `<=` in the segment search (the curve is continuous at its points), `< 0` for `<= 0` in the inner slope (a zero slope's reciprocal is infinite, so the harmonic mean is zero anyway) and the final 0…255 clamp (Swift's slopes never overshoot a valid curve); Exposure's `max(0, …)` before the gamma and its table's lower clamp (a negative light ends at zero either way), the gradient map's byte clamp (its colours lie in 0…1); the controls' `x > 1` and `x < 254` (a point more than a unit from both ends lies inside them) and Remove's upper bound (Remove rests on the last point).
- Hue/Saturation in the session (9.3a): `Document/HueSaturation.h` twins Swift's types. Swift's file runs to 574 lines, so the twin splits it: `HueSaturation.cpp` (ranges, bands, sample modes, settings, the edit), `HueSaturation+Filter.cpp` (the cube) and `HueSaturation+Session.cpp` (the session extension, `canAdjustColors` with it; `HueSaturation+Session.h` its fragment). `ColorRange` and `HueSampleMode` keep Swift's raw values and case order (`allColorRanges`, `colorRanges`, `allHueSampleModes`); `defaultBand` is a free function where Swift has a property, and `help` gives the modes' help; their SF Symbols, the eyedropper and its badges, are the sheet's to draw (9.3b). `HueSaturationSettings` keeps Swift's dictionaries as `std::map`s, so the constructor's entry for its range and the setters' inserts compare as Swift's do; Swift's computed `hue`, `saturation`, `lightness` and `band` are getter and setter pairs. `HueSaturationFilter` is a namespace; `HueResponse` and `Color` stand for Swift's tuples, and `cube` returns the floats Swift packs into `Data`. Core Image's `CIColorCube` becomes a CPU lookup, rows in parallel: each pixel unpremultiplied, read trilinearly from the 33-point cube, premultiplied again and rounded once; Core Image's own precision may differ by a level, and Swift's tests allow eight. A failed table or row allocation is `ExportError(render)`, since a worker's `bad_alloc` would pass its catch; `HueSaturationFailureTests` reaches that net through a picture one pixel wide, whose row list asks as much as its pixels. A selection blends the result over the job's own pixels, as the Levels filter does, where Swift draws them into a context first: the same bytes, since layer pixels are that context's format. `HueSaturationEdit` is a class in a `std::optional`, as `LevelsEdit`, its `id` standing for Swift's identity; its preview source is made as the Levels edit's (to 8000 pixels a side, from a painted layer's tiles without flattening). The session keeps a watcher for previews and one for the commit. Where Swift cancels the preview task (Preview off, nothing to show, OK) the twin forgets whose it is, so that result drops; a result for a closed edit fails the id check, Swift's `hueSaturation === edit`. A run counts as running until its finish has run (`m_hueSaturationRendering`, as Levels' queue), so a failure is reported even when the next job waits. Known difference: Swift's run for a closed edit returns before it looks at the queue, so a job queued behind it waits for the next change; the twin's runs it. Known difference: Swift's task never looks at its cancellation, so a render still running when Preview goes off, or when OK is pressed, lands. A failed render sets `brushError` whichever edit asked, as Swift's `adjustedPixels` does. Known difference: Swift's `beginHueSaturation` resolves a pending gradient without waiting, so its edit holds the pixels from before the gradient and the commit, finding the layer changed, lands nothing; the twin waits for the gradient, as `beginLevels` does. `commitHueSaturation` refuses while a commit runs (Swift has no guard; its second commit would find the layer changed and land nothing), keeps a copy of the edit for the finish (`CommittingHue`), frees the project before the edit goes (Swift's two `defer`s) and names the new asset after the layer; the rebuild drops shape and text. `sampledHue` reads the composite through `PickerHSB`, as Swift's; `beginHueTargeting` takes Swift's `max(by:)`, the first of the strongest claims. `setHueSampleMode` and `setHueTargeting` are the sheet's writes and notify on a change; `endHueTargeting` changes nothing observable and stays silent. Swift's beeps are warnings on `omaphoto.app`. Left out as dead: `beginHueSaturation`'s `document` and `activeLayer` guard (`canAdjustColors` reads both) and the targeting's `?? .reds` (the ranges are never empty). Equivalent mutants: the weight's fallbacks for ramps of no width (no hue lies inside one) and its `<=` at the plateau's end (both sides give 1); `toHSL`'s cap on saturation, `toRGB`'s gray return and clamp (the arithmetic stays within 0 and 1 but for rounding, which later clamps absorb); a clear pixel's fill (its lookup is multiplied by alpha 0); the response's skips of a still range or a zero weight (each adds nothing); the saturation's floor of zero (`toRGB` draws gray at or below it); the amount's clamps and the lightness clamp (a sum past ±100 reaches white or black either way). The hooks: `canEditLayers`, `canContentAwareFill` (so `beginFilter`), `beginLevels` and `ProjectWorkspace::canSwitch` read `hueSaturation`; `canUseHistory`, `canStartProjectOperation`, `selectLayer` and `selectTool` do not, as in Swift.
- The Hue/Saturation sheet (9.3b): `UI/HueSaturationSheet.h/.cpp` twin Swift's file, `HueSaturationSheet` and `SpectrumEditor`; the editor takes Swift's binding as a value and a change function, as `CurvesControls` does. The sheet writes through Swift's `settings` binding (`change`: the settings as they stand, the defaults once the edit is gone, handed over with the edit's Preview). The range picker is a 160-wide `QComboBox`, resting under Colorize; Swift hides its label, so a hidden `QLabel` names it through the label relation Linux reads (there a combo box's own name is its text). Swift's sliders run freely; the twin's step in hundredths and show the session's value under `QSignalBlocker`, clamped to their range, so a typed value past it rests the slider at its end without writing back. Each field is a `PickerField`: a readable, finite number applies when the field lets go of focus (Swift's value binding; Swift's `Int()` traps on the rest, which revert), and Return clamps it to the slider's range (Swift's `onSubmit`), then goes on to OK, the panel's default button, where Swift's handler commits by itself. The field's own slider replaces its typing with the value it sets (a wheel leaves the keys with the field; 9.6f); the others leave it. The fields print whole numbers through their locale, ungrouped as every field of the twin; the unit sits two points past each, the hue's a degree sign, the others empty, as Swift's `unitSuffix`. The eyedroppers and the targeting button paint themselves: a quarter of the accent in a rounded frame when chosen (Swift's plain button with its background), the eyedropper through `EyedropperIcon` at 14 points with an 8-point disc cut by a plus or a minus (Swift's `plus.circle.fill` and `minus.circle.fill`), and `hand.point.up.left` as a finger and a palm outlined and turned; `HueSaturationSpectrumTests` pins each glyph's ink, measured once. The spectrum paints Swift's 72 slices, the after bar through `shiftedHue`, clipped to a rounded rect of 3; its handles are bars for the core and blocks for the shoulders, dragged by nearness round the circle (Swift's `nearestHandle`); the held handle stays until a press starts afresh, because Qt can lose a release, and Swift's `width > 0` is left out (a press needs width). "Limited to the selection" shows with a selection, never for an adjustment layer's editor (Swift's `adjustmentOriginal` term, 9.5d). `ContentView::synchronizePanels` shows the sheet in `adjustmentPanel`, titled Hue/Saturation, while an edit is open; its close button and Escape cancel. On the canvas `picking` counts the hue eyedropper: a press samples the range and hands the panel the keys, unless the colour picker is open or palette picking runs with no range eyedropper armed, which sample colour, in Swift's `mouseDown` order. Targeting shows `Qt::SizeHorCursor` (Swift's `resizeLeftRight`) after picking and before Space; a press begins it and ends there even when refused (Swift returns), a drag moves it by the pointer's x from the press, Ctrl for Cmd driving the hue, and sets the resize cursor again, as Swift's does on every drag, so Alt's eyedropper under the Brush lasts only until the next, a release ends it, and a move without the left button ends a drag whose release was lost; with Space the press pans. A double click with targeting armed is a second press, ahead of the Type tool's words and the polygonal lasso's close, as Swift's `mouseDown` sees it: a text draft and a polygonal outline can both be open under Hue/Saturation (Swift's `canAdjustColors` asks for neither). The press, drag and release live in `EditorCanvas+Picking.cpp` beside the sampling. The canvas draws the edit's preview in the layer's place after a filter's and Levels'. The Image menu's Hue/Saturation… (Ctrl+U) follows `canAdjustColors`, so it stays enabled while its own edit is open, as Swift's (choosing it again changes nothing); Levels… rests meanwhile. In a text field, on a desktop whose keyboard scheme binds Ctrl+U to deleting the line (X11's), the field takes it first, as with Ctrl+A; offscreen binds nothing, so the tests see the menu. Equivalent mutants: the top row's spacing (its stretch takes the room), the slider's stretch (a horizontal `QSlider` expands by itself), `nearestHandle`'s `fmod` (Swift's too: the handles and the pointer lie within one turn) and `endHueTargeting` on release (the canvas never reads the drag it clears). A buttonless move never reaches the handles (Qt drops it without mouse tracking), so their left-button check is tested with the right button held.
- Filters in the session (9.4a): `Document/Filters.h/.cpp` hold Swift's model (Remove Background's kind, its four settings, `SubjectRemoval` and `commitBackgroundMask` since 9.7); `isImageAdjustment` and the list of kinds arrive with the Filter menu (9.4b), their only reader. `FilterSettings::normalized` clamps through `ImageAdjustmentPixels::clamp`, a value that is not finite taking the default. `PixelFilter::run` hands Curves, Exposure, the Gradient Map and Grain to their settings' `apply` (Grain in layer pixels, `1 / scale` units, the job's seed), the fill to `ContentFill::run`, and the rest a premultiplied copy, as Swift's contexts: Gaussian Blur is `PixelAdjust::gaussianBlur` unclamped, clear past the edge as Core Image blurs a finite image; Motion Blur is `PixelAdjust::motionBlur`, `CIMotionBlur` on the CPU: Gaussian taps (exp(−i²/2σ²) over three sigmas, normalised) along the angle at σ = distance × scale / √12, each tap read bilinearly with clear past the edge, rows in parallel, the angle counterclockwise on screen since Core Image's y points up; Add Noise and Lens Correction run Swift's kernels, `NoisePixels.c` and `LensPixels.c`, compiled in place (the lens's k is distortion / 100 × 0.35). Known difference: Swift measured Core Image's taper on a dot; the twin's is the exact Gaussian. `FilterEdit::prepare` is Swift's `prepared` and `prepare` in one: the mapping and a preview copy at most 2048 on its long side (sides truncated, one at least; whole for noise, grain and the fill; a painted layer's copy drawn from its tiles, a full-size one flattened as Levels' is). `growForBlur` pads by the blur's reach (3σ + 2, or half the streak + 2) through `grow`, 30,000 a side and 100 megapixels at most (`ProjectError(tooLarge)`), and never shrinks the grid; its rounding up is left out, since `grow` takes the integral box. The session keeps `filterSettings`, which each commit writes and each filter starts from, the Gradient Map from the palette's foreground and background instead. `updateFilter(settings, preview)` normalises, grows the grid when a blur needs more room (a failure is `brushError`; the old preview goes), renders an automatic kind once per settings (`preparedSettings`, which the worker hands back with its image), and with Preview off drops the waiting job and the preview. Known difference: one watcher serves every edit, so a job may wait behind a cancelled edit's run; that job is Swift's running task, and Preview off leaves it (dropped, it would leave `preparing` set for good). `commitFilter` closes a Gradient Map picker with OK first (`cancelFilter` with Cancel), closes as Cancel does with no step when nothing would change (no distortion, Exposure's defaults, no grain), forgets the running preview and drops the waiting job (Swift cancels the task), and hands the worker the job's parts, so a painted layer flattens there; a blur's result is trimmed to the pixels it left, any other keeps the grown grid, and an automatic kind reuses its preview when the settings match. Known difference (a fix): Swift carries a covering mask over the grown box while a trimmed blur's pixels sit on the trimmed box, so the mask slid and squeezed; the twin carries it over the layer's new box. The picker's `gradientMap` target (`ColorPickerTarget::highlights` names the end; the title follows it) opens on the open Gradient Map through `openGradientMapColorPicker`, `previewGradientMapColor` writes the working colour into the map through `updateFilter` (silent when nothing changes), and `closeColorPicker` writes OK's colour or puts the original back, the palette untouched. Left out as dead: the opener's `committing` term (a commit keeps the project busy, which `canEditPalette` reads) and `setGradientMapColor`'s (a commit closes the picker first); that function's other guards came back with 9.5c. `PixelFilter::trimmed` throws `ExportError(render)` when its crop finds no memory, since Swift's `cropping` shares the pixels and cannot fail so; the blurs' row lists (`rowNumbers`) turn `bad_alloc` into `ExportError(render)`, as Hue/Saturation's do, and their weight tables stay plain, the callers bounding them to 1,734 entries. Tests: `FilterTests` (Swift's four; the Gaussian one tests the code, which grows and trims, as Swift's has since 1.2.0, `7c4512a`), `FilterModelTests` (Add Noise and the lens checked against Swift's kernels, whose headers only that test includes), `FilterSessionTests`, `FilterFailureTests` (a binary of its own, under `AddressSpaceLimit`), and in `ImageAdjustmentTests` Swift's two Image-menu tests and the command half of `curvesIdentityAndImageCommandPreserveAlpha` (Swift's `AdjustmentLayerTests` holds it). Equivalent mutant: `>=` in `growForBlur`'s guard (at equal reach it grows the same grid again).
- The Hue/Saturation sheet (9.3b): `UI/HueSaturationSheet.h/.cpp` twin Swift's file, `HueSaturationSheet` and `SpectrumEditor`; the editor takes Swift's binding as a value and a change function, as `CurvesControls` does. The sheet writes through Swift's `settings` binding (`change`: the settings as they stand, the defaults once the edit is gone, handed over with the edit's Preview). The range picker is a 160-wide `QComboBox`, resting under Colorize; Swift hides its label, so a hidden `QLabel` names it through the label relation Linux reads (there a combo box's own name is its text). Since 12.14c the sliders are Camera Raw's coloured ones over a thousand steps, writing whole numbers as Swift's do, and show the session's value through `reshape`, clamped to their range, so a typed value past it rests the slider at its end without writing back. Each field is a `PickerField`: a readable, finite number applies when the field lets go of focus (Swift's value binding; Swift's `Int()` traps on the rest, which revert), and Return clamps it to the slider's range (Swift's `onSubmit`), then goes on to OK, the panel's default button, where Swift's handler commits by itself. The field's own slider replaces its typing with the value it sets (a wheel leaves the keys with the field; 9.6f); the others leave it. The fields print whole numbers through their locale, ungrouped as every field of the twin; the unit sits two points past each, the hue's a degree sign, the others empty, as Swift's `unitSuffix`. The eyedroppers and the targeting button paint themselves: a quarter of the accent in a rounded frame when chosen (Swift's plain button with its background), the eyedropper through `EyedropperIcon` at 14 points with an 8-point disc cut by a plus or a minus (Swift's `plus.circle.fill` and `minus.circle.fill`), and `hand.point.up.left` as a finger and a palm outlined and turned; `HueSaturationSpectrumTests` pins each glyph's ink, measured once. The spectrum paints Swift's 72 slices, the after bar through `shiftedHue`, clipped to a rounded rect of 3; its handles are bars for the core and blocks for the shoulders, dragged by nearness round the circle (Swift's `nearestHandle`); the held handle stays until a press starts afresh, because Qt can lose a release, and Swift's `width > 0` is left out (a press needs width). "Limited to the selection" shows with a selection, never for an adjustment layer's editor (Swift's `adjustmentOriginal` term, 9.5d). `ContentView::synchronizePanels` shows the sheet in `adjustmentPanel`, titled Hue/Saturation, while an edit is open; its close button and Escape cancel. On the canvas `picking` counts the hue eyedropper: a press samples the range and hands the panel the keys, unless the colour picker is open or palette picking runs with no range eyedropper armed, which sample colour, in Swift's `mouseDown` order. Targeting shows `Qt::SizeHorCursor` (Swift's `resizeLeftRight`) after picking and before Space; a press begins it and ends there even when refused (Swift returns), a drag moves it by the pointer's x from the press, Ctrl for Cmd driving the hue, and sets the resize cursor again, as Swift's does on every drag, so Alt's eyedropper under the Brush lasts only until the next, a release ends it, and a move without the left button ends a drag whose release was lost; with Space the press pans. A double click with targeting armed is a second press, ahead of the Type tool's words and the polygonal lasso's close, as Swift's `mouseDown` sees it: the Type tool can be chosen and a polygonal outline open under Hue/Saturation, but since 1.3.2 no text draft (`d448625`, 12.14d2). The press, drag and release live in `EditorCanvas+Picking.cpp` beside the sampling. The canvas draws the edit's preview in the layer's place after a filter's and Levels'. The Image menu's Hue/Saturation… (Ctrl+U) follows `canAdjustColors`, so it stays enabled while its own edit is open, as Swift's (choosing it again changes nothing); Levels… rests meanwhile. In a text field, on a desktop whose keyboard scheme binds Ctrl+U to deleting the line (X11's), the field takes it first, as with Ctrl+A; offscreen binds nothing, so the tests see the menu. Equivalent mutants: the top row's spacing (its stretch takes the room), the slider's stretch (a horizontal `QSlider` expands by itself), `nearestHandle`'s `fmod` (Swift's too: the handles and the pointer lie within one turn) and `endHueTargeting` on release (the canvas never reads the drag it clears). A buttonless move never reaches the handles (Qt drops it without mouse tracking), so their left-button check is tested with the right button held.
- Filters in the session (9.4a): `Document/Filters.h/.cpp` hold Swift's model (Remove Background's kind, its four settings, `SubjectRemoval` and `commitBackgroundMask` since 9.7); `isImageAdjustment` and the list of kinds arrive with the Filter menu (9.4b), their only reader. `FilterSettings::normalized` clamps through `ImageAdjustmentPixels::clamp`, a value that is not finite taking the default. `PixelFilter::run` hands Curves, Exposure, the Gradient Map and Grain to their settings' `apply` (Grain in layer pixels, `1 / scale` units, the job's seed), the fill to `ContentFill::run`, and the rest a premultiplied copy, as Swift's contexts: Gaussian Blur is `PixelAdjust::gaussianBlur` unclamped, clear past the edge as Core Image blurs a finite image; Motion Blur is `PixelAdjust::motionBlur`, `CIMotionBlur` on the CPU: Gaussian taps (exp(−i²/2σ²) over three sigmas, normalised) along the angle at σ = distance × scale / √12, each tap read bilinearly with clear past the edge, rows in parallel, the angle counterclockwise on screen since Core Image's y points up; Add Noise and Lens Correction run Swift's kernels, `NoisePixels.c` and `LensPixels.c`, compiled in place (the lens's k is distortion / 100 × 0.35). Known difference: Swift measured Core Image's taper on a dot; the twin's is the exact Gaussian. `FilterEdit::prepare` is Swift's `prepared` and `prepare` in one: the mapping and a preview copy at most 2048 on its long side (sides truncated, one at least; whole for noise, grain and the fill; a painted layer's copy drawn from its tiles, a full-size one flattened as Levels' is). `growForBlur` pads by the blur's reach (3σ + 2, or half the streak + 2) through `grow`, 30,000 a side and one surface at most (`DocumentLimits`, 200 megapixels since 12.11a; `ProjectError(tooLarge)`), and never shrinks the grid; its rounding up is left out, since `grow` takes the integral box. The session keeps `filterSettings`, which each commit writes and each filter starts from, the Gradient Map from the palette's foreground and background instead. `updateFilter(settings, preview)` normalises, grows the grid when a blur needs more room (a failure is `brushError`; the old preview goes), renders an automatic kind once per settings (`preparedSettings`, which the worker hands back with its image), and with Preview off drops the waiting job and the preview. Known difference: one watcher serves every edit, so a job may wait behind a cancelled edit's run; that job is Swift's running task, and Preview off leaves it (dropped, it would leave `preparing` set for good). `commitFilter` closes a Gradient Map picker with OK first (`cancelFilter` with Cancel), closes as Cancel does with no step when nothing would change (no distortion, Exposure's defaults, no grain), forgets the running preview and drops the waiting job (Swift cancels the task), and hands the worker the job's parts, so a painted layer flattens there; a blur's result is trimmed to the pixels it left, any other keeps the grown grid, and an automatic kind reuses its preview when the settings match. Known difference (a fix): Swift carries a covering mask over the grown box while a trimmed blur's pixels sit on the trimmed box, so the mask slid and squeezed; the twin carries it over the layer's new box. The picker's `gradientMap` target (`ColorPickerTarget::highlights` names the end; the title follows it) opens on the open Gradient Map through `openGradientMapColorPicker`, `previewGradientMapColor` writes the working colour into the map through `updateFilter` (silent when nothing changes), and `closeColorPicker` writes OK's colour or puts the original back, the palette untouched. Left out as dead: the opener's `committing` term (a commit keeps the project busy, which `canEditPalette` reads) and `setGradientMapColor`'s (a commit closes the picker first); that function's other guards came back with 9.5c. `PixelFilter::trimmed` throws `ExportError(render)` when its crop finds no memory, since Swift's `cropping` shares the pixels and cannot fail so; the blurs' row lists (`rowNumbers`) turn `bad_alloc` into `ExportError(render)`, as Hue/Saturation's do, and their weight tables stay plain, the callers bounding them to 1,734 entries. Tests: `FilterTests` (Swift's four; the Gaussian one tests the code, which grows and trims, as Swift's has since 1.2.0, `7c4512a`), `FilterModelTests` (Add Noise and the lens checked against Swift's kernels, whose headers only that test includes), `FilterSessionTests`, `FilterFailureTests` (a binary of its own, under `AddressSpaceLimit`), and in `ImageAdjustmentTests` Swift's two Image-menu tests and the command half of `curvesIdentityAndImageCommandPreserveAlpha` (Swift's `AdjustmentLayerTests` holds it). Equivalent mutant: `>=` in `growForBlur`'s guard (at equal reach it grows the same grid again).
- The filter sheet (9.4b): `UI/FilterSheet.h/.cpp` twin Swift's file, `FilterSheet` and `GradientMapControls`. The sheet is the content of a `FloatingPanel` named `filterPanel`, which `ContentView::synchronizePanels` shows with the edit, titled by its kind, and closes with it, its close button and Escape cancelling (Swift's `onChange(of: filterEdit == nil)`). The kind's controls are made once, since an edit's kind never changes. Swift's `control` is a title (sixty points at least, the slider's buddy), a slider over a thousand steps of its range, or of the range's logarithm where Swift's is logarithmic, the value rounded to the field's decimals as Swift's binding rounds it, and a 56-point `PickerField` with its unit two points after (Swift's `unitSuffix`), which applies a readable finite number on Return or on leaving (normalizing clamps it), gives up its typing when its own slider changes the value (a wheel leaves the keys with the field; a notch that rounds to the same value changes nothing; 9.6f), and shows Swift's `.fractionLength(0...decimals)`: at most that many decimals, none trailing, in the field's locale, digits never grouped, as in every field of the twin. The sliders show the session's number under a `QSignalBlocker`, so a dragged thumb sits at the rounded value, as Swift's binding shows it; a step that rounds to the setting it already holds changes nothing, so the thumb keeps its place and arrow steps add up. Add Noise's distribution is two checkable buttons in a `QButtonGroup` beside its label (Swift's segmented picker), Monochromatic a `QCheckBox`; Lens Correction's note is callout-sized in `PlaceholderText`; Curves hosts `CurvesControls` through the settings. `GradientMapControls` draws Swift's gradient bar (20 high, rounded 4, a 35% black rim inside), the ends as the palette's `SwatchButton`s (24 points, radius 6, a white ring of 1.5 inside a black rim) with their titles, help and accessible names, and Reverse; a swatch opens the picker on its end, and on every change of the picker's colour the sheet calls `previewGradientMapColor` (Swift's `onChange`), which ignores a palette picker. The controls wait for the sheet's first synchronize, which runs once they are built. `settings` and `update` keep Swift's fallbacks for a sheet whose edit has gone: the defaults, Preview on. The Image menu gains Curves… (Ctrl+M) before Levels…, and Exposure…, Gradient Map… and Grain… after Hue/Saturation…; the Filter menu (`Fil&ter`, between Image and Layer, as Swift declares it) lists `allFilterKinds` but the fill and the image adjustments (`isImageAdjustment`), Remove Background among them since 9.7; every entry rests while colours cannot be adjusted or Hue/Saturation is open, as Swift's. Tests: `FilterSheetTests` (7.5's, moved to the panel), `FilterControlsTests` (each kind's controls, the sliders' travel, the fields, the Gradient Map's swatches, bar and repaint), the menus' in `SelectMenuTests` and `CompositorMenusTests`. Equivalent mutants: Swift's `?? .gaussianBlur` for the kind (the panel makes a sheet only for an open edit) and the memory of the picker's colour (`previewGradientMapColor` with an unchanged colour changes nothing).
- Adjustment layers in the document and the store (9.5a): `Document/LayerAdjustment.h/.cpp` twin Swift's file, `LayerAdjustment+Session.h` its session half. `AdjustmentKind` keeps Swift's raw values (`rawValue`, and `adjustmentKind(text)` for the manifest) and case order (`allAdjustmentKinds`); `filterKind` came with 9.5c, its reader; `symbol` names an SF Symbol, whose glyph the panel draws (9.5d). `LayerAdjustment` keeps Swift's fields: the legacy Hue/Saturation four, the optional `hsvSettings`, Levels and Curves, and the optional Exposure, Gradient Map and Grain, whose computed properties are getter and setter pairs; `resolvedHSV` falls back on the legacy four. `isValid` keeps Swift's bounds (hue within 360 either way, the rest within 100, each range's values alike, every band's handles finite, Levels as normalized, then every kind's own rules whatever the kind) and leaves out the `isFinite` terms the bounds already refuse and Levels' count (the twin holds four ranges). `apply` arrives with 9.5b, its reader. `ImageLayer.adjustment` joins equality and travels wherever Swift lists it: the record (`hierarchyRecord`, so the snapshot), `ImageLayer(record, …)` (so an install and a resize's rebuild), both resizers and duplicates. A copy between projects needs no term of its own: the baker bakes nothing for a layer without pixels, and the placement unlinks it, where Swift skips the bake. The manifest's `adjustment` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Adjustment.cpp` over the readers the manifest shares (`ManifestJson` in `IO/ProjectStore+Json.h`): every key required but the four optionals, which stay out when absent; a `ColorRange`-keyed dictionary is an array of key and value in turn, since Swift's `ColorRange` declares no `CodingKeyRepresentable` (a range Swift does not name refuses it, and so does an odd count, whose last key has no value to read, so Swift's count check has no twin; a later pair wins, as a dictionary assigns); the seed is a `UInt32`. The store refuses an adjustment before version 7, on a folder, with pixels or invalid; Swift's text rule's adjustment term is dead, since text needs pixels. `LiveMaskGraph::validate` refuses clipping to an adjustment layer, so `canLinkMask` refuses one as a source; `canPaint` refuses an adjustment layer's pixels, not its mask. `addAdjustment` puts a blank layer named by its kind just above the active one (into an active folder, uncollapsed; Swift's `addBlankLayer` alone lifts a layer above the folder's contents), as one `New … Adjustment` step, a Gradient Map running from the foreground to the background and each Grain with a seed of its own, 10,000 layers at most, then asks for its editor (Swift's `adjustmentEditingID`, 9.5c). `updateAdjustment` writes valid settings alone, with no step of its own, and moves `brushRevision`. Tests: `LayerAdjustmentTests` (kinds, validity, the optionals, the manifest's keys and refusals, the store's and the graph's rules, adding, updating, travel), `AdjustmentLayerTests` (Swift's `legacyHSVStillDecodesAndRenders`; the rest arrive with 9.5b and 9.5c), Swift's `settingsSaveAndOlderAdjustmentsStillOpen` in `ImageAdjustmentTests`, and an unbaked copy in `ProjectCopyTests`.
- Adjustment layers drawn (9.5b): `LayerAdjustment::apply` runs the kind's own filter (Hue/Saturation and Levels through their filters, Curves, Exposure and the Gradient Map through their settings), Grain placed by the region it is given: its origin and its units a pixel, `region.width / image.width`, so the export and `drawLiveComposite` keep the pattern in the document however their drawing is cut or scaled. Known difference, Swift's too: the canvas places layers by scale and centre, not through the painter's transform, so its region is the view's and Grain's pattern follows the view, moving under a pan and differing from the export (`grainFollowsTheViewOnTheCanvas`). `LiveMaskRenderer` gains Swift's three hooks as public members: `adjustment`, `adjustmentOpacity` and `adjustmentClip`, which multiplies the layer's own mask into device coverage where Swift clips the context. `prepareStacks` keeps every layer's mode (Swift's `blendMode`); Swift's `adjustment(base) == nil` there is dead and left out, since nothing clips to an adjustment (`LiveMaskGraph`). `drawComposite` hands an unclipped adjustment to `adjust` and draws a clipped one only inside its base's stack, where `adjust` runs on the group after its unpremultiplied base, as Swift's. `adjust` reads the painter's device (a `QImage`, as `LayerRenderer` requires; else a `logic_error`), converted to the kernels' `RGBA8888_Premultiplied` at a pixel ratio of one, within the pixel budget (past it, a warning on `omaphoto.rendering`, where Swift's `try?` is silent), applies the settings over the region the device shows (its device transform inverted), blends a mode other than normal at full coverage and restores the original alpha (`layer_extract_alpha`, `layer_unpremultiply_opaque`, `LayerRenderer::composite` for the mode, `layer_restore_alpha`), then mixes the result into the original by opacity × mask × outer clip, rounded once, where Swift mixes by opacity (`CIBlendWithMask`) and then draws through the soft clip, and draws it back one to one in `Source` mode under the painter's own clip, as Swift's copy honours the context's clip, the painter's whole device transform cancelled as the stack group cancels it, so an image of any format or pixel ratio takes it (the app hands it only `RGBA8888_Premultiplied` at a ratio of one: the export's image, a stack's group, `AdjustmentSurface`'s surface; a caller of the renderer itself may hand it any image). A failure to allocate is `ExportError`, caught there as a warning; anything else is a bug and is left to crash. `ImageExporter::render`, `drawLiveComposite` and the canvas set the hooks as Swift's three places do; `drawLiveComposite` gains Swift's `onSurface` and, when a document holds an adjustment, draws through `AdjustmentSurface`, so a painter's opacity or clip applies to the composite once. The canvas clips an adjustment by its enabled mask, mid-stroke by the edit's tiles (`maskEdit`, shared with the folders' clip, Swift's `liveMaskEdit`), placed by the layer's transform at the view's scale; its `DisplayState` counts adjustment layers with their settings, since an undo restores settings without moving `brushRevision`. Tests: Swift's `AdjustmentLayerTests` (the curves command test moved there from `ImageAdjustmentTests`, its Swift home), `theLiveCompositeDrawsAsTheExport`, `opacityMixesAndGrainSitsInTheDocument`, `everyKindAdjustsThroughItsSettings` and `aFoldersMaskClipsTheAdjustmentsInside` beside them, `anyImageTargetTakesTheAdjustment` beside them, `anAdjustmentAdjustsWithinItsBudgetAndStack` and `anAdjustmentTakesAnyImageTarget` in `LiveMaskRendererTests`, `adjustmentsShowAsTheExportDoes`, `anAdjustmentsMaskClipsItOnTheCanvas` and `grainFollowsTheViewOnTheCanvas` in `CanvasDisplayTests`.
- Adjustment layers drawn (9.5b): `LayerAdjustment::apply` runs the kind's own filter (Hue/Saturation and Levels through their filters, Curves, Exposure and the Gradient Map through their settings), Grain placed by the region it is given: its origin and its units a pixel, `region.width / image.width`, so the export and `drawLiveComposite` keep the pattern in the document however their drawing is cut or scaled. `LiveMaskRenderer` gains Swift's three hooks as public members: `adjustment`, `adjustmentOpacity` and `adjustmentClip`, which multiplies the layer's own mask into device coverage where Swift clips the context. `prepareStacks` keeps every layer's mode (Swift's `blendMode`); Swift's `adjustment(base) == nil` there is dead and left out, since nothing clips to an adjustment (`LiveMaskGraph`). `drawComposite` hands an unclipped adjustment to `adjust` and draws a clipped one only inside its base's stack, where `adjust` runs on the group after its unpremultiplied base, as Swift's. `adjust` reads the painter's device (a `QImage`, as `LayerRenderer` requires; else a `logic_error`), converted to the kernels' `RGBA8888_Premultiplied` at a pixel ratio of one, within the pixel budget (past it, a warning on `omaphoto.rendering`, where Swift's `try?` is silent), applies the settings over the region the device shows (its device transform inverted), blends a mode other than normal at full coverage and restores the original alpha (`layer_extract_alpha`, `layer_unpremultiply_opaque`, `LayerRenderer::composite` for the mode, `layer_restore_alpha`), then mixes the result into the original by opacity × mask × outer clip, rounded once, where Swift mixes by opacity (`CIBlendWithMask`) and then draws through the soft clip, and draws it back one to one in `Source` mode under the painter's own clip, as Swift's copy honours the context's clip, the painter's whole device transform cancelled as the stack group cancels it, so an image of any format or pixel ratio takes it (the app hands it only `RGBA8888_Premultiplied` at a ratio of one: the export's image, a stack's group, `AdjustmentSurface`'s surface; a caller of the renderer itself may hand it any image). A failure to allocate is `ExportError`, caught there as a warning; anything else is a bug and is left to crash. `ImageExporter::render`, `drawLiveComposite` and the canvas set the hooks as Swift's three places do; `drawLiveComposite` gains Swift's `onSurface` and, when a document holds an adjustment, draws through `AdjustmentSurface`, so a painter's opacity or clip applies to the composite once. The canvas clips an adjustment by its enabled mask, mid-stroke by the edit's tiles (`maskEdit`, shared with the folders' clip, Swift's `liveMaskEdit`), placed by the layer's transform at the view's scale; its `DisplayState` counts adjustment layers with their settings, since an undo restores settings without moving `brushRevision`. Tests: Swift's `AdjustmentLayerTests` (the curves command test moved there from `ImageAdjustmentTests`, its Swift home), `theLiveCompositeDrawsAsTheExport`, `opacityMixesAndGrainSitsInTheDocument`, `everyKindAdjustsThroughItsSettings` and `aFoldersMaskClipsTheAdjustmentsInside` beside them, `anyImageTargetTakesTheAdjustment` beside them, `anAdjustmentAdjustsWithinItsBudgetAndStack` and `anAdjustmentTakesAnyImageTarget` in `LiveMaskRendererTests`, `adjustmentsShowAsTheExportDoes` and `anAdjustmentsMaskClipsItOnTheCanvas` in `CanvasDisplayTests`.
- Editing adjustment layers in the session (9.5c): `Document/AdjustmentEditing.cpp` twins Swift's file, `AdjustmentEditing+Session.h` its session half; `adjustmentEditingID` (a getter and a setter that resumes file requests and notifies, Swift's `didSet`) and `adjustmentOriginal` (a getter) sit in `EditorSession.h`. `addAdjustment` asks for the new layer's editor last, as Swift's. `beginAdjustmentEditing` takes a completion callback: it hides every layer not beneath the adjustment in hierarchy order (the adjustment itself and all above; the records stay for live masks and folders), renders that snapshot through `ImageExporter::render` in a worker (`QtConcurrent::run` with a watcher per call, as each Swift call is a task of its own; the worker catches `ExportError` alone, since a session's snapshot never fails the store's rules), and on the UI thread opens the kind's editor on a layer of that image at the origin: Levels with its histogram (`countLevelsHistogram`, which `beginLevels` shares), Hue/Saturation with `resolvedHSV`, or the filter panel of `filterKind` (declared in `LayerAdjustment.h`, as Swift's extension) with the four settings; then it keeps the original and opens one `Edit … Adjustment` transaction. The editors' layer has an id of its own, so the canvas never shows their previews, and none renders: `updateLevels`, `updateHueSaturation` and `updateFilter` hand the settings to `previewAdjustmentEditing` first (the edited settings, or the original with Preview off) and notify, since settings the layer refuses still change the editor; the three cancels and commits hand the edit to `finishAdjustmentEditing` first (a filter's after its Gradient Map picker closes, as in Swift). `finishAdjustmentEditing` writes the edited settings on OK and the original on Cancel, closes the three edits and the Hue tools (sample mode, targeting, a targeting drag), ends the transaction, clears the request, resumes file requests, asks for canvas focus and moves `brushRevision`. OK writes the settings out even unchanged, so an Exposure or Hue/Saturation layer made without them gains them in a step, as in Swift. A Gradient Map picker stays open when `finishAdjustmentEditing` is called directly (Swift's tests call it; the app's cancel and commit close the picker first), so `setGradientMapColor` keeps Swift's guards: with no filter open, or another kind, it writes nothing; its `committing` term is dead, since a commit closes the picker before it begins. Left out as dead: Swift's `editedAdjustment` returns nil when its kind's edit is gone, which never happens while `adjustmentOriginal` is set (the two are set and cleared together, and every path that closes an edit reaches `finishAdjustmentEditing` first), so the twin reads the edit through `value()`; the `?? .curves` behind `filterKind`, a checked read; `finishAdjustmentEditing`'s cancellation of previews (none runs for an adjustment's editor, and a histogram lands only on a Levels edit still open) and its `hueSaturationPending = nil` (only a preview sets it). Swift's `.task(id:)` cancels a render whose request changed; the landing's guard (the same request, no editor open) does that work here, and nothing in the app changes the request while a render runs (every writer sits behind `canEditLayers`). A fix beyond Swift: Swift's landing checks the request alone, so a Levels, Hue/Saturation or filter edit begun on other pixels while the render ran (choosing a layer and opening one stay possible, as in Swift) would be replaced or joined by the adjustment's editor; the twin's landing leaves that edit alone, clears the request, resumes file requests and logs a warning on `omaphoto.app`. A render or an editor that fails clears the request and sets `brushError`, as Swift's `catch`. `canEditLayers` and `canStartProjectOperation` read the request; `canUseHistory` does not, as in Swift, so undo stays open while the render runs, and an editor may open for a layer undone meanwhile, whose close writes nothing. Swift's `isGroup != true` there is left out as dead: a folder not beneath the adjustment holds nothing that is, and a live mask's source is never a folder. Equivalent mutants: the start guard's request term (the landing asks again; the start's only spares the render) and its `adjustmentOriginal` term (the original never outlives its kind's edit, which the next terms read). Tests: `AdjustmentEditorTests` (Swift's `sharedEditorsKeepPixelsDynamicAndSupportCancel` for every kind, then what lies beneath, the refusals, a failed render, an edit begun meanwhile, the gates and file requests, the Hue tools, a layer undone meanwhile, the signals), Swift's `newAdjustmentLayersStartFromThePaletteRenderAndEditInThePanel` in `ImageAdjustmentTests`, the request as a gate in `SessionGateTests`; tests that add an adjustment and edit on clear the request, as Swift's `add` does.
- Editing adjustment layers in the window (9.5d): `LayersPanel` twins Swift's `.task(id: adjustmentEditingID)`: on every `changed()` (and when made, as a task runs on appear) it compares the request with the one it last began (`m_adjustmentTask`) and begins a new one; a render already under way for the same request lands nothing, so nothing needs cancelling. The footer's adjustment button is a `QToolButton` whose menu (Swift's `Menu`, `InstantPopup`) lists the six kinds by name and calls `addAdjustment`, resting while `canEditLayers` does not hold. Its glyph and the rows' are `LayerIcons` paths for Swift's SF Symbols; `symbol` names each kind's (circle.lefthalf.filled, slider.horizontal.3, the curve path turned a quarter clockwise as Swift turns it, plusminus.circle, paintpalette, circle.grid.3x3), and `adjustmentThumbnail` draws it 1.2 times the rail's size, centred in the row's 36-point square, as the text glyph is. An adjustment's row shows that glyph, "Adjustment · Double-click to edit", and no link for its mask (Swift's `linkable`); a double click on a control makes it active and asks for its editor (Swift's `renameClickedLayer`), one on the name renames. The Layer menu opens with Swift's New Adjustment Layer submenu (the kinds with an ellipsis, named `new…Adjustment`, resting while `canEditLayers` does not hold) and Edit Adjustment… (the active layer's request, for an adjustment layer alone), then a separator. The Levels sheet's caption says "Underlying pixels · alpha-weighted histogram" for an adjustment's editor, and the filter and Hue/Saturation sheets hide "Limited to the selection" there, as Swift's `adjustmentOriginal` terms. Tests: Swift's `adjustmentEditorsUseMovableNonmodalPanels` for every kind in `FloatingPanelTests`; `AdjustmentPanelTests` (the footer and its request, the rows and their glyphs, the Layer menu, the sheets' words); the Layer menu's count and names in `CompositorMenusTests`.
- Layer effects in the document and the session (9.6a): `Document/LayerEffects.h/.cpp` twin Swift's model: the four effects with Swift's defaults, `LayerEffectKind` by raw value in Swift's order (`allLayerEffectKinds`), `LayerEffectSelection`, `LayerEffects` with its helpers. Swift's file runs to 544 lines, so its session extension is `LayerEffects+Session.cpp` with `LayerEffects+Session.h`, and its renderer arrives with 9.6b. `isValid` keeps Swift's bounds and leaves out the `isFinite` terms the bounds already refuse; `offset` falls away from the light, y down. The manifest's `effects` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Effects.cpp`: every key required but `enabled`, which stays out while absent; the store holds no rule for effects, as Swift's. Swift 1.1 gave the record an `effects` key but its `projectSnapshot` and `installProject` never carried it, so its saves and exports dropped the effects; Swift agrees with the twin since 1.2.0 (`5775dd1`, `6230e41`). The twin's record (`hierarchyRecord`) carries them and `ImageLayer(record, …)` loads them, so they save, load and, with 9.6b, export. Elsewhere they travel as Swift lists them: equality, a duplicate and the brush's two commits keep them; Invert's, the filter's, Levels' and Hue/Saturation's commits, a floating merge, a copy between projects and both resizers leave them out. The session's `effectsEditing`, `effectsEditingOriginal` and `effectSelection` are getters; 9.6d gave the first two setters, which notify on a change, for `ContentView`'s check, and made `dropEffectSelection` public for the canvas's press (the list's arrive with 9.6e). `selectLayer`, `selectLayers` and `selectLayerTarget` drop the chosen effect before any guard, as Swift's, so a refusal still drops it and says so (`dropEffectSelection`); Delete and the trash take a chosen effect first. `setEffects` finishes an opacity drag and makes one step of a valid change; `changeEffects` stays with the panel's layer, a step per change as Swift's; Cancel puts back the panel's effect alone. The colour picker gains Swift's effect target (`openEffectColorPicker`, `previewEffectColor`, `closeColorPicker`'s effect case, titles such as "Color Picker (Stroke Color)"); `ColorPalette.h` declares `LayerEffectKind` opaquely, since `LayerEffects.h` includes it. Left out as dead: the `isGroup` terms of `canEditEffects`, `setEffects` and `canCopyEffect` (a folder never holds pixels, `LayerHierarchy::validate` sees to it, so `asset` says the same), `selectEffect`'s `selectedLayerIDs = [id]` (`setActiveLayerID` sets it, and `selectLayer` cannot refuse behind `canEditLayers`) and its effect picker close (every path that clears `effectsEditing` closes that picker first, `ContentView`'s check since 9.6d included). Swift has no effects tests: `LayerEffectsTests` and `LayerEffectsSessionTests` pin its code, and `ProjectCopyTests` the copy.
- Layer effects drawn (9.6b): `Rendering/LayerEffectsKernel` twins `MetalLayerEffects` as a namespace (the class only holds its device): the shader's passes in float and in its order, bands of 32 lines side by side: the alpha, the spread rows and columns, the ring, the bilinear shift, the blur rows and columns, the inner shadow's coverage and the composition, each byte rounded as the shader's `uchar(x * 255 + 0.5)`. The spread is Swift's CPU `extreme`, a sliding window each index enters and leaves once, whose maxima and minima equal the shader's loops, nothing read past the edges; the blur's weights are computed once, since every pixel of the shader computes the same ones, and each tap is added in the shader's order, the columns tap by tap over whole rows. `LayerEffectsKernelTests` replays the shader's loops as written and compares byte for byte. Metal's 80-million-pixel ceiling guards the GPU's buffers, past which Swift falls back to Core Image; the kernel takes what `render` allows, 100 million. Its buffers are allocated before any worker runs, through one helper, and one that cannot be is `ExportError(render)`; the spread's queues are sized by each pass's bands and line length, so a thin layer asks little. Workers write the result through one pointer taken before they start: `scanLine()` detaches, which races. Swift's CPU renderer (`shadowCoverage`, `innerCoverage`, `ringCoverage`, `coverage`, `strokeCoverage`, `fill`) serves machines without Metal and is dead here, as the brush's dab fallback is (8.1). `LayerEffectsRenderer` is `Document/LayerEffects+Renderer.h/.cpp`, since Swift's file runs past 500 lines: `cached` keeps Swift's cache (the pixels and mask by identity, `QImage::cacheKey` for `===`, the switched-on effects by value, eight at most within 64 MiB of results and their sources, first in first out) under a mutex, since the export's worker and the UI both draw; a failure logs on `omaphoto.rendering` where Swift's `try?` is silent, and the layer then draws bare. `placed`, `margin` and `render` follow Swift's; the mask is multiplied in with `DestinationIn`, smoothly scaled, where CoreGraphics clips to it, and the pixels are copied into the padded grid before the kernel runs. `ImageExporter::render` and `drawLiveComposite` draw a layer's effects image through `placed` with its opacity, blend mode and folder clip, the mask already inside it, as Swift's do; the canvas draws them with 9.6c. Equivalent mutant: `cached`'s validity term (`render` refuses the same effects and the cache's catch returns none; the term only spares the warning).
- Layer effects on the canvas (9.6c): `Rendering/EffectsPreviewCache` twins Swift's class, a `QObject` the session holds as `effectsPreviews`, Swift's `@ObservationIgnored` member. Swift's serial queue is a `QThreadPool` of one thread; a request waits 60 ms on a single-shot timer (precise below two seconds) and then joins the pool; Swift's locked `cancelled` is a shared atomic flag; the result is posted back and lands only while its request is still the layer's, Swift's id check. `Request` holds the asset, so a painted layer flattens on the worker. `matches`, the kept `previous`, `seed`, `rendered`, `prepare` and the shared budget (64 MiB of output, 32 to 1536 a side) follow Swift's. A reduced preview scales each source through `QImage::scaled`'s smooth filter, which measures each axis on its own (an area average where it shrinks, bilinear where it grows), for CoreGraphics' high-quality reduction, since a mask may differ in size and shape from its layer; a mask travels as alpha, so its values pass exactly; a render that fails logs on `omaphoto.rendering` and lands none, as Swift's `try?`. The destructor drops the queued renders; the pool's own destructor waits for the one under way. The canvas's completion repaints the view through a `QPointer`, Swift's `[weak self]`. A fix beyond Swift: Swift scales the stroke and the drop shadow for a reduced preview but not the inner shadow, which then shows too wide on any layer past 1536 pixels; the twin scales its distance and blur too.
- Layer effects in the document and the session (9.6a): `Document/LayerEffects.h/.cpp` twin Swift's model: the four effects with Swift's defaults, `LayerEffectKind` by raw value in Swift's order (`allLayerEffectKinds`), `LayerEffectSelection`, `LayerEffects` with its helpers. Swift's file runs to 544 lines, so its session extension is `LayerEffects+Session.cpp` with `LayerEffects+Session.h`, and its renderer arrives with 9.6b. `isValid` keeps Swift's bounds and leaves out the `isFinite` terms the bounds already refuse; `offset` falls away from the light, y down. The manifest's `effects` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Effects.cpp`: every key required but `enabled`, which stays out while absent; the store holds no rule for effects, as Swift's. Swift 1.1 gave the record an `effects` key but its `projectSnapshot` and `installProject` never carried it, so its saves and exports dropped the effects; Swift agrees with the twin since 1.2.0 (`5775dd1`, `6230e41`). The twin's record (`hierarchyRecord`) carries them and `ImageLayer(record, …)` loads them, so they save, load and, with 9.6b, export. Elsewhere they travel as Swift lists them: equality, a duplicate and the brush's two commits keep them; Invert's, Levels' and Hue/Saturation's commits, a floating merge and the canvas resizer keep them since 14.22, the image resizer scaling them (the filter's keeps them since 12.9a); a copy between projects keeps them since 12.10b, as Swift 1.2.5's list does. The session's `effectsEditing`, `effectsEditingOriginal` and `effectSelection` are getters; 9.6d gave the first two setters, which notify on a change, for `ContentView`'s check, and made `dropEffectSelection` public for the canvas's press (the list's arrive with 9.6e). `selectLayer`, `selectLayers` and `selectLayerTarget` drop the chosen effect before any guard, as Swift's, so a refusal still drops it and says so (`dropEffectSelection`); Delete and the trash take a chosen effect first. `setEffects` finishes an opacity drag and makes one step of a valid change; `changeEffects` stays with the panel's layer, a step per change as Swift's; Cancel puts back the panel's effect alone. The colour picker gains Swift's effect target (`openEffectColorPicker`, `previewEffectColor`, `closeColorPicker`'s effect case, titles such as "Color Picker (Stroke Color)"); `ColorPalette.h` declares `LayerEffectKind` opaquely, since `LayerEffects.h` includes it. Left out as dead: the `isGroup` terms of `canEditEffects`, `setEffects` and `canCopyEffect` (a folder never holds pixels, `LayerHierarchy::validate` sees to it, so `asset` says the same), `selectEffect`'s `selectedLayerIDs = [id]` (`setActiveLayerID` sets it, and `selectLayer` cannot refuse behind `canEditLayers`) and its effect picker close (every path that clears `effectsEditing` closes that picker first, `ContentView`'s check since 9.6d included). Swift has no effects tests: `LayerEffectsTests` and `LayerEffectsSessionTests` pin its code, and `ProjectCopyTests` the copy.
- Layer effects drawn (9.6b): `Rendering/LayerEffectsKernel` twins `MetalLayerEffects` as a namespace (the class only holds its device): the shader's passes in float and in its order, bands of 32 lines side by side: the alpha, the spread rows and columns, the ring, the bilinear shift, the blur rows and columns, the inner shadow's coverage and the composition, each byte rounded as the shader's `uchar(x * 255 + 0.5)`. The spread is Swift's CPU `extreme`, a sliding window each index enters and leaves once, whose maxima and minima equal the shader's loops, nothing read past the edges; the blur's weights are computed once, since every pixel of the shader computes the same ones, and each tap is added in the shader's order, the columns tap by tap over whole rows. `LayerEffectsKernelTests` replays the shader's loops as written and compares byte for byte. Metal's 80-million-pixel ceiling guards the GPU's buffers, past which Swift falls back to Core Image; the kernel takes what `render` allows, one surface (200 million since 12.11a). Its buffers are allocated before any worker runs, through one helper, and one that cannot be is `ExportError(render)`; the spread's queues are sized by each pass's bands and line length, so a thin layer asks little. Workers write the result through one pointer taken before they start: `scanLine()` detaches, which races. Swift's CPU renderer (`shadowCoverage`, `innerCoverage`, `ringCoverage`, `coverage`, `strokeCoverage`, `fill`) serves machines without Metal and is dead here, as the brush's dab fallback is (8.1). `LayerEffectsRenderer` is `Document/LayerEffects+Renderer.h/.cpp`, since Swift's file runs past 500 lines: `cached` keeps Swift's cache (the pixels and mask by identity, `QImage::cacheKey` for `===`, the switched-on effects by value, eight at most within 64 MiB of results and their sources, first in first out) under a mutex, since the export's worker and the UI both draw; a failure logs on `omaphoto.rendering` where Swift's `try?` is silent, and the layer then draws bare. `placed`, `margin` and `render` follow Swift's; the mask is multiplied in with `DestinationIn`, smoothly scaled, where CoreGraphics clips to it, and the pixels are copied into the padded grid before the kernel runs. `ImageExporter::render` and `drawLiveComposite` draw a layer's effects image through `placed` with its opacity, blend mode and folder clip, the mask already inside it, as Swift's do; the canvas draws them with 9.6c. Equivalent mutant: `cached`'s validity term (`render` refuses the same effects and the cache's catch returns none; the term only spares the warning).
- Layer effects on the canvas (9.6c): `Rendering/EffectsPreviewCache` twins Swift's class, a `QObject` the session holds as `effectsPreviews`, Swift's `@ObservationIgnored` member. Swift's serial queue is a `QThreadPool` of one thread; a request waits 60 ms on a single-shot timer (precise below two seconds) and then joins the pool; Swift's locked `cancelled` is a shared atomic flag; the result is posted back and lands only while its request is still the layer's, Swift's id check. `Request` holds the asset, so a painted layer flattens on the worker. `matches`, the kept `previous`, `seed`, `rendered`, `prepare` and the shared budget (64 MiB of output, 32 to 1536 a side) follow Swift's (the recent results and `renderNow` since 12.12a). A reduced preview scales each source through `QImage::scaled`'s smooth filter, which measures each axis on its own (an area average where it shrinks, bilinear where it grows), for CoreGraphics' high-quality reduction, since a mask may differ in size and shape from its layer; a mask travels as alpha, so its values pass exactly; a render that fails logs on `omaphoto.rendering` and lands none, as Swift's `try?`. The destructor drops the queued renders; the pool's own destructor waits for the one under way. The canvas's completion repaints the view through a `QPointer`, Swift's `[weak self]`. A fix beyond Swift: Swift scales the stroke and the drop shadow for a reduced preview but not the inner shadow, which then shows too wide on any layer past 1536 pixels; the twin scales its distance and blur too.
- `Rendering/LayerEffectsSurface` twins Swift's class: `make` for its failable `init` (80 million pixels at most, a warning past it or on a failed allocation), `matches`, `update` (tiles taken by origin and `cacheKey`, Swift's `ObjectIdentifier`), `compose` and `window`. Swift's CPU fallback (`fill` and the renderer's coverages) serves machines without Metal and is dead here, as the renderer's is; a pass paints on a copy of the surface and publishes it only once whole, so a window, kernel or copy that cannot be allocated logs and leaves the last image, and a pass's tiles count as taken only once redone, so the next update tries them again (a fix beyond Swift, which marks them first and after a failed first pass would show the empty context). The window draws a painted base from its tiles, the tiles over it, then multiplies the mask into the layer's own pixels once (`DestinationIn`, smoothly scaled); Swift draws the base and the tiles through the soft clip each, so a half-covered pixel keeps a quarter of the old pixels under the paint (known difference). Fixes beyond Swift: Swift widens a change by the margin, the room outside the layer, and reads a reach round that which leaves out the inner shadow, so an inside stroke or an inner shadow stays stale past the margin and the inner shadow is cut at the window's edge (dark bands while painting). The twin widens and reads by the reach, the inner shadow's included, and stops every window at the surface's bounds, where the renderer's padding ends: a first pass equals `LayerEffectsRenderer::render` byte for byte, and every later pass a fresh render of the painted pixels (`LayerEffectsSurfaceTests`). Left out as dead: the reach's `isEnabled` terms (the surface is made from the shown effects), Swift's empty-region guard and its zero-size terms (a stroke's grid is a pixel at least, and a change lies inside it), and the reach's `ceil` and the region's `integral` (every box is made whole where it is used, and tiles lie on whole pixels).
- The canvas's effects (9.6c): `DisplayState::Layer` carries `effects`, `drawLayers` calls `prepare`, and `synchronizeDisplay` hands a finished stroke's surface to the cache as a seed first (`handOnStrokeSurface`, Swift's opening lines). `drawOwn` keeps Swift's order: the warp, the distorted effects, the distortion's preview, the effects preview, then the stroke, the shape's preview and the colour edits' previews, so a layer with effects shows its effects over its own pixels while Levels, Hue/Saturation or a filter previews it, as Swift's does (`EffectsCanvasTests` pins it). A fix beyond Swift: Swift asks the cache twice a draw, first with the mask unplaced and the stored transform, then with what the layer shows; the first request wins, so a placed mask clips the preview unplaced, and while a transform edit moves such a layer the two requests cancel each other and render without end. The twin asks once, with the shown mask and transform, before the distortion's branches, so the mask is worked out first. The drawing functions lost their `const`: they update the stroke surface and hand the cache a repaint. `drawStroke` moved to `EditorCanvas+Effects.cpp` beside `strokeSurface` and `handOnStrokeSurface`, keeping `EditorCanvas.cpp` under 500 lines. `Distort.cpp` gains `DistortEffectsCache`, both `distortedEffects` (`const` with a mutable cache, as `distortPreview`; a warp that throws leaves the effects off, Swift's `try?`) and `commitDistort`'s seed, the cache cleared after the step as Swift's `defer` clears it. Swift has no tests for any of this: `EffectsPreviewCacheTests`, `LayerEffectsSurfaceTests` and `EffectsCanvasTests` pin its code. Equivalent mutants: the pool's thread count (a result lands by its request's id, whatever the order), the destructor's `clear` and every cancelling of a superseded request, the worker's two checks included (the landing's id check drops them; they only spare renders), the matching's `&&` between two absent masks (the sources must match anyway), `!previous` before the seed (a landed result clears its seed), the resizes skipped at full size (a draw at 1:1 copies); in the surface, the reach's floor of one (an overlay reads each pixel alone) and the stroke's `+ 2` (the spread reads `lround(size)`), `inner`'s cut at the bounds (QPainter clips the draw alike), the first region's margin (the reach goes as far) and the window's skip of tiles off its region (it only spares draws); `commitDistort`'s clearing of its cache (keyed by its inputs, the cache only frees memory). `EffectsFailureTests` starves a stroke's first pass through `AddressSpaceLimit`, in a binary of its own: the canvas logs it and shows the last effects. Untested failure paths: `rendered`'s and the landing's tests of a result, and the resize's null check, differ only when a preview fails, and a preview asks too little for `AddressSpaceLimit` to reach a worker's arena.
- Layer effects' panel (9.6d): `UI/EffectsSheet.h/.cpp` twin Swift's sheet: 340 points wide with 20 of padding and 16 between rows, the kind's headline (13 pixels, semibold), the stroke's Position as two touching checkable `QToolButton`s in an exclusive `QButtonGroup` (Swift's segmented picker; its hidden label is the box's accessible name), the colour as the Type bar's `SwatchButton` (36 by 18, radius 3, a half-black rim, `<Kind> color` its tooltip and accessible name, black while the effect is missing) opening `openEffectColorPicker`, Swift's `slider` rows (a 64-point title as the slider's buddy, a 130-point slider over a thousand steps of its range that sets the value unrounded, as Swift's continuous binding, a 48-point right-aligned `PickerField` with the title as its accessible name and placeholder, its unit two points after), then Cancel and OK ten points apart, OK the default button and Cancel no auto-default, so Return anywhere means OK, as Swift's `.defaultAction`. A field shows its value whole, ties to even as Swift's `.number` format rounds; it applies a readable finite number on leaving or on Return (which then goes on to OK), clamped to the slider's range as Swift's binding, reverts other text, keeps its typing while other changes arrive, and steps with Up and Down (Shift ten) from the session's value, clamped, the step replacing any typing and showing, as the picker's entries do; a change through the row's own slider (a wheel leaves the keys with the field) replaces it too. Each change is a step, as Swift's `changeEffects`. On every change of the picker's colour the sheet calls `previewEffectColor` (Swift's `onChange`), and it repaints its swatch on every change. While the layer lacks the effect the controls hide, as Swift's `if let effect`. `ContentView::synchronizePanels` first runs Swift's layers check (an edited effect gone from its layer, or the layer gone, closes an effect picker without its colour and clears `effectsEditing` and `effectsEditingOriginal`; a palette's picker stays), then shows the sheet in `effectsPanel`, titled by the kind, whenever `effectsEditing` changes (a new choice, a new sheet), its close button and Escape cancelling, and closes it when the choice clears. The footer's effects button (`sparkles` in `LayerIcons`: three four-pointed stars) is an instant-popup menu of the four kinds, each with an ellipsis, resting while `canEditEffects` does not hold. While an effect is chosen (`selectedEffect`, so a choice an undo took away names the layer again) the trash says `Delete selected effect` and the Layer menu's Delete `Delete <Kind>`. The canvas's left press lets go of a chosen effect before any guard, Swift's first line of `mouseDown`; other buttons keep it. Swift has no tests for the window's effects: `EffectsPanelTests` pins them, and `LayerEffectsSessionTests` the new writers' signals. Equivalent mutants: the fields' `OmitGroupSeparator` (no range reaches a thousand) and OK's explicit `setDefault` (a dialog makes its first auto-default button the default as it shows, and Cancel is none).
- Effects in the list (9.6e): `LayerEffectRow` (declared in `NativeLayerList.h`, its code in `NativeLayerList+Effects.cpp`) twins Swift's private class: a 24-point row under its layer for each effect, in Swift's order, the cell 52 points tall and 24 more for each (`rowHeight`, `effectHeight`). The list activates its column's layout after configuring the cells, as `noteHeightOfRows` changes heights at once, so no press lands on a row that a taller one still covers. The eye (20 by 22, 38 points in plus the layer's step, flat, `eye` or `eye.slash` in `PlaceholderText`, named `Hide <Kind>` or `Show <Kind>`) toggles the effect in one step and rests while `canEditLayers` does not hold; the name (11 pixels, `WindowText`, `PlaceholderText` while hidden) ends in an ellipsis when narrow, as `.byTruncatingTail`; the row's tooltip is Swift's with Alt for Option, its accessible name `<Kind> effect`, and an accessible interface of Swift's group role chooses the effect on the press action (`accessibilityPerformPress`). The rows are made anew only when the kinds change, else reconfigured in place; the old ones are hidden and deleted later, because a row's own double click may cancel a new effect's panel on the same layer, whose Cancel removes that effect and rebuilds the rows under the press, where Swift's reference counting keeps the row alive. A chosen effect's row shows `Highlight` at 30%, and while one is chosen the cell's selection hides, as Swift's table selection does. Presses follow Swift's `mouseDown` order: Alt without Ctrl on the strip (the bottom 10 points of the taller row, over its last effect; Swift's `min(10, height / 3)` is dead, rows being at least 52) clips through `clickRow`; on an effect, Alt without Ctrl arms a copy while layers can be edited, the release choosing the effect and a move past `startDragDistance` with Alt still held starting a `QDrag` of `UUID:Kind` under `effectType` with the list's token, offering Copy alone under the duplicate cursor, as the row's Alt drag does; any other left press chooses the effect, a double click editing it (Swift's `clickCount > 1`); the eye's Alt presses are the row's, ignored in its filter so they go on to it (Swift's `hitTest`). An effect dropped from the same list (Swift compares the source's session) lands on the row under the pointer, filled, as a copy, where `canCopyEffect` allows. A left press below every row lets go of the chosen effect and, without Ctrl or Shift, of every layer, as Swift's table with `allowsEmptySelection` does (a gap the list of 4.6 left). Known difference: the two points between rows take no press, where Swift's rows take their spacing. Swift has no tests for the list's effects: `LayerEffectRowTests` pins them, with `QDrag::exec` defined in its binary as in `LayerDragGestureTests`.
- Remove Background (9.7): Apple's Vision has no Linux twin, so `Document/SubjectRemoval` runs U²-Net (Qin et al., Apache-2.0, its text in `licenses/U-2-Net.txt`) on ONNX Runtime (MIT), as rembg converted it (MIT): `u2net.onnx`, 175,997,641 bytes, SHA-256 `8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491`, the MD5 rembg pins. Chosen over `u2netp` (poorer edges), `silueta` (no clear source) and IS-Net's `isnet-general-use` (sharper, but the DIS repository puts only its code and metric under Apache-2.0 and its DIS5K training set under terms of its own, so the weights' terms are unclear); 90 ms a mask on 32 cores, measured. The model is found with `QStandardPaths::locate(GenericDataLocation, "omaphoto/u2net.onnx")`, so a package installs it as `share/omaphoto/u2net.onnx` and the dev image carries it in `/usr/local/share`. `Dockerfile.dev` fetches it and Microsoft's ONNX Runtime 1.21.0 build (Ubuntu 24.04 packages none; Ubuntu 25.04 on and Arch ship theirs with the same CMake config), both by pinned checksum; that build's CMake config goes to `/usr/local/lib/cmake`, which Ubuntu's CMake searches, and its library to `lib64`, where the config looks for it; CMake links `onnxruntime::onnxruntime`. One `Ort::Session` serves the process, loaded on first use; a load that throws leaves it unloaded, so the next use tries again. Every model step passes through `vision`, where ONNX Runtime's exceptions become `SubjectRemovalError(model)` and a failed allocation (the input buffer, the runtime wrapper's own vectors) becomes `ExportError(render)`, so none passes a worker's catch. `SubjectRemovalError` twins `SubjectRemoval.Failure`: `noSubject` with Swift's words, and a `model` kind for a missing model (naming every folder looked in) or ONNX Runtime's own message, where Swift passes on Vision's error. The input is the paper's and rembg's: the image premultiplied (clear reads as black), scaled to 320 by 320, divided by its brightest byte, then ImageNet's mean and spread; the first output is stretched from its lowest to its highest value, truncated to bytes as rembg does, and scaled to the layer's grid. An output with no value at or above one half is Swift's `noSubject` (flat images give under 0.03, measured). Known difference: U²-Net's mask is made at 320 points, so Basic's edges are softer than Vision's; Advanced's Refine pulls them onto the layer's own. The raw mask of the last image is kept by `cacheKey`, Swift's `MaskCache`. `Document/GuidedMatte` twins Swift's file: `box` keeps Swift's float sums, the column pass running a row at a time; `filter` frees what it no longer needs; `levels` scales through premultiplied RGBA (Qt's smooth scaling of Grayscale8 strays by a level shrinking and up to ten enlarging, probed) and reads gray over black through `qGray`; `refine` draws back up with `SmoothTransformation`, and the model's mask is enlarged through premultiplied RGBA too. `refined` does Core Image's Shift Edge (a Gaussian with the edge repeated through `PixelAdjust::gaussianBlur`, then Swift's clamp-and-scale cut) and Contrast in float over bytes, rounded once; the blur rounds to bytes first, a level at most. Contrast runs unconditionally, being the identity at 0. The preview (`run`) refines a copy 1400 long at most and multiplies each premultiplied byte by the mask into pixels of its own, copied before the loop so a copy that finds no memory is `ExportError(render)` (a shared image would copy inside the loop and come back null); the commit (`subjectMask`) refines at full size, multiplies in a mask of the layer's own grid (another grid is a `logic_error`, never read past) (disabled or not, which is then shown, as Swift's), blends through a selection over that mask or over white, and writes the mask in one `Remove Background` step with the mask chosen; its worker flattens painted pixels and masks. A fix beyond Swift: a placed mask's pixels were dropped but its placement kept, so the new mask, made in the layer's grid, sat at the old placement; the twin clears the placement. The filter sheet shows Swift's words, Quality as two touching checkable buttons (the label hidden, its accessible name) and Advanced's Refine, Contrast and Shift Edge rows with Swift's help, made once and shown while Advanced is chosen, as Swift's `if`. Equivalent mutants: the cache's key (only the time differs) and the commit worker's catch of `SubjectRemovalError` (after its preview succeeded, only ONNX Runtime failing mid-run could throw there). Tests: `GuidedMatteTests` (the box against plain sums, the filter against He's steps in doubles, overshoot included), `SubjectRemovalTests` (Swift's `visionRequestRunsOnAnImage` among them, on a red disc the model finds), `SubjectRemovalModelTests` (a binary of its own: a broken model, a missing one, then the real one), `SubjectRemovalFailureTests` (a starved preview and a starved model input, under `AddressSpaceLimit`, glibc's mmap threshold fixed so large requests map memory the limit counLine truncated
- Crop (10.1): `Document/Crop.h/.cpp` twin Swift's file, `Crop+Session.h` its session half. `CropGeometry` is a namespace; `CropDrag::Mode` is a kind and an index, as `TransformDrag::Mode` twins an enum with payloads; `CropSnap`'s `nearest` and Swift's local `shift` are file helpers; `cropSnapTargets` returns `SnapGuides`, Swift's tuple. `valid` and `commitCrop` read the frame normalised, as `CGRect`'s `width` and `minX` read it standardised; `valid`'s `isFinite` terms are left out, since the ranges refuse NaN and infinities. `cropRect`, `cropRatioChoice` and `cropError` are getters and setters that notify on a change. `commitCrop` takes a completion callback, posted on every path; the snapshot is taken and the worker started before anything changes, the resize runs through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, the worker catching `ProjectError` and `ExportError` (`CanvasResizer::resize` turns a `std::bad_alloc` of its records and maps into `ExportError(render)`, as 9.7's `vision` does), and the result is moved out (`takeResult`) and installed through `applyDocumentSize`, one `Crop` step; memory that runs out while the snapshot is taken or the result lands sets `cropError` to the render error and changes nothing else, so the project is never left busy. Only a crop that landed drops its frame (Swift drops it first); a failure sets `cropError` to the error's text and keeps it, and the project is freed after either, Swift's `defer`. Hooks: `selectTool` cancels on a change of tool and opens the Crop tool on the whole canvas, Free, when no frame is drawn; `restore`, `beginTransform`, `importImages`, `beginLevels`, `beginFilter` and `ProjectController::begin` cancel; `installProject` and `clearProject` drop the frame and announce it with their own signal; `canEditLayers` reads `cropRect`, `canUseHistory` does not, as in Swift. The canvas's gestures live in `EditorCanvas+Crop.cpp`. A press on a grip resizes (the first region holding the point, corners first, Swift's `first(where:)`), one inside a drawn frame that is not the whole canvas moves it, any other drops the frame and draws anew; `CGRect.contains` leaves out the far edges, so the file's own `contains` does too, where `QRectF::contains` takes them. The snap is built at the press, 8 view points (`cropSnapDistance`); Alt keeps the middle still, Ctrl leaves the edges unsnapped (Swift's Control, as the Move tool's snapping reads it), and either key changing mid-drag reshapes the frame at the last hover point, as Swift's monitor reads the mouse. Swift's `?? CGRect(origin: pixel, size: .zero)` and its `if let cropSnap` are dead (the Crop tool always shows a frame; the snap is built with the drag) and are checked reads. A drag goes on under the left button alone, with the Crop tool, a free project and a document, as Swift's `mouseDragged`; a move without the left button ends it, since Qt can lose a release. `endCropDrag` is Swift's `cropDrag = nil` with its `didSet`: the release, a lost focus, Escape (cancel) and Return or Enter (commit) end the drag and free its cursor. Wheels and pinches rest mid-drag. The drag keeps the cursor its press showed in `m_dragCursor` (Swift's `.current`, with the cursor rects disabled), taken before a new frame drops the old one. Cursors: picking first, then the open hand under Space, then over a grip Qt's resize shape for Swift's frame position (top left and bottom right `SizeFDiagCursor`, top right and bottom left `SizeBDiagCursor`, top and bottom `SizeVerCursor`, left and right `SizeHorCursor`; Qt's shapes say no inward or outward), elsewhere the crosshair. `TransformOverlay` gains `cropViewRect`, `cropResizeRegions` (a `CropRegion` struct for Swift's tuple) and `drawCrop`: the canvas outside the frame under 60% black (a `QPainterPath`'s own odd-even rule), a white frame, the thirds at 40% white in one path so their crossings paint once, and 8-point white grips with black rims, antialiased, with CoreGraphics' butt caps and miter joins. Known difference: at 1× QPainter strokes one-point lines through its cosmetic stroker, which draws no joins, so the outer corner pixels of the frame and the grips stay partly clear, as the text box's (8.9c). The dimming spans the canvas, so a frame that appears or goes repaints the canvas; a frame that changes repaints the old and the new frame with their grips (`drawnRect`), since outside both the dimming stays. That is the twin's `cropDraggingRedrawsOnlyTheOverlay`: Qt has no layers, so the union of the frames repaints, pixels included, never the canvas. Under the Crop tool `renderBounds` takes the frame's union, so a frame past the canvas shows the checkerboard it would take in and redraws the canvas, as in Swift. `CropControls` is the tool's `ToolHeaderBar`: its row spaced 14 as Swift's `HStack`, Swift's labelled picker a "Ratio" `QLabel` (its combo box's buddy) and a `QComboBox` sharing 170 points, a choice that changes the ratio calling `changeCropRatio` (Swift's Line truncated
- Canvas and image size (10.2): `flipCanvas` sits in `LayerFlip.cpp`, as Swift's extension: after `commitTransform` and `cancelCrop` it asks `canEditLayers`, mirrors every layer's transform and every placed mask across the canvas's middle and the selection's outline through a `QTransform` (the path keeps its fill rule), one `Flip Canvas Horizontal` or `Flip Canvas Vertical` step; Swift's `finishOpacityEdit` is left out, since `commitTransform` has run it. `UI/CanvasSizeSheet` is Swift's form in a 450-point column: the title at 17 pixels, bold, the current size grouped through the English locale, the memory as Foundation's `ByteCountFormatter` counts it for memory (binary units: bytes, whole KB, MB to one place, GB to two, trailing zeros dropped; `UI/ByteCounts` since 10.3), a `Mid` divider, Units as a labelled `QComboBox`, Width and Height as `PickerField`s (a readable number applies on Return or on leaving, other text goes back, Swift's value binding; printed as Swift's `.number` to three places, none trailing, never grouped, as every field of the twin, halves rounding to even as Foundation's formatter rounds them), the Relative and Lock toggles (locking takes the width's proportions at once, Swift's `onChange`), the New line or the invalid note in `BrightText`, the anchor grid (nine 25-point buttons in an exclusive `QButtonGroup`, each a circle filled in the accent when chosen for Swift's `circle` and `circle.fill`, with Swift's help as the tooltip and accessible name and "Selected" as the accessible description, Swift's accessibility value), the anchor's name and note, the Canvas extension picker, and for Custom Swift's `ColorPicker` as a label and a 36 by 18 `SwatchButton` that opens a `QColorDialog` without alpha, the desktop's colour dialog where SwiftUI opens the system panel; then Cancel and the default OK, which rests while the draft is invalid, so Swift's guard in its action is left out. Units, Relative and Lock change only through their own controls, so nothing writes them back, and so does the image sheet's Resample. A wheel over Units changes the unit without the focus, so both sheets take the focus to Units first: a size still being typed commits in the unit it meant. `UI/ImageSizeSheet` is Swift's sheet: Pixels and Percent only while resampling, Width and Height converting by unit (Swift's binding refuses a number that is not positive; without resampling a size sets the resolution), Lock, disabled without resampling, the resolution with its unit (in inches or centimetres a new resolution scales the pixels, Swift's `onChange`), Resample (turned off, the pixels go back, the lock closes and Pixels or Percent becomes Inches), Sampling while resampling, the explanation, the result or the invalid note, Cancel and the default Resize. The controller's `canvasSize` and `imageSize` live in `IO/ProjectController+Resize.cpp` (with 10.3's exports the controller's file would pass 500 lines) and take a completion callback: refused without a window, a document or `begin`, as Swift's guards; the sheet is a window-modal `QDialog` over the window (`sheet`, Swift's `beginSheet`), whose Escape and close button cancel; the project stays busy until the sheet answers and the resize lands, or its alert is dismissed, Swift's `defer`. `resizeProject` takes the snapshot and starts the worker before anything changes, the worker catching `std::runtime_error` (both of the resizers' errors), and moves the result out and lands it through `applyDocumentSize` or `applyImageSize` (staged since 10.1); memory that runs out while the snapshot is taken or the result lands is explained as the render error. The alert opens from the handler, once the unwinding has freed what failed. The IO file includes the sheets, as Swift's controller hosts them. The Image menu gains Canvas Size… (Ctrl+Alt+C) and Image Size… (Ctrl+Alt+I), enabled with a document while the controller can start, and Flip Canvas Horizontal and Vertical, enabled by `canEditLayers`. Tests: `CanvasSizeSheetTests`, `ImageSizeSheetTests`, `ProjectResizeTests` (the sheets over a window, typed and answered by keys, a cancel, a refusal, a failure's alert), `ProjectResizeFailureTests` (a binary of its own, `main` as `CropFailureTests`': the image resizer, the snapshot, the worker and the landing each run out), the canvas flips in `LayerFlipTests`, the entries in `CompositorMenusTests`.
- Exports (10.3): `IO/ProjectController+Export.cpp` holds `exportPNG` and `exportJPEG`, each with a completion callback. `exportPNG` asks for a document and `begin` (no window: its panel then stands alone), takes the snapshot, opens the panel and renders, encodes and writes in one worker (`exportWork`, which catches `std::runtime_error`, the exporter's errors and the write's); `exportJPEG` also needs the window, renders in a worker (catching `ExportError`), shows `JPEGExportSheet` in the window-modal `sheet` of 10.2, then the panel, then writes the sheet's bytes in a worker. The project stays busy until the file is written, the flow is cancelled or its alert is dismissed, Swift's `defer`; a snapshot that finds no memory is explained as the render error, and `ImageExporter::render` and `jpeg` turn `std::bad_alloc` into `ExportError(render)` at their doors (function-try-blocks), which also serves 9.5c's editor render; the PNG snapshot's copy for the worker and `exportWork`'s launch are guarded too, explained as the render error. The launch's guard is not reached by the tests: its few small allocations fail only under a limit that fails the alert as well. The panel's callback and `showError`'s are moved, never copied, since the PNG flow's completion holds the snapshot. `exportPanel` is Swift's `NSSavePanel` for one type: a `QFileDialog` titled Export PNG or Export JPEG with the type's name filter and default suffix, suggesting the project's name (`ProjectTab::nameWithoutSuffix`, Swift's `deletingPathExtension`) or Untitled with `.png` or `.jpg`; a chosen name without one of the type's suffixes (`png`; `jpeg`, `jpg`, `jpe`; any case) gains the preferred one (`png`, and `jpeg`, `UTType.jpeg`'s preferred extension), where the Mac panel enforces the type, and as with Save As a completed name where anything exists is refused with an alert. `ImageExporter::jpeg` takes Swift's three cancellation points as an optional `cancelled` test, throwing `CancellationError` (Swift's type) before the flatten, before the encoding and before the preview. `UI/JPEGExportSheet` twins Swift's sheet: the title, the 560 by 330 preview on gray 0.12 (the encoded preview fitted, centred and smooth; while updating, a 64-point plate of the window colour at 85%, Swift's regular material, behind a 32-point busy `QProgressBar`), Quality as a slider in hundredths with its percent (`lround`, Swift's `rounded()`) 45 points wide and right-aligned, the matte as a label and a 36 by 18 `SwatchButton` that opens a `QColorDialog` without alpha (the same colour asks nothing, as Swift's `onChange`), the raster's size grouped with "px · sRGB", then the error in `BrightText`, or the file's size and "· encoded preview, fitted to window", or "Updating preview…", and Cancel and the default Export…, enabled only while a ready result stands. Swift's `.task(id: options)` is `request`: it sets the last request's flag, clears the error and restarts `jpegWait`, a 200 ms single-shot `PreciseTimer` (Swift's sleep), whose timeout encodes in a worker holding the new flag; a result or error lands only while its flag is clear, Swift's check after the await; the destructor sets the last flag, as SwiftUI cancels the task with its view. Export stores the quality under Swift's key in `QSettings`; a stored value counts when it reads as a finite number, clamped to 0–1. `UI/ByteCounts` is Linux-only: Foundation's `ByteCountFormatter` in its memory style (binary) and file style (decimal), shared by the canvas sheet and the JPEG sheet: bytes grouped, KB whole, MB to one place, GB to two, trailing zeros dropped. The File menu gains Export PNG… (Ctrl+Shift+E) and Export JPEG… (Ctrl+Alt+Shift+S) after Save As, enabled with a document while the controller can start. Copy Merged came with 7.4. Tests: `JPEGExportSheetTests`, `ProjectExportTests` (the real panels and alerts from `DialogDesk`, names typed in full since a panel opens where the last one went, and the File menu's entries), `ProjectExportFailureTests` (a binary of its own: the render's door, the snapshot, the JPEG worker; `DialogDesk::aroundAccept` sets a limit around a panel's accept alone, since a dialog that paints under one can fail in FreeType and abort; the panel limits sit at 2,560 KB, above the snapshot's peak of 2,300 KB (768 KB and a 1,532 KB mapping, reached before the panel allocates) and one arena growth, below a copying mutant's 3,060 KB peak, measured in 12.8d, because the heap's free chunks count on top of a limit and shift with load; `limited`'s `malloc_trim(0)` is an equivalent mutant, since no free top chunk serves a 1,532 KB mapping), `ByteCountsTests`, the cancellation points in `JPEGExportTests`, the shortcuts and the count in `CompositorMenusTests`. Equivalent mutants: the options a result lands with taken as the current ones (only the newest request lands, and its options are current), the `!m_error` terms of the size's visibility and of Export (an error follows only a failed encoding of oLine truncated
- Canvas and image size (10.2): `flipCanvas` sits in `LayerFlip.cpp`, as Swift's extension: after `commitTransform` and `cancelCrop` it asks `canEditLayers`, mirrors every layer's transform and every placed mask across the canvas's middle and the selection's outline through a `QTransform` (the path keeps its fill rule), one `Flip Canvas Horizontal` or `Flip Canvas Vertical` step; Swift's `finishOpacityEdit` is left out, since `commitTransform` has run it. `UI/CanvasSizeSheet` is Swift's form in a 450-point column: the title at 17 pixels, bold, the current size grouped through the English locale, the memory as Foundation's `ByteCountFormatter` counts it for memory (binary units: bytes, whole KB, MB to one place, GB to two, trailing zeros dropped; `UI/ByteCounts` since 10.3), a `Mid` divider, Units as a labelled `QComboBox`, Width and Height as `PickerField`s (a readable number applies on Return or on leaving, other text goes back, Swift's value binding; printed as Swift's `.number` to three places, none trailing, never grouped, as every field of the twin, halves rounding to even as Foundation's formatter rounds them), the Relative and Lock toggles (locking takes the width's proportions at once, Swift's `onChange`), the New line or the invalid note in `BrightText`, the anchor grid (nine 25-point buttons in an exclusive `QButtonGroup`, each a circle filled in the accent when chosen for Swift's `circle` and `circle.fill`, with Swift's help as the tooltip and accessible name and "Selected" as the accessible description, Swift's accessibility value), the anchor's name and note, the Canvas extension picker, and for Custom a label and a `DialogColorSwatch` on the app's picker (13.4a); then Cancel and the default OK, which rests while the draft is invalid, so Swift's guard in its action is left out. Units, Relative and Lock change only through their own controls, so nothing writes them back, and so does the image sheet's Resample. A wheel over Units changes the unit without the focus, so both sheets take the focus to Units first: a size still being typed commits in the unit it meant. `UI/ImageSizeSheet` is Swift's sheet: Pixels and Percent only while resampling, Width and Height converting by unit (Swift's binding refuses a number that is not positive; without resampling a size sets the resolution), Lock, disabled without resampling, the resolution with its unit (in inches or centimetres a new resolution scales the pixels, Swift's `onChange`), Resample (turned off, the pixels go back, the lock closes and Pixels or Percent becomes Inches), Sampling while resampling, the explanation, the result or the invalid note, Cancel and the default Resize. The controller's `canvasSize` and `imageSize` live in `IO/ProjectController+Resize.cpp` (with 10.3's exports the controller's file would pass 500 lines) and take a completion callback: refused without a window, a document or `begin`, as Swift's guards; the sheet is a window-modal `QDialog` over the window (`sheet`, Swift's `beginSheet`), whose Escape and close button cancel; the project stays busy until the sheet answers and the resize lands, or its alert is dismissed, Swift's `defer`. `resizeProject` takes the snapshot and starts the worker before anything changes, the worker catching `std::runtime_error` (both of the resizers' errors), and moves the result out and lands it through `applyDocumentSize` or `applyImageSize` (staged since 10.1); memory that runs out while the snapshot is taken or the result lands is explained as the render error. The alert opens from the handler, once the unwinding has freed what failed. The IO file includes the sheets, as Swift's controller hosts them. The Image menu gains Canvas Size… (Ctrl+Alt+C) and Image Size… (Ctrl+Alt+I), enabled with a document while the controller can start, and Flip Canvas Horizontal and Vertical, enabled by `canEditLayers`. Tests: `CanvasSizeSheetTests`, `ImageSizeSheetTests`, `ProjectResizeTests` (the sheets over a window, typed and answered by keys, a cancel, a refusal, a failure's alert), `ProjectResizeFailureTests` (a binary of its own, `main` as `CropFailureTests`': the image resizer, the snapshot, the worker and the landing each run out), the canvas flips in `LayerFlipTests`, the entries in `ImageMenuTests` (moved out of `CompositorMenusTests` at 500 lines, 14.4).
- Exports (10.3): `IO/ProjectController+Export.cpp` holds `exportPNG` and `exportJPEG` (since 14.18 `exportAs`, whose sheet and flow its bullet describes), each with a completion callback. `exportPNG` asks for a document and `begin` (no window: its panel then stands alone), takes the snapshot, opens the panel and renders, encodes and writes in one worker (`exportWork`, which catches `std::runtime_error`, the exporter's errors and the write's); `exportJPEG` also needs the window, renders in a worker (catching `ExportError`), shows `JPEGExportSheet` in the window-modal `sheet` of 10.2, then the panel, then writes the sheet's bytes in a worker. The project stays busy until the file is written, the flow is cancelled or its alert is dismissed, Swift's `defer`; a snapshot that finds no memory is explained as the render error, and `ImageExporter::render` and `jpeg` turn `std::bad_alloc` into `ExportError(render)` at their doors (function-try-blocks), which also serves 9.5c's editor render; the PNG snapshot's copy for the worker and `exportWork`'s launch are guarded too, explained as the render error. The launch's guard is not reached by the tests: its few small allocations fail only under a limit that fails the alert as well. The panel's callback and `showError`'s are moved, never copied, since the PNG flow's completion holds the snapshot. `exportPanel` is Swift's `NSSavePanel` for one type: a `QFileDialog` titled Export PNG or Export JPEG with the type's name filter and default suffix, suggesting the project's name (`ProjectTab::nameWithoutSuffix`, Swift's `deletingPathExtension`) or Untitled with `.png` or `.jpg`; a chosen name without one of the type's suffixes (`png`; `jpeg`, `jpg`, `jpe`; any case) gains the preferred one (`png`, and `jpeg`, `UTType.jpeg`'s preferred extension), where the Mac panel enforces the type, and as with Save As a completed name where anything exists is refused with an alert. `ImageExporter::jpeg` takes Swift's three cancellation points as an optional `cancelled` test, throwing `CancellationError` (Swift's type) before the flatten, before the encoding and before the preview. `UI/JPEGExportSheet` (since 14.18 `UI/ExportAsSheet`) twins Swift's sheet: the title, the 560 by 330 `JPEGPreview` on gray 0.12 (13.4b; while updating, a 64-point plate of the window colour at 85%, Swift's regular material, behind a 32-point busy `QProgressBar`), Quality as a slider in hundredths with its percent (`lround`, Swift's `rounded()`) 45 points wide and right-aligned, the matte as a label and a `DialogColorSwatch` on the app's picker (13.4a; the same colour asks nothing, as Swift's `onChange`), then one row of the raster's size grouped with "px · sRGB" (since 14.18 the format's note; the size is a grid row of its own), the error in `BrightText`, or the file's size, or "Updating…", and Cancel and the default Export…, enabled only while a ready result stands. Swift's `.task(id: options)` is `request`: it sets the last request's flag, clears the error and restarts `jpegWait` (`exportWait` since 14.18), a 200 ms single-shot `PreciseTimer` (Swift's sleep), whose timeout encodes in a worker holding the new flag; a result or error lands only while its flag is clear, Swift's check after the await; the destructor sets the last flag, as SwiftUI cancels the task with its view. Export stores the quality under Swift's key in `QSettings`; a stored value counts when it reads as a finite number, clamped to 0–1. `UI/ByteCounts` is Linux-only: Foundation's `ByteCountFormatter` in its memory style (binary) and file style (decimal), shared by the canvas sheet and the JPEG sheet: bytes grouped, KB whole, MB to one place, GB to two, trailing zeros dropped. The File menu gains Export PNG… (Ctrl+Shift+E) and Export JPEG… (Ctrl+Alt+Shift+S) after Save As, enabled with a document while the controller can start. Copy Merged came with 7.4. Tests: `ExportAsSheetTests`, `ProjectExportTests` (the real panels and alerts from `DialogDesk`, names typed in full since a panel opens where the last one went, and the File menu's entries), `ProjectExportFailureTests` (a binary of its own: the render's door, the snapshot, the JPEG worker; `DialogDesk::aroundAccept` sets a limit around a panel's accept alone, since a dialog that paints under one can fail in FreeType and abort; the project holds 2,048 layers and the panel limits sit at 5,120 KB, between the snapshot's peak of 3,456 KB and a copying mutant's 6,912 KB, measured in 12.16, because the heap's free chunks count on top of a limit and shift with load; `projectSnapshot` reserves its record list, since a growing list held two at once (5,120 to 5,376 KB at its peak, which made the old 2,560 KB limits for 1,024 layers fail one run in three), and `aSnapshotHoldsOneRecordList` holds that at 4,608 KB; `limited`'s `malloc_trim(0)` is an equivalent mutant, since no free top chunk serves a mapping that size), `ByteCountsTests`, the cancellation points in `JPEGExportTests`, the shortcutLine truncated
- File and picture drops (10.4): `IO/ImageFileDrop` twins Swift's enum as a namespace. `importProviders` takes the drop's `QMimeData`, where Swift takes item providers: each local file URL is a file, in order; a URL that is no file counts as unreadable, unless no file came at all and the drop holds a picture (a browser's or a screenshot's), whose bytes are copied to `Dropped-<UUID>` in the temporary folder, as Swift's `temporaryFile` copies a promised file, the format chosen in Swift's order (PNG, JPEG, HEIC, TIFF, then any `image/` type) and the suffix from Qt's MIME database. The files go to the workspace's `receive` or, without a workspace, to the session's `importImages`; once that has run, a drop with anything unreadable adds Swift's words to the session's import error, after the importer's own ("from the file manager" where Swift says "from Finder"). Swift's `projects:` parameter has no caller and is left out. `holdsImages` is Swift's drop types but the layer row's: a local file or a picture. `receiveProviders` copies layer rows as before and hands anything else to `importProviders` one provider at a time (`providers`: a URL each, or, when no file came, one item holding the preferred picture alone, so a lazy drag is asked for nothing else, as Swift asks for one format; its web addresses are left behind, since alone they read as unreadable either way), each after the last has run, for the tab then in front, whose session hears what could not be read and stays alive until its import has run; so an import error holds the next file until it is dismissed, Swift's gate between providers. A copied picture that cannot be written whole (its flush checked, since `QFile` buffers) is removed and counts as unreadable; a failed flush fails `QFile::remove` too, so the copy is closed and unlinked by path. An open alert shows its message as it stands (`showAlert` updates it), as SwiftUI's alert re-reads it. The canvas, the tab buttons and the New canvas button take files and pictures as they take rows (a canvas without a workspace under Swift's session guards, Levels, busy, the new-canvas sheet, the importer and a rename, importing into its own session); Alt refuses only a row, as Swift's `validateDrop`. Tests: `ImageFileDropTests`.
- Slider snapping (10.5): `UI/SliderSnap` twins Swift's enum as a namespace. Swift swaps `NSSliderCell.startTracking` so that a press on the track sets the value whose knob is centred there before tracking begins; the twin's `install` puts one application event filter in place, once, as Swift's lazy static swaps once: a left press on an enabled horizontal `QSlider` with a range, outside its knob and with travel for it, sets the slider down, then sets the value under the press (`QStyle::sliderValueFromPosition` over the groove less the knob, the knob's half-width off, flipped for an inverted slider), and presses where the knob now is, the original press consumed, so Qt drags the knob from there even when the slider's owner rounds the value and moves it (a filter's field); a press made with another button held is left to Qt, which refuses it. So editing begins before the value, Swift's order (the opacity slider's drag opens its one step first), and a press on the knob drags it from where it is. QSlider keeps its style option protected, so the filter builds one as `initStyleOption` does. Qt's own absolute set (`SH_Slider_AbsoluteSetButtons`) would set the value before the press and recentre a pressed knob, so it is not used, but it is the test's oracle for where the knob lands. `main` installs it after the theme. Tests: `SliderSnapTests` (Swift's `clickingTheTrackSnapsTheKnobAndStillEditsTheValue`: the value under the press at once, editing begun first, a drag going on, the release; the knob, other buttons, a disabled, vertical, empty, inverted and narrow slider). Equivalent mutant: the groove's own offset, zero under Fusion, the app's one style. The call in `main` is not under test, as the theme's construction there is not.
- Selection modifiers, Select Subject, Soft Light and the font menu (12.1, Swift 1.1.1–1.1.4): `DocumentSelection` gains `feather`, the fade's reach in document pixels. Its coverage is filled antialiased and blurred by `PixelAdjust::gaussianBlur` at sigma feather / 2 with the edge repeated, Core Image's `clampedToExtent`; `coverageBounds` grows the outline's box by `ceil(2 × feather)`, and the clip, the copy region and a mask made from the selection (now drawn through `PixelAdjust::coverage` from the clip) take it. A feather stacks as blurs do (the root of the squares, 250 at most) and travels through moves, Expand, Contract and Inverse. The Select menu's Expand…, Contract… and Feather… ask for an amount (`promptSelectionAmount`); `selectionAmountOperation` is a getter and a setter that resumes file requests, read first by `canEditLayers`, `canUseHistory` and `canStartProjectOperation`. `SelectionAmountSheet` (in `LassoControls`, Swift's file) is Swift's sheet in a `FloatingPanel` named `selectionAmountPanel`, titled by the operation; its note keeps its room while hidden, as SwiftUI's opacity; `QString::toInt` trims spaces as Swift's `trimmingCharacters` does. The selection bar gains Feather and its amount (1–250). Select ▸ Subject (Ctrl+Alt+A) draws the live composite, runs `SubjectRemoval::subjectMask` in a worker with the project busy (a completion callback, as the other jobs), and on the same document applies `MaskTracing::whitePixels` (at least half gray) as the selection, one `Select Subject` step, or sets `brushError`; `canSelectSubject` leaves out Swift's document and busy terms, which `canEditLayers` reads. Soft Light joins `LayerBlendMode` after Overlay as QPainter's `CompositionMode_SoftLight`, which follows the PDF formula `BlendModeTests` holds. `TypeFontPicker` is Swift's: the style's own name alone until the menu opens, a missing name added at the end, an open menu left alone, the face already shown writing nothing, the current name elided at its end within 210 points (Fusion's label draws a pixel in from each side of the edit field). Point text caches its measure by style in the inline editor, and `setTextDraft` resumes file requests only when a draft ends, as Swift 1.1.3's. Swift's `Geometry` cache, which skips AppKit's frame work, has no twin: the canvas draws the editor. Equivalent mutants: the editor's box branch (`textBoxSize` returns the box), `setTextDraft`'s ended test (`resumeFileRequests` asks `canStartProjectOperation`, which a draft refuses). Tests: `SelectionFeatherTests` (Swift's, then stacking, travel, bounds and the sheet's session half), `SelectionAmountSheetTests`, `SelectSubjectTests`, `TypeFontPickerTests`, and the menus, blend modes and Type bar in their files.
- Folders, opacity and Snap (12.2a, Swift 1.1.5–1.1.6): a folder takes an opacity that multiplies into each layer inside it, never composited as a unit, since folders pass through. `LayerOpacity::effective` (in `LayerGroups.h`) walks up to 64 folders, a lookup standing for Swift's closure; `ImageLayer::effectiveOpacity`, `ProjectLayerRecord::effectiveOpacity` (declared in `ProjectStore.h`, defined in `LayerGroups.cpp`, as Swift's extension) and `CanvasDocument::effectiveOpacities` twin Swift's, the last refusing a repeated id with a `logic_error` where Swift's dictionary traps. Every drawing path takes it: the export, `drawLiveComposite`, the canvas (its every draw, adjustments included, and `DisplayState`, so a folder's change redraws) and the live-mask baker's sources, whose records are pointers, so it calls `LayerOpacity::effective` itself. `canEditOpacity` is one layer, a folder included; `canEditAppearance` stays for blending, so `LayerAppearanceControls` rests with `canEditOpacity` and its Blend caption and picker with `canEditAppearance`; `setSelectedLayersOpacity` dims selected folders too. The format moves to version 8 here (Swift's guides arrive under it in 12.3; until then a version-8 project's `guides` key is ignored as unknown), because 1.2.0's store rule (`d9b3f9e`) lets a folder dim from version 8 on, its blend mode always normal. View ▸ Snap is `snappingEnabled`, a getter and a setter that clears the guides and notifies (Swift's `didSet`); `snappedMove` and the crop drag read it. The transform box draws its accent alone. The effects sheet's sliders span Swift's new ranges (Size 0–20, Distance 0–100, the inner shadow's 0–50, Blur 0–100) while typing and steps reach the effect's own limits (500, 5,000, 500), clamped by `apply`; `QSlider` pins a larger value at its end by itself, so Swift's clamp of the thumb has no twin; the swatch takes a white ring inside a black rim, where SwiftUI's `contentShape` has no need of a twin (a Qt button takes its whole rect). The layer list takes T for Type, as 1.1.6's table does. Equivalent mutant: `canEditOpacity`'s `activeLayer()` term (one selected layer is the active one). `drawPixelGrid` moved to `EditorCanvas+Grid.cpp`, keeping the canvas file under 500 lines. Tests: `FolderOpacityTests` (the walk, the export, the composite, adjustments, effects, the canvas in each way it draws, the baker, Swift's `aDimmedFolderSavesAndReopens`), folder duplication in `LayerDuplicateTests` (moved out of `EditorSessionTests` at 500 lines), `TransformTests` and `LayerDragTests`, the controls in `LayerAppearanceControlsTests`, Snap in `TransformSnapTests`, `CropCanvasTests` and `ViewMenuTests` (the View menu tests, moved out of `CompositorMenusTests`), the ranges and swatch in `EffectsPanelTests`.
- Slider snapping (10.5): `UI/SliderSnap` twins Swift's enum as a namespace. Swift swaps `NSSliderCell.startTracking` so that a press on the track sets the value whose knob is centred there before tracking begins; the twin's `install` puts one application event filter in place, once, as Swift's lazy static swaps once: a left press on an enabled horizontal `QSlider` with a range, outside its knob and with travel for it, sets the slider down, then sets the value under the press (`QStyle::sliderValueFromPosition` over the groove less the knob, the knob's half-width off, flipped for an inverted slider), and presses where the knob now is, the original press consumed, so Qt drags the knob from there even when the slider's owner rounds the value and moves it (a filter's field); a press made with another button held is left to Qt, which refuses it. So editing begins before the value, Swift's order (the opacity slider's drag opens its one step first), and a press on the knob drags it from where it is. QSlider keeps its style option protected, so the filter builds one as `initStyleOption` does. Qt's own absolute set (`SH_Slider_AbsoluteSetButtons`) would set the value before the press and recentre a pressed knob, so it is not used, but it is the test's oracle for where the knob lands. `main` installs it after the theme. Tests: `SliderSnapTests` (Swift's `clickingTheTrackSnapsTheKnobAndStillEditsTheValue`: the value under the press at once, editing begun first, a drag going on, the release; the knob, other buttons, a disabled, vertical, empty, inverted and narrow slider). Equivalent mutant: the groove's own offset, zero under Fusion, the app's one style. The call in `main` is not under test, as the theme's construction there is not. Swift's `07eee7e` draws its window before the press, since an `NSSliderCell` learns its track only when it draws; Qt's style measures the groove without a paint, so the twin's test presses a slider that has never painted and still snaps (14.21).
- Selection modifiers, Select Subject, Soft Light and the font menu (12.1, Swift 1.1.1–1.1.4): `DocumentSelection` gains `feather`, the fade's reach in document pixels. Its coverage is filled antialiased and blurred by `PixelAdjust::gaussianBlur` at sigma feather / 2 with the edge repeated, Core Image's `clampedToExtent`; `coverageBounds` grows the outline's box by `ceil(2 × feather)`, and the clip, the copy region and a mask made from the selection (now drawn through `PixelAdjust::coverage` from the clip) take it. A feather stacks as blurs do (the root of the squares, 250 at most) and travels through moves, Expand, Contract and Inverse. The Select menu's Expand…, Contract… and Feather… ask for an amount (`promptSelectionAmount`); `selectionAmountOperation` is a getter and a setter that resumes file requests, read first by `canEditLayers`, `canUseHistory` and `canStartProjectOperation`. `SelectionAmountSheet` (in `LassoControls`, Swift's file) is Swift's sheet in a `FloatingPanel` named `selectionAmountPanel`, titled by the operation; its note keeps its room while hidden, as SwiftUI's opacity; `QString::toInt` trims spaces as Swift's `trimmingCharacters` does. The selection bar gains Feather and its amount (1–250). Select ▸ Subject (Ctrl+Alt+A) draws the live composite, runs `SubjectRemoval::subjectMask` in a worker with the project busy (a completion callback, as the other jobs), and on the same document applies `MaskTracing::whitePixels` (at least half gray) as the selection, one `Select Subject` step, or sets `brushError`; `canSelectSubject` leaves out Swift's document and busy terms, which `canEditLayers` reads. Soft Light joins `LayerBlendMode` after Overlay (since 13.9a hand-blended by Photoshop's formula, see that bullet). `TypeFontPicker` is Swift's: the style's own name alone until the menu opens, a missing name added at the end, an open menu left alone, the face already shown writing nothing, the current name elided at its end within 210 points (Fusion's label draws a pixel in from each side of the edit field). Point text caches its measure by style in the inline editor, and `setTextDraft` resumes file requests only when a draft ends, as Swift 1.1.3's. Swift's `Geometry` cache, which skips AppKit's frame work, has no twin: the canvas draws the editor. Equivalent mutants: the editor's box branch (`textBoxSize` returns the box), `setTextDraft`'s ended test (`resumeFileRequests` asks `canStartProjectOperation`, which a draft refuses). Tests: `SelectionFeatherTests` (Swift's, then stacking, travel, bounds and the sheet's session half), `SelectionAmountSheetTests`, `SelectSubjectTests`, `TypeFontPickerTests`, and the menus, blend modes and Type bar in their files.
- Folders, opacity and Snap (12.2a, Swift 1.1.5–1.1.6): a folder takes an opacity that multiplies into each layer inside it, never composited as a unit, since folders pass through. `LayerOpacity::effective` (in `LayerGroups.h`) walks up to 64 folders, a lookup standing for Swift's closure; `ImageLayer::effectiveOpacity`, `ProjectLayerRecord::effectiveOpacity` (declared in `ProjectStore.h`, defined in `LayerGroups.cpp`, as Swift's extension) and `CanvasDocument::effectiveOpacities` twin Swift's, the last refusing a repeated id with a `logic_error` where Swift's dictionary traps. Every drawing path takes it: the export, `drawLiveComposite`, the canvas (its every draw, adjustments included, and `DisplayState`, so a folder's change redraws) and the live-mask baker's sources, whose records are pointers, so it calls `LayerOpacity::effective` itself. `canEditOpacity` is one layer, a folder included; `canEditAppearance` stays for blending, so `LayerAppearanceControls` rests with `canEditOpacity` and its Blend caption and picker with `canEditAppearance`; `setSelectedLayersOpacity` dims selected folders too. The format moves to version 8 here (Swift's guides arrive under it in 12.3; until then a version-8 project's `guides` key is ignored as unknown), because 1.2.0's store rule (`d9b3f9e`) lets a folder dim from version 8 on, its blend mode always normal. View ▸ Snap was `snappingEnabled`, a getter and a setter that cleared the guides and notified (Swift's `didSet`); `snappedMove` and the crop drag read it (one Snap since 14.5). The transform box draws its accent alone. The effects sheet's sliders span Swift's new ranges (Size 0–20, Distance 0–100, the inner shadow's 0–50, Blur 0–100) while typing and steps reach the effect's own limits (500, 5,000, 500), clamped by `apply`; `QSlider` pins a larger value at its end by itself, so Swift's clamp of the thumb has no twin; the swatch takes a white ring inside a black rim, where SwiftUI's `contentShape` has no need of a twin (a Qt button takes its whole rect). The layer list takes T for Type, as 1.1.6's table does. Equivalent mutant: `canEditOpacity`'s `activeLayer()` term (one selected layer is the active one). `drawPixelGrid` moved to `EditorCanvas+Grid.cpp`, keeping the canvas file under 500 lines. Tests: `FolderOpacityTests` (the walk, the export, the composite, adjustments, effects, the canvas in each way it draws, the baker, Swift's `aDimmedFolderSavesAndReopens`), folder duplication in `LayerDuplicateTests` (moved out of `EditorSessionTests` at 500 lines), `TransformTests` and `LayerDragTests`, the controls in `LayerAppearanceControlsTests`, Snap in `TransformSnapTests`, `CropCanvasTests` and `ViewMenuTests` (the View menu tests, moved out of `CompositorMenusTests`), the ranges and swatch in `EffectsPanelTests`.
- Keyboard shortcuts (12.2b, Swift 1.1.6): `UI/KeyboardShortcuts.h/.cpp` twin Swift's file, `KeyboardShortcuts+Sheet.cpp` its sheet and recorder. `ShortcutChord` keeps Swift's key strings (Backspace 0x7f, Return, Escape, Tab, Space, the arrows as Cocoa's F700s) and modifier bits, Ctrl for Command, Alt for Option, Meta for Control, then Shift; a key event reads Backspace and Delete alike, Enter as Return, Backtab as Tab, a shifted bracket or sign as its own key, anything else by its lowered key name, so a function key's name is long and refused (Swift takes it as a private-use character; known difference); labels are `QKeySequence`'s native text (Ctrl+Alt+K), where Swift prints symbols. `ShortcutDefinition::all` is Swift's list less Hide Compositor, which has no twin; `problem` keeps Swift's rules less its three chords reserved by macOS (the desktop takes its own before the app does). `ShortcutSettings` keeps the overrides in `QSettings` under Swift's `keyboardShortcuts.v1`, as Swift's JSON, a stored set that fails `problem` ignored with a warning on `omaphoto.app` where Swift is silent; `changed()` stands for its observation, and `reload` reads the store again, as a launch does. The menus remember each entry's own key in a property and take `menu` of it on every change. `canvasEvent` and `textEvent` hand back the press to handle, or none to swallow: the canvas (its key handling moved to `EditorCanvas+Keys.cpp`, the navigation file at 500 lines), the layer list and the window's key filter read `canvasEvent`, open text `textEvent`; a translated press the canvas or list leaves passes the original on, as `super.keyDown` does. Swift's `panPhysicalKey` is `m_panKey`: the key whose release ends a remapped pan. `NativeShortcut` is `configuredNativeShortcut`: a `QShortcut` over the sheet and its children clicks its button when that shows (a disabled button refuses `click` by itself); its own plain Return or Escape rests it, leaving the dialogs' default and escape handling, so a field's Return still commits first, while a button moved onto the other's key takes it; a moved original key is swallowed at the sheet; a synthesized press carries Swift's shifted text (`}` for Shift+]), which the brush keys read; `NewCanvasSheet`'s own Return shortcuts rest while Apply is moved; Levels' Preview takes its chord this way, in place of the checkbox's shortcut. The sheet is Swift's: 660 wide, the hint, a search, the three groups of rows with 150-by-26 recorders, the contextual words in Linux's keys, the problem in `BrightText` at 12 pixels only while there is one, Restore Defaults, Cancel and a default Save resting while recording or wrong; a modifier alone keeps recording, and while recording the recorder claims every key, Swift's `performKeyEquivalent`: a `Qt::Tool` panel keeps its parent window's menu shortcuts live, and `QWidget::event` would move the focus on Tab. It opens from the Edit menu, after Paste and a separator, in the `keyboardShortcuts` panel `CompositorMenus` holds. Equivalent mutants: `menu`'s and the Shift branch's `isMenu` terms (menu chords all carry modifiers), `canvasEvent`'s early return without overrides, `m_panKey`'s reset (every pan sets it) and Save's explicit default (its only auto-default button). Tests: `KeyboardShortcutsTests`, `KeyboardShortcutsSheetTests`, `RemappedKeysTests`.
- Guides and the layout grid (12.3a, Swift 1.1.7): `Document/Guides.h/.cpp` twin Swift's `Guides.swift`: `CanvasGuide` (its axis by Swift's raw values, `rawValue` and `guideAxis`), `LayoutGrid` (a namespace), `GuideDrag`, and the session extension, declared in `Guides+Session.h` with the accessors of Swift's view flags (`showsGrid`, `showsGuides`, `showsRulers`, `snapEnabled`, the four Snap To flags, `locksGuides`, each a getter and a setter that notifies, and `guideDrag`), whose state stays in `EditorSession.h`; `guideHitDistance` and `guideColor` are Swift's statics. `finishGuideDrag` takes `removing` where Swift names it `delete`. `canEditGuides` leaves out Swift's `_ = showsBusy`, which only registers an observation. 1.1.7's `snapEnabled` (Snap To's master switch, read by `alignmentSnapTargets` and `snappedGuidePosition`) stands beside 1.1.6's `snappingEnabled` (read by `snappedMove` and the crop drag), as it does in Swift through 1.3.3, with a View entry each. `transformSnapTargets` and `cropSnapTargets` are `alignmentSnapTargets` with and without centres, the canvas's middle after its edges and a layer's between its edges, Swift's order; `TransformSnap::box` (in `Crop.cpp`) is the upright box both files read. The document holds `guides`, equality and undo included; the manifest's optional `guides` is Swift's `Codable` (an array of `id`, `axis` and `position`, left out when there are none, the snapshot writing none for an empty list), and the store refuses guides before version 8 (invalid), more than 1,000 (too large), a repeated id or a position past a million (invalid). `installProject` and `clearProject` drop a drag; `CanvasDocument(snapshot)` loads the guides, so `applyDocumentSize` and `applyImageSize` keep them; the canvas resizer offsets them, the image resizer scales them (a same-size resize keeps them), the canvas flip mirrors those across it. The View menu gains Swift's Show (Grid Ctrl+', Guides Ctrl+;), Rulers (Ctrl+R), Snap (Ctrl+Shift+;), Snap To (Guides, Grid, Layers, Document Bounds), Lock Guides (Ctrl+Alt+;) and Clear Guides, resting without a document or, for Clear, without guides; the five new shortcut definitions join the list. Equivalent mutants: `finishGuideDrag`'s `isNew` test on removal and its `original != position` test (the history drops a step that changes nothing). The rulers, the grid and guides on the canvas and their drags are 12.3b's. Tests: `GuideTests` (Swift's but the canvas drag and the ruler's steps), `ViewMenuTests`, the targets' order in `TransformSnapTests`.
- Guides and the layout grid (12.3a, Swift 1.1.7): `Document/Guides.h/.cpp` twin Swift's `Guides.swift`: `CanvasGuide` (its axis by Swift's raw values, `rawValue` and `guideAxis`), `LayoutGrid` (a struct since 13.5b), `GuideDrag`, and the session extension, declared in `Guides+Session.h` with the accessors of Swift's view flags (`showsGrid`, `showsGuides`, `showsRulers`, `snapEnabled`, the four Snap To flags, `locksGuides`, each a getter and a setter that notifies, and `guideDrag`), whose state stays in `EditorSession.h`; `guideHitDistance` and `guideColor` are Swift's statics. `finishGuideDrag` takes `removing` where Swift names it `delete`. `canEditGuides` leaves out Swift's `_ = showsBusy`, which only registers an observation. 1.1.7's `snapEnabled` (Snap To's master switch, read by `alignmentSnapTargets` and `snappedGuidePosition`) is since 14.5 the one View › Snap: Swift 1.4.6 (`bba92dc`) made 1.1.6's `snappingEnabled` an alias of it and dropped its own entry, so `snappingEnabled()` reads `m_snapEnabled` for the moves, crops and drawing that read it, and `setSnapEnabled` clears the snap guides when it turns off (Swift's moved `didSet`); the alias's setter has no caller and is left out. `transformSnapTargets` and `cropSnapTargets` are `alignmentSnapTargets` with and without centres, the canvas's middle after its edges and a layer's between its edges, Swift's order; `TransformSnap::box` (in `Crop.cpp`) is the upright box both files read. The document holds `guides`, equality and undo included; the manifest's optional `guides` is Swift's `Codable` (an array of `id`, `axis` and `position`, left out when there are none, the snapshot writing none for an empty list), and the store refuses guides before version 8 (invalid), more than 1,000 (too large), a repeated id or a position past a million (invalid). `installProject` and `clearProject` drop a drag; `CanvasDocument(snapshot)` loads the guides, so `applyDocumentSize` and `applyImageSize` keep them; the canvas resizer offsets them, the image resizer scales them (a same-size resize keeps them), the canvas flip mirrors those across it. The View menu gains Swift's Show (Grid Ctrl+', Guides Ctrl+;), Rulers (Ctrl+R), Snap (Ctrl+Shift+;), Snap To (Guides, Grid, Layers, Document Bounds), Lock Guides (Ctrl+Alt+;) and Clear Guides, resting without a document or, for Clear, without guides; the five new shortcut definitions join the list. Equivalent mutants: `finishGuideDrag`'s `isNew` test on removal and its `original != position` test (the history drops a step that changes nothing). The rulers, the grid and guides on the canvas and their drags are 12.3b's. Tests: `GuideTests` (Swift's but the canvas drag and the ruler's steps), `ViewMenuTests`, the targets' order in `TransformSnapTests`.
- Rulers, and guides on the canvas (12.3b, Swift 1.1.7): `UI/CanvasRulers.h/.cpp` twin Swift's file: `CanvasRuler::thickness`, `CanvasRulerCorner` and `CanvasRulerView` (a widget per strip, repainting on every `changed()`, where Swift's canvas asks each ruler to redraw), with Swift's `majorStep` and `label`. The canvas is the rulers' sibling, so a ruler maps its points through the screen (`inCanvas`), where Swift reads its own bounds as the canvas's. A press hands the canvas the keys and begins a guide from that ruler (horizontal from the top one), a drag moves it, a release over a ruler drops it; a ruler has no mouse tracking, so it sees no buttonless move and leaves a lost release to the canvas's next press. `ContentView` frames the canvas slot in a grid with the corner and the two strips, shown while `showsRulers` holds and a document is open. `TransformOverlay` draws the layout grid (majors solid gray 0.7 at 45%, subdivisions dotted one point on and two off in gray 0.55 at 28% once four points apart, both a hairline) and the guides (`EditorSession::guideColor`, a hairline across the whole view) first, as Swift's `draw`; the canvas redraws the view when the grid's flag, the guides' flag or the displayed guides change (`GuidesShown`), Swift's observation. Guide drags live in `EditorCanvas+Guides.cpp`: with the Move tool a press on a guide (`canEditGuides` and `hitGuide`) moves it ahead of any layer under the resize cursor of its axis, a drag follows, a release over a ruler (`isOverRuler`: a point left of or above the canvas while the rulers show) deletes it; Escape cancels it ahead of a transform edit's keys; wheels and pinches rest while one is dragged; the Move tool's hover shows the guide's resize cursor after the handles and before a layer's move cursor. Qt can lose a release: a buttonless move and a lost focus finish the drag where it is. Equivalent mutant: the ruler press's `canEditGuides` test (`beginGuideCreation` asks it again). Tests: `GuideCanvasTests` (Swift's `moveToolDragsAGuideAndRulerDropDeletesIt` and `rulerStepUsesNicePixelIntervals`; the drawing, repaints, cursors, keys, lost releases, the rulers' layout, ticks, repaints, focus and drags), the canvas's place in `ContentViewTests`.
- The Magic tool's Object mode (12.4, Swift 1.1.8): `Document/ObjectSelection.h/.cpp` twin Swift's file, `ObjectSelection+Session.h` its session half; `WandMode` (Swift's raw values, `allWandModes`) sits in `Selection.h`. Vision's foreground instances have no Linux twin (the user's decision): `SubjectRemoval::foreground` hands back U²-Net's mask at the image's size (the Remove Background model and cache, 9.7), which `select` scales to the model's 320 grid, cuts at half, and splits into 8-connected regions (`region`, the neighbours a traced outline joins); the region under the clicked cell is the instance, background none, and a picture where the model finds no subject none, as Vision's empty result. Its cells are enlarged smoothly to the image and refined by `GuidedMatte::refine` (radius 5) on the image, for Swift's edge-preserving upsample, then cut at half; `adjusted` erodes or dilates a pixel ring a step (eight neighbours, ten steps at most, the image's edge counting as inside, Swift's `eroded` and `dilated`), `MagicWand::outline` traces it and `smoothed` rounds the stairs as Swift's (the start Qt's close repeats dropped first, since a CGPath's close adds no point, then a subpath under three points dropped, Douglas–Peucker at 1.6 from the leftmost point, Chaikin three times, the fill rule kept; Swift's later drops of a repeated end and Chaikin's guard under three points are dead after that and left out). A `bad_alloc` is `ExportError(render)`; Swift's `unsupported` has no twin. The worker catches `SubjectRemovalError`, `MagicWandError` (the trace's refusals) and `ExportError`, each landing in `brushError` as Swift's `catch`. `selectionSample` is the wand's sample made public as Swift's; `selectObject` runs through the wand's watcher and landing (`Wanding` carries the step's name, `Object Selection`), with a completion callback, the offset clamped to ten either way and the anti-alias setting as its smoothing. `pressWandKey` chooses the tool, W on the canvas and in the list, a repeat ignored; Tab steps the mode; the tool's label, its shortcut definition (`Magic`) and the bar's title say Magic. The bar (`LassoControls`) shows Wand and Object as checkable buttons for Magic alone, the colour wand's controls in Wand mode, and in Object mode This Layer and All Layers and an Edge field of −10 to 10 px (`SelectionAmountField` takes a sign when its range goes below zero; a lone minus changes nothing, an overflow reads as its end), with Swift's help; the anti-alias help follows the mode. The canvas's press runs `selectObject` ahead of the colour wand, a click inside the selection picks afresh, the cursor is the crosshair with `SelectionIcon::objectSelection` (haloed by eight offsets as every selection icon, where Swift strokes it white), and the rail draws the icon for Object mode (`ToolIconKind::object`); the status hint follows the mode. Known difference: U²-Net finds one salient mask, so objects touching in it are one region where Vision may tell instances apart. Left out as dead: the mode picker's `cancelLasso` (Magic never holds a draft: `beginLasso` refuses it and `selectTool` cancels one), `selectObject`'s busy term (`canEditSelection` reads it) and its clamp of the offset (`adjusted` clamps), and a null check of the enlarged cells (`GuidedMatte::refine` checks its own). Equivalent mutants: the grid's cut at exactly 128, `simplifyClosed`'s guard under four points (the fallback returns the same points), `finish`'s three-point threshold at four (a traced loop has four corners), the Chaikin reserve, the edge's rounding (typing and arrows give whole numbers), W calling `selectTool` (`pressWandKey` is that call). Untested failure paths: the region's own 320-cell image, 100 KB, which no limit starves without starving the grid before it, and the worker's `MagicWandError`: a refined mask never nears eight million edges, and the trace's image-sized buffer asks less than the guided filter before it. Tests: `ObjectSelectionTests` (regions, edges, smoothing, the model on red discs, the session, keys, the canvas), the bar in `LassoControlsTests`, the icon sheet, the rail and hints in `ContentViewTests` and `ContentViewStatusTests`.
- Photoshop import (12.5a, Swift 1.2.0): `src/IO/PSD/` twins Swift's `IO/PSD` folder. `PSDTypes` holds `PSDError` (Swift's five cases and words), `PSDConversion`, `PSDRecord`, `PSDDocument` and `fromPSD` (Swift's `LayerBlendMode.fromPSD`); Swift's `PSDLayerKind.other` is never assigned, so the case and its note are left out. `PSDChannelCoder` decodes raw and PackBits planes (a no-op run skipped, every overrun `truncated`) and builds `RGBA8888_Premultiplied` pixels tagged sRGB, rounding as Swift's `(c × a + 127) / 255`, and `Grayscale8` masks; a surface that cannot be allocated is `ExportError(render)` through `BrushRaster::context`, where Swift says `truncated`. `PSDReader` reads Swift's fields in Swift's order over a `Cursor` whose bounds check cannot overflow (an 8B64 length near `qint64`'s top reads as truncated, where Swift's `offset + count` traps), maps the file where it can and reads it where it cannot (a pipe, a mount without `mmap`), as `mappedIfSafe` does (a file that cannot be opened or read is `unreadable`, where Swift passes on the Cocoa error), logs every refusal on `omaphoto.io` and rethrows it. Pascal names are Mac OS Roman through a table of the 128 high code points taken from Python's `mac_roman` (Qt decodes it only through ICU); keys are ASCII or nothing, as Swift's `.ascii`. `PSDAdjustments::parse` (in `PSDReader+Adjustments.cpp`) reads Levels, Curves and Hue/Saturation as Swift does. `PSDVector` draws vector masks with `QPainterPath` (tight `boundingRect`, a path empty only without elements as a `CGPath`), fills by nonzero winding and strokes with `Qt::SvgMiterJoin` at 10, SVG's miter rule being CoreGraphics' (probed: kept to a ratio of 9.57, bevelled from 10.43; `Qt::MiterJoin` measures from the join point and clips instead); `integral` stays in doubles, so a wild box reaches the size check instead of an overflowing `QRect`. `PSDDocumentBuilder` makes the layers and conversions; a shape's identity is its asset's. `ImageImporter::loadPhotoshop` and `photoshopAssets` run on the import's worker, `makeImport` on the UI thread. In the session (`EditorSession+Imports.cpp`) a file whose first bytes are `8BPS` takes the Photoshop path: `beginPSDReading` shows the sheet while a `QFutureWatcher` reads it, `finishPSDReading` fills it or takes it away, `finishConversion` answers from the event loop (a resumed task), Cancel while reading drops the file silently, a failure joins the import's failures, and a confirmed file lands through `insertPhotoshop` (a new canvas at the file's size and resolution, or one folder named for the file inside the active folder, centred on a drop point, 10,000 layers at most). A request of Photoshop files alone is one `Import Photoshop File` step. Fixes beyond Swift: `insertPhotoshop` validates the hierarchy first, so folders nested past its depth refuse the file (`truncated`), and a folder clipped to the layer below takes no link but the base note, where Swift links it; either way Swift admits a document its store refuses. Left out as dead: the gates' `showsConversionSheet` terms and its `didSet` (the sheet shows only while importing, which the gates refuse already), Swift's test hook `confirmConversions`, `confirmPSDConversions` and `NativeLayerList.moveLayer` (no caller), the request's `id`, the builder's folder and adjustment checks on a clipping base (only pixel layers become bases), the reader's `layerSectionEnd`, the default mask colour, the reader's plane-size checks (a plane decodes to its size), resolution's finiteness test and its floor, the vector code's finiteness tests on boxes built from finite numbers and the origination's notes (never set), Curves' two length checks past the first, `pixelSize`'s area in doubles (its boxes are whole, so the whole pixels decide alone), the session's catch around `makeImport` (every image arrives with its asset, so nothing there allocates), and the new folder's removal from the collapsed set (a fresh id is in no set). `UI/PSDConversionSheet` is Swift's sheet in a window-modal `QDialog` (`conversionSheet`) that `ContentView` shows from the session's request: the title at 17 pixels bold, the words, a spinner while reading or the list of layer names over their notes, Cancel and the default confirm button, resting while reading; Escape and the close button cancel, and a sheet the session takes away answers nothing; `open()` makes the dialog window-modal. The request shown is kept, so an unrelated change keeps the content, and replaced content hides at once and is deleted later. The importer's filter and the desktop entry take `.psd` (`image/vnd.adobe.photoshop`); a dropped picture is copied as `Dropped.<suffix>` into a folder of its own, as Swift 1.2.0 keeps a dropped file's name. Equivalent mutants: the cursor's own bounds refusal and `skip`'s (every read past the end meets another refusal: a short signature, a short plane), PackBits' count and row-end checks (the branches refuse the same bytes; they spare reading past theLine truncated
- Outer Glow and brush Smoothing (12.5b, Swift 1.2.0): `OuterGlowEffect` joins the effects with Swift's defaults (20 pixels, white, 75%) and bounds (0–500, 0–1), `LayerEffectKind::outerGlow` ("Outer Glow") last, so the footer's menu, the list's rows and every helper take it; the manifest's `outerGlow` is Swift's `Codable` (`size`, `opacity`, the colour, `enabled` only while set). `LayerEffectsKernel` blurs the shape's coverage at sigma size ÷ 2, Metal's glow buffer (a size under 0.02 copies it, as Swift's zero shift does), and composes it over the drop shadow and under an outside stroke, clamped to glow × (1 − shape) × opacity; the renderer's margin takes three sizes, the stroke surface's reach three sizes and two. Swift's CPU `outerGlowCoverage` serves machines without Metal and is dead here, as the other CPU coverages are (9.6b), so Swift's CPU and Metal parity test has no twin. The preview cache keeps the last preview while one effect is hidden or shown on the same pixels (Swift's `dbe82b0`). A fix beyond Swift: a reduced preview scales the glow's size, as it scales the inner shadow's lengths (9.6c). The sheet shows the glow's swatch, Size (0–100 on the slider, 500 typed, px) and Opacity. Smoothing is Swift's string: `BrushSettings::smoothing` (0–100); while the Brush paints (either mode) with smoothing, the brush (`m_brushAnchor`) moves only once the pointer (`m_brushPointer`) pulls further than smoothing ÷ the viewport's zoom, and then by the excess along the string; the release paints on to the pointer; a cancel drops both. The other brush tools follow the pointer. Equivalent mutants: the press's pointer and the cancel's two resets (each makes the others' work redundant), `>` at the string's exact length (a move of zero), the release's tool and smoothing terms (otherwise it appends the last point again), and the surface's glow reach between the blur's three sigmas and Swift's three sizes. `BrushControls` shows Smoothing for the Brush alone: a 100-point slider in tenths and a 42-point `SelectionAmountField` of 0–100 with Swift's help. The rest of 1.2.0 is held already (TASKS.md 12.0, each note now saying Swift agrees) or not code (`NativeLayerList.moveLayer` has no caller; the spelling touches shader comments), but for two follow-ups of `90ee29f` and `7c4512a`: Select All never rests and reaches a focused layer list (7.1's note), and the invert test holds no wall clock. Tests: `OuterGlowTests` (Swift's, but the parity test), the glow in `LayerEffectsKernelTests`' shader replay, `LayerEffectsSurfaceTests`' rows and `EffectsPreviewCacheTests`' scaling, the kinds, manifest and menu in their files; `BrushSmoothingTests` and the bar in `BrushControlsTests`.
- Camera RAW (12.6a, Swift 1.2.1): Core Image's RAW filter has no Linux twin, so `IO/RawImporter` develops through LibRaw (the user's decision), its thread-safe `libraw_r` found by pkg-config; `Dockerfile.dev` installs `libraw-dev`, the PKGBUILD and the flake depend on `libraw`, the distro images carry it. A RAW is any file whose extension names a type inheriting `image/x-dcraw` in the MIME database (`matches`, `isRawType` for a dropped picture, `globs` for the importer's filter), Swift's `UTType.rawImage`. `RawDevelopSettings` keeps Swift's fields, `isAsShot` and `reset`; `RawDevelopRequest` is Swift's tuple. White balance: `planckian` is Kim et al.'s cubic (1667–25000 K), `whitePoint` moves off the locus along its greener uv normal by tint ÷ 3000, `temperatureOf` inverts it by a mired search and golden sections: Adobe's scale and sign (the DNG SDK's `kTintScale`), so a positive tint is a greener white and turns the picture magenta, and D65 reads about +10, as Lightroom's Daylight. Known difference: CIRAWFilter's temperature and tint follow Apple's own model; these follow Adobe's convention on CIE 1960 uv. `asShot` reads the camera's multipliers (`cam_mul`, else dcraw's daylight `pre_mul`) and turns the neutral into xy through LibRaw's `rgb_cam` (a DNG's `cam_xyz` stays zero) with `pre_mul` put back; `develop` works back from the chosen white to camera multipliers (`user_mul`, the second green the first's), reading the camera's `pre_mul`, `rgb_cam` and size from unpack's copy (`rawdata`), since a process overwrites `color` with its own multipliers and a half-size one halves `sizes`; a white the matrix maps to a response at or below zero is refused, logged, where LibRaw would drop the balance in silence; the refusal is `ImageImportError(whiteBalance)`, a kind of Linux's own with words of its own, since `CIRAWFilter` takes every white and real cameras' matrices reach it inside the sliders (the warm and green corner). It takes LibRaw's 16-bit sRGB at a power of one (linear), with neither auto-brightening nor the maximum's adjustment (`adjust_maximum_thr` 0, so a frame near white keeps its level), clipped at white in camera space (`highlight` 0, so a clipped photosite stays neutral: unclipped, the matrix turns it magenta), where LibRaw divides by the smallest multiplier; the twin multiplies the smallest back, so the balance is anchored on green, as Core Image's, where dcraw alone brightens the frame as the temperature moves. Known difference: where a multiplier falls below green's (the slider's far ends), the frame's white falls to that multiplier. It makes the look itself, rows side by side: exposure multiplies linear light by 2^EV, clamped, then Boost mixes sRGB's curve (0) with LibRaw's BT.709 curve (1). Known difference: Apple's Boost curve is undisclosed; at 1 this is LibRaw's default curve. The result is opaque `RGBA8888_Premultiplied` tagged sRGB, oriented by LibRaw. Only three-colour sensors develop; a four-colour one is `unsupported` (dcraw's matrices hold no fourth to invert). A preview past its limit decodes at half size, then fits smoothly. `Queue` is Swift's actor: one develop at a time under a mutex, the unpacked frame kept for the previews of one file, a full develop always fresh, `release` letting go; Swift keys the cache by the limit too, since its filter's scale is fixed when made, where LibRaw halves per develop. LibRaw's refusals are `unreadable`, logged with its message on `omaphoto.io`; a surface that cannot be made is `ExportError(render)`. In the session (`EditorSession+Imports.cpp`) a RAW goes before the Photoshop check: `pixelSize` (unreadable when it fails) and Swift's budget (`tooLarge` past 30,000 a side or the pixels left), then `developRaw` shows the sheet with the camera's settings (the defaults when they cannot be read) and keeps the answer, `finishRawDevelop` clears the request, releases the queue on a worker and answers from the event loop (a resumed task): nothing skips the file, settings develop the whole frame on the import's worker, which catches `ImageImportError` and `ExportError`, into a layer named for the file. `rawDevelop` alone stands for Swift's `rawDevelop` and `showsRawDevelop`, which are set and cleared together; the latter's `didSet` is dead (the sheet shows only while importing, which holds file requests anyway), and so is the test hook `confirmRawDevelop`. `UI/RawDevelopSheet` is Swift's sheet in a window-modal `QDialog` (`rawDevelopSheet`) that `ContentView` shows while the request stands, its close button and Escape cancelling and a sheet the session takes away answering nothing: the title at 17 pixels bold, the 560 by 340 preview on 35% black rounded 6 with a spinner while a develop runs, four rows (a 90-point title as the buddy of a 300-point slider, the value 80 points wide in `PlaceholderText`: Exposure in hundredths, Temperature and Tint whole, Boost in thousandths, printed as Swift's `%.Nf`), Reset (resting while as shot), Cancel and the default Line truncated
- Outer Glow and brush Smoothing (12.5b, Swift 1.2.0): `OuterGlowEffect` joins the effects with Swift's defaults (20 pixels, white, 75%) and bounds (0–500, 0–1), `LayerEffectKind::outerGlow` ("Outer Glow") last, so the footer's menu, the list's rows and every helper take it; the manifest's `outerGlow` is Swift's `Codable` (`size`, `opacity`, the colour, `enabled` only while set). `LayerEffectsKernel` blurs the shape's coverage at sigma size ÷ 2, Metal's glow buffer (a size under 0.02 copies it, as Swift's zero shift does), and composes it over the drop shadow and under an outside stroke, clamped to glow × (1 − shape) × opacity; the renderer's margin takes three sizes, the stroke surface's reach three sizes and two. Swift's CPU `outerGlowCoverage` serves machines without Metal and is dead here, as the other CPU coverages are (9.6b), so Swift's CPU and Metal parity test has no twin. The preview cache keeps the last preview while one effect is hidden or shown on the same pixels (Swift's `dbe82b0`). A fix beyond Swift: a reduced preview scales the glow's size, as it scales the inner shadow's lengths (9.6c). The sheet shows the glow's swatch, Size (0–100 on the slider, 500 typed, px) and Opacity. Smoothing is Swift's string: `BrushSettings::smoothing` (0–100); while the Brush paints (either mode) with smoothing, the brush (`m_brushAnchor`) moves only once the pointer (`m_brushPointer`) pulls further than smoothing ÷ the viewport's zoom, and then by the excess along the string; the release paints on to the pointer; a cancel drops both. The other brush tools follow the pointer. Equivalent mutants: the press's pointer and the cancel's two resets (each makes the others' work redundant), `>` at the string's exact length (a move of zero), the release's tool and smoothing terms (otherwise it appends the last point again), and the surface's glow reach between the blur's three sigmas and Swift's three sizes. `BrushControls` shows Smoothing for the Brush alone: a 100-point slider in tenths and a 42-point `SelectionAmountField` of 0–100 with Swift's help. The rest of 1.2.0 is held already (TASKS.md 12.0, each note now saying Swift agrees) or not code (`NativeLayerList.moveLayer` has no caller; the spelling touches shader comments), but for two follow-ups of `90ee29f` and `7c4512a`: Select All never rests and, since 13.10, reaches the canvas from a focused layer list (7.1's note), and the invert test holds no wall clock. Tests: `OuterGlowTests` (Swift's, but the parity test), the glow in `LayerEffectsKernelTests`' shader replay, `LayerEffectsSurfaceTests`' rows and `EffectsPreviewCacheTests`' scaling, the kinds, manifest and menu in their files; `BrushSmoothingTests` and the bar in `BrushControlsTests`.
- Camera RAW (12.6a, Swift 1.2.1): Core Image's RAW filter has no Linux twin, so `IO/RawImporter` develops through LibRaw (the user's decision), its thread-safe `libraw_r` found by pkg-config; `Dockerfile.dev` installs `libraw-dev`, the PKGBUILD and the flake depend on `libraw`, the distro images carry it. A RAW is any file whose extension names a type inheriting `image/x-dcraw` in the MIME database (`matches`, `isRawType` for a dropped picture, `globs` for the importer's filter), Swift's `UTType.rawImage`. `RawDevelopSettings` keeps Swift's fields, `isAsShot` and `reset`; `RawDevelopRequest` is Swift's tuple. White balance: `planckian` is Kim et al.'s cubic (1667–25000 K), `whitePoint` moves off the locus along its greener uv normal by tint ÷ 3000, `temperatureOf` inverts it by a mired search and golden sections: Adobe's scale and sign (the DNG SDK's `kTintScale`), so a positive tint is a greener white and turns the picture magenta, and D65 reads about +10, as Lightroom's Daylight. Known difference: CIRAWFilter's temperature and tint follow Apple's own model; these follow Adobe's convention on CIE 1960 uv. `asShot` reads the camera's multipliers (`cam_mul`, else dcraw's daylight `pre_mul`) and turns the neutral into xy through LibRaw's `rgb_cam` (a DNG's `cam_xyz` stays zero) with `pre_mul` put back; `develop` works back from the chosen white to camera multipliers (`user_mul`, the second green the first's), reading the camera's `pre_mul`, `rgb_cam` and size from unpack's copy (`rawdata`), since a process overwrites `color` with its own multipliers and a half-size one halves `sizes`; a white the matrix maps to a response at or below zero is refused, logged, where LibRaw would drop the balance in silence; the refusal is `ImageImportError(whiteBalance)`, a kind of Linux's own with words of its own, since `CIRAWFilter` takes every white and real cameras' matrices reach it inside the sliders (the warm and green corner). It takes LibRaw's 16-bit sRGB at a power of one (linear), with neither auto-brightening nor the maximum's adjustment (`adjust_maximum_thr` 0, so a frame near white keeps its level), clipped at white in camera space (`highlight` 0, so a clipped photosite stays neutral: unclipped, the matrix turns it magenta), where LibRaw divides by the smallest multiplier; the twin multiplies the smallest back, so the balance is anchored on green, as Core Image's, where dcraw alone brightens the frame as the temperature moves. Known difference: where a multiplier falls below green's (the slider's far ends), the frame's white falls to that multiplier. It makes the look itself, rows side by side: exposure multiplies linear light by 2^EV, clamped, then Boost mixes sRGB's curve (0) with LibRaw's BT.709 curve (1). Known difference: Apple's Boost curve is undisclosed; at 1 this is LibRaw's default curve. The result is opaque `RGBA8888_Premultiplied` tagged sRGB, oriented by LibRaw. Only three-colour sensors develop; a four-colour one is `unsupported` (dcraw's matrices hold no fourth to invert). A preview past its limit decodes at half size, then fits smoothly. `Queue` is Swift's actor: one develop at a time under a mutex, the unpacked frame kept for the previews of one file, a full develop always fresh, `release` letting go; Swift keys the cache by the limit too, since its filter's scale is fixed when made, where LibRaw halves per develop. LibRaw's refusals are `unreadable`, logged with its message on `omaphoto.io`; a surface that cannot be made is `ExportError(render)`. In the session (`EditorSession+Imports.cpp`) a RAW goes before the Photoshop check: `pixelSize` (unreadable when it fails) and Swift's budget (`tooLarge` past 30,000 a side or the pixels left), then `developRaw` shows the sheet with the camera's settings (the defaults when they cannot be read) and keeps the answer, `finishRawDevelop` answers from the event loop (a resumed task): nothing skips the file, ending the request at once (`endRawDevelop` clears it and releases the queue on a worker); settings keep the request with `rawImporting` set and develop the whole frame on the import's worker, which catches `ImageImportError` and `ExportError`, into a layer named for the file, and `finishDecode` ends the request once that frame lands or fails (14.2). `rawDevelop` alone stands for Swift's `rawDevelop` and `showsRawDevelop`, which are set and cleared together; the latter's `didSet` is dead (the sheet shows only while importing, which holds file requests anyway), and so is the test hook `confirmRawDevelop`. `UI/RawDevelopSheet` is Swift's sheet in a window-modal `QDialog` (`rawDevelopSheet`) that `ContentView` shows while the request stands, its close button and Escape cancelling (while importing they wait, 14.2) and a sheet the session takes away answering nothing: the title at 17 pixels bold, the 560 by 340 preview on 35% black rounded 6 with a spinner while a develop runs, four rows (a 90-point title as the buddy of a 300-point slider, the value 80 Line truncated
- Middle-button pan, tool defaults, Auto Select and the icon (12.6b, Swift 1.2.1): `Document/ToolDefaults` twins Swift's enum as a namespace: `boolean` (Swift's `bool`, a C++ keyword) and `set` keep Auto Select, the transform controls, the pixel grid, the layout grid, guides, rulers, Snap, the four Snap To switches and Lock Guides in `QSettings` under Swift's `tool.` keys, read by the session's members as they are made and written by each setter, and by the two places that show guides by themselves (`beginGuideCreation`, `addGuide`), as Swift's `didSet` sees those writes. Swift returns the defaults under XCTest; Qt has no such flag, and most test binaries run outside `QStandardPaths`' test mode, so the twin keeps the defaults until `ToolDefaults::enable()`, which `main` calls first (not under test, as the theme's construction is not). The settings file keeps text, so a stored value reads as `true` or `false`; anything else is ignored with a warning on `omaphoto.app`, where Swift's `as? Bool` is silent. The canvas's middle button (Swift's `otherMouseDown` for button 2) pans from any tool while a document is open, on a drag point of its own (`m_middlePanPoint`), so a left drag under way goes on beside it: a move with both buttons pans, then gives the left its turn (so a Hand drag pans twice), and a right-button tip drag waits for the middle's release. Known difference: macOS sends one drag event a motion, so which button Swift's canvas follows then is AppKit's choice. The press hides the brush circle and shows the closed hand, which outranks every other cursor; the release puts the tool's cursor and circle back. Qt can lose a release: a move without the middle button ends the pan. Unlike Swift's `mouseDown`, the press takes no focus. `transformPressLayer` gains Swift's foreground rule: with Auto Select, a press inside the active layer's box goes to the layer under the pointer when that is painted above it (a background holding every press no longer hides the layer on top); `above` reads both layers through checked lookups, since the pointer's layer and the visible active one are both drawn, where Swift guards each index. Swift's `underPointer != active.id` is left out as dead (`above` refuses a layer against itself); equivalent mutant: `>=` in `above` (only the active layer equals itself, and `selectLayer` of the active layer changes nothing). The reshaped app icon (`45ab8fd`) replaces `packaging/icons`. Tests: `ToolDefaultsTests` (the defaults until enabled, every toggle across sessions and as text, guides shown by a guide, a strange value), `theMiddleButtonPansFromAnyTool` in `CanvasNavigationTests`, Swift's `autoSelectPicksForegroundLayerStackedOnSelectedBackground` and a mask box past its layer in `TransformPressTests`, whose older Auto Select test now expects the layer above.
- Photoshop's blend modes (12.7a, Swift 1.2.2): `LayerBlendMode` takes Swift's order, Photoshop's, and `allLayerBlendModes` follows it, so the blend keys step Normal, Darken, Multiply…; the raw values are Swift's (`Linear Dodge (Add)` among them), so the manifest reads them without a new version, as Swift's. `blendGroup` stands for Swift's static `groups`: each group's modes sit together in the enum, so the group is the last first member a mode reaches. `BlendModePicker` draws a separator between groups, as Swift's pop-up does; QComboBox's keys step over them and never make one current, so no preview names one. Hard Light and Exclusion are QPainter's composition modes; the other eight go by hand (`SeparableBlend`, `HslBlend`). The PSD reader takes Swift's new keys (`lbrn`, `lddg`, `hLit`, `vLit`, `lLit`, `pLit`, `hMix`, `smud`, `fsub`, `fdiv`); Dissolve, Darker Color and Lighter Color fall to Normal with the conversion note, and Swift's renamed test uses Dissolve. A fix beyond Swift: Swift's `LiveMaskRenderer` composites a clipping stack's group and an adjustment's result through `cgMode`, which is Normal for the eight (v1.2.2's `prepareStacks`, `drawComposite` and `adjust`, unchanged through 1.3.3), so a base or an adjustment in one of them draws as Normal there; the twin hands every mode to `LayerRenderer::composite`, which blends the eight by hand, as Photoshop composites a clipping group with its base's mode (`ClippingStackTests::aStackBlendsOnceWithItsBasesMode`, `AdjustmentLayerTests::adjustmentBlendAndSoftMaskPreserveCoverage`). Swift's `coreImageFilter` has no twin: `needsSurface` names the modes. Equivalent mutants: `<=` against `<` at one half in Vivid and Pin Light (both branches give the backdrop there). Tests: the formulas in `BlendModeTests`, the menu's groups and keys in `BlendModePickerTests`, the order in `LayerAppearanceTests`, `BlendShortcutTests`, `EditorCanvasTests` and `ProjectManifestTests`, the keys in `PSDRoundTripTests` and `PSDImportTests`, the canvas against the export in `CanvasDisplayTests::photoshopsOwnModesShowAsTheExportDoes`.
- Photoshop's blend modes (12.7a, Swift 1.2.2): `LayerBlendMode` takes Swift's order, Photoshop's, and `allLayerBlendModes` follows it, so the blend keys step Normal, Darken, Multiply…; the raw values are Swift's (`Linear Dodge (Add)` among them), so the manifest reads them without a new version, as Swift's. `blendGroup` stands for Swift's static `groups`: each group's modes sit together in the enum, so the group is the last first member a mode reaches. `BlendModePicker` draws a separator between groups, as Swift's pop-up does; QComboBox's keys step over them and never make one current, so no preview names one. Hard Light and Exclusion are QPainter's composition modes; the other eight go by hand (`SeparableBlend`, `HslBlend`). The PSD reader takes Swift's new keys (`lbrn`, `lddg`, `hLit`, `vLit`, `lLit`, `pLit`, `hMix`, `smud`, `fsub`, `fdiv`); Dissolve, Darker Color and Lighter Color fall to Normal with the conversion note, and Swift's renamed test uses Dissolve. A fix beyond Swift: Swift's `LiveMaskRenderer` composites a clipping stack's group and an adjustment's result through `cgMode`, which is Normal for the eight (v1.2.2's `prepareStacks`, `drawComposite` and `adjust`, through 1.3.3; Swift 1.4 blends both in every mode, 13.9a), so a base or an adjustment in one of them drew as Normal there; the twin hands every mode to `LayerRenderer::composite`, which blends the eight by hand, as Photoshop composites a clipping group with its base's mode (`ClippingStackTests::aStackBlendsOnceWithItsBasesMode`, `AdjustmentLayerTests::adjustmentBlendAndSoftMaskPreserveCoverage`). Swift's `coreImageFilter` has no twin: `needsSurface` names the modes. Equivalent mutants: `<=` against `<` at one half in Vivid and Pin Light (both branches give the backdrop there). Tests: the formulas in `BlendModeTests`, the menu's groups and keys in `BlendModePickerTests`, the order in `LayerAppearanceTests`, `BlendShortcutTests`, `EditorCanvasTests` and `ProjectManifestTests`, the keys in `PSDRoundTripTests` and `PSDImportTests`, the canvas against the export in `CanvasDisplayTests::photoshopsOwnModesShowAsTheExportDoes`.
- Black & White, Color Balance and Invert (12.7b, Swift 1.2.2): `kernels/AdjustPixels.c/.h` are v1.2.2's, with `adjust_black_white` and `adjust_color_balance`, run through `ImageAdjustmentPixels::run` on one thread as Swift's. `BlackWhiteSettings` and `ColorBalanceSettings` keep Swift's fields, defaults and bounds (the `isFinite` terms left out, as the bounds refuse NaN and infinities); `apply` throws `ProjectError(invalid)` past them and Color Balance hands an untouched image back. Swift's `FilterSettings.normalized` does not clamp either, so a number typed past a slider's range fails the preview with that error and a commit sets `brushError` with no step, as in Swift: the filter's two workers catch `ProjectError` beside their other errors, where it would otherwise escape as `QUnhandledException` and abort. `FilterKind` gains both after Grain (`isImageAdjustment`), `AdjustmentKind` Invert, Black & White and Color Balance in Swift's order, with `isEditable` (all but Invert) and `filterKind`; `LayerAdjustment` gains the two optionals with Swift's computed getter and setter pairs, validity and `apply` (Invert through `PixelInvert::run`, whole image, colour only); the manifest's `blackWhiteSettings` and `colorBalanceSettings` are Swift's synthesized `Codable`, every key required, left out while absent. `addAdjustment` opens the editor for editable kinds alone: an Invert layer applies at once. The editors carry both settings through `beginAdjustmentEditing` and `editedAdjustment`. A fix beyond Swift: Swift's Edit Adjustment… stays enabled on an Invert layer, whose editor switch breaks after the original is kept and the transaction opened, so nothing ever closes them and the session stays refused; the twin's entry rests for a kind that is not editable, and the two switches throw a `logic_error` for Invert, which nothing reaches. As Swift's row, an Invert layer says "Adjustment · Double-click to edit", yet a double click on its glyph renames it (`m_editableAdjustment`, Swift's `isEditable` test). The Image menu lists Black & White… and Color Balance… before Exposure…, the Layer menu's New Adjustment Layer entries take an ellipsis when editable, and a menu doubles the ampersand of "Black & White" (a lone one is a mnemonic in Qt), in the kinds' entries and in Undo and Redo's step names alike; object names drop it (`blackWhite`, `newBlackWhiteAdjustment`). `LayerIcons` draws Swift's three new symbols as paths: `circle.righthalf.filled`, `circle.filled.pattern.diagonalline.rectangle` (a rounded frame, diagonal lines, a filled circle) and `scale.3d` (three axes with arrowheads). The filter sheet's two kinds live in `UI/FilterSheet+Adjustments.cpp` (the sheet's file neared 500 lines): the six families and Tint's Hue and Saturation, shown while Tint is on (Swift's `if`, the rows `m_shownWhen` holds with Advanced's), and Color Balance's three headlines (13 pixels, semibold, Swift's `.headline`, as the effects sheet's) over three rows each and Preserve Luminosity; each `flag` is Swift's `Toggle` with its help as the tooltip. `NativeLayerList` stops listening in its destructor, as `ContentView` does: closing a list window hides it first, and a rename's focus-out then commits into a list half destroyed. Tests: Swift's `invertAppliesWithoutAnEditor` in `AdjustmentLayerTests`, its editor test over the editable kinds in `AdjustmentEditorTests` and `FloatingPanelTests`, `ColorAdjustmentTests` (the kernels against Photoshop's defaults and a tint worked in HSL, the tones of Color Balance and Preserve Luminosity, bounds, every manifest key, the sheet), the kinds, keys and menus in `FilterModelTests`, `LayerAdjustmentTests`, `AdjustmentPanelTests`, `CompositorMenusTests` and `SelectMenuTests`. `ProjectExportFailureTests`' two panel limits rose from 2,048 KB: the thousand-layer snapshot grew by the two optionals of every record (2,560 KB since 12.8d).
- The Camera Raw filter's model (12.8a, Swift 1.2.3): `kernels/AdjustPixels.c/.h` are v1.2.3's, with the Camera Raw kernels and Grain's particle size (`59b5040`); that kernel warns under `-Wunused-parameter` and `-Wmisleading-indentation` through 1.3.3 (both lines date from `9982fad`; `075a71b` fixes another), so CMake silences those two for that file alone. `Document/CameraRaw.h/.cpp`, `CameraRawColor`, `CameraRawDetailOptics` and `CameraRawGeometryCalibration` twin Swift's four files; Swift's `CameraRaw.swift` runs to 638 lines, so its session extension is `CameraRaw+Session.cpp` with `CameraRaw+Session.h`. The enums keep Swift's raw values and order; `CameraRawClipping` keeps Swift's bits (highlights 1, shadows 2); `Balance` and `Gains` are structs for Swift's tuples; `region` returns a reference where Swift writes through a key path. `CameraRawDrag` sits in `CameraRaw.h`, where Swift's sits in `CameraRawColor.swift`: it holds the whole settings, which C++ needs complete. Swift's panel state sits on `FilterEdit` (the shown groups, pages, swatch, point index, the tools, the guide draft, clipping, sharpen mask, the scope and its mode, the readout); the twin gathers it in `CameraRawPanel`, `FilterEdit::rawPanel`, written through `setCameraRawPanel`, which notifies while an edit is open. `renderSettings` is Swift's settings with the hidden groups left out (`applying`); the commit renders and keeps those, so a hidden group stays out next time, and an identity grade closes as Cancel does. `previewJob` carries the panel's views; the preview's worker runs `CameraRawScope::preview`, whose scope lands on the panel. A fix beyond Swift: Swift's `preview` drops the point colour's view (it runs the job itself only for clipping or the sharpen mask, through 1.3.3), so Visualize never shows; the twin runs the job for it too. Geometry's warp is `QTransform::quadToQuad` from the image's rect to Swift's corners flipped to y down, drawn smooth and antialiased; a warp Qt cannot solve is a `logic_error`. Constrain Crop fits the frame inside `brush_alpha_bounds`; a crop whose copy finds no memory is `ExportError(render)`, where Swift's `cropping` returning nil falls back to the uncropped warp. Left out as dead: `apply`'s `isValid` guard and with it `isValid` (settings arrive normalized, and nothing else asks), `nudged`'s guard for an empty channel (`normalized` repairs every curve to two points; `at` throws if that breaks), the vectorscope's wrap of a negative hue (its angle's cosine and sine turn round alike), the parametric curve's `max(0, weight)` (a tone inside its region weighs 0 to 1), each group's `applying(shows:)` (no caller), and the defringe eyedropper's own `normalized` (`updateFilter` normalizes). Equivalent mutants: `tone < shadow` against `<=` (the regions meet with equal weight), the defringe tie's `<` against `<=`, the mixer's `weights > 0` against `>= 0` (a zero weight adds nothing), the identity commit's cancel (the history drops an unchanged step), and Auto's `committing` term (a committing edit refuses `updateFilter`, and the landing asks again). The Filter menu and the sheet leave Camera Raw out until its panel (12.8b). Tests: `CameraRawTests`, `CameraRawSessionTests` (Swift's session tests; the preview's views and scope), `CameraRawToolsTests`, `CameraRawRulesTests`, `CameraRawColorTests`, `CameraRawLensTests` (the kernel's calibration called directly), `CameraRawWiringTests` (Effects, Optics and Detail against their kernels called with the header's order, a clip view on an untouched grade, Auto in linear light, every eye's fields, Offset, Aspect and Constrain Crop against placed pictures, the scope's channels and hues, the curve, mixer and grading call with its floats written out, the sharpen-mask view, each lens flag alone, Grain's scale, a dull colour's reach, Rotate, Scale and Vertical against placed pictures), `CameraRawFixtures.h`, Grain's size in `ImageAdjustmentTests`, the kind in `FilterModelTests`.
- The Camera Raw panel (12.8b1, Swift 1.2.3): Swift docks the filter's `NSPanel` to the document window's right edge, full height, following it (`dockedToMainWindowRight`). Wayland lets no client place a window, and a tiling compositor would tile a second one, so the twin docks inside the window: `FloatingPanel::show` takes a dock slot, `ContentView`'s `panelDock` at the right of the middle row, `FloatingPanel::dockedWidth` (Swift's 440) wide, and there the panel is its own `PanelWindow` made a plain child (`Qt::Widget`), so `QDialog` still takes Escape as Cancel and Return as the default button; a header holds the title and a close button, which cancels. A docked frame is never remembered (`moveEvent` asks `isWindow`), so Gaussian Blur and the rest reopen where they were left, Swift's `dockedPlacementLeavesTheSavedFilterPosition`; `refocus` hands a docked panel the keys through `setFocus`, looking past the hidden floating panel of the same name, which is the window's child too. The dock takes room beside the layers panel and spans the middle row alone, so the tool bar and status bar keep their width, where Swift's panel lies over the window's whole height (known difference). Refocus hands the keys to the docked panel itself, not the field last typed in (known difference: `makeKey` keeps its first responder). `UI/CameraRawSlider` twins Swift's file: `CameraRawSliderTrack` with Swift's colours, the gradient drawn 4 points high across the groove under the style's knob, a double click on the knob (its rect two points out) resetting; a press on the track is `SliderSnap`'s (10.5): Swift's 0.18 s glide has no twin, and a drag goes on from the snapped knob, as every slider of the twin does (known differences); `display` keeps a knob under a drag, Swift's `isTrackingValue`; the track is fixed at construction: Swift's track update in `updateNSView` has no twin, since the rows whose tracks change (12.8b2's mixer) are made anew. `UI/CameraRawControls.h/.cpp` and `CameraRawControls+Basic.cpp` (Light, Color, Effects) twin Swift's view: the scope 110 high on black at 35%, the three ribbons at 55% in SwiftUI's dark red, green and blue (the Levels histogram's values) or the vectorscope's cells, square by the width and counted up from the bottom, so a 110-point graph shows its lowest 18 rows, as Swift's does, its context menu choosing the mode, the two clipping triangles lit in that blue and red, the readout; the words say Alt for Option and a right click for Control-click; the ten groups, Light and Color open, each eye shown while its group adjusts; a row's title (96 points at least, a double click resetting), `CameraRawSlider` and a 56-point `PickerField` showing Swift's `.number` to the row's decimals. A row's object name is its settings field (`vignetteAmountSlider`), since titles repeat. Alt is read from `QGuiApplication::keyboardModifiers`, Swift's `NSEvent.modifierFlags`, and its release (auto-repeat aside), from any window, ends the clipping and sharpen-mask views (Swift's local monitor). Curve to Calibration fill their groups in 12.8b2, and the Filter menu's entry waits for them. On the canvas (`EditorCanvas+CameraRaw.cpp`) a hover reads the readout (Swift's `mouseMoved`, never a drag), leaving clears it, the three eyedroppers sample and hand the panel the keys, the guide and the targeted drag begin, go on and end on release, and a move without the left button ends either, since Qt can lose a release; the four tools are `picking`, so the eyedropper shows. `EyedropperIcon::icon` came from `LevelsSheet`, which shares it. Left out as dead: the canvas's `kind == cameraRaw` and edit terms before the readout (the session asks), a hide of the other placement's panel in `show` (every edit closes its panel first), Swift's clipping reset before a row's reset (`assign` clears it), and the docked panel's own hide on close (its slot hides it). Equivalent mutants: the titles' minimum width, 96 points (a layout sizes labels offscreen alike), and the unlit triangle's 55% white (cosmetic, over whatever the scope shows). Two consequences of the named differences: a double click on a slider's track resets it, since the first press puts the knob under the second; and a canvas press takes the keys, so a field still being typed in commits at the click, where Swift's panel keeps its field editor. Tests: `CameraRawSliderTests` (Swift's three), `CameraRawPanelTests`, `CameraRawCanvasTests` (the dock in a real window among them), `FloatingPanelTests::dockedPlacementLeavesTheSavedFilterPosition`.
- The Camera Raw filter's model (12.8a, Swift 1.2.3): `kernels/AdjustPixels.c/.h` are v1.2.3's, with the Camera Raw kernels and Grain's particle size (`59b5040`); that kernel warns under `-Wunused-parameter` and `-Wmisleading-indentation` through 1.3.3 (both lines date from `9982fad`; `075a71b` fixes another), so CMake silences those two for that file alone. `Document/CameraRaw.h/.cpp`, `CameraRawColor`, `CameraRawDetailOptics` and `CameraRawGeometryCalibration` twin Swift's four files; Swift's `CameraRaw.swift` runs to 638 lines, so its session extension is `CameraRaw+Session.cpp` with `CameraRaw+Session.h`. The enums keep Swift's raw values and order; `CameraRawClipping` keeps Swift's bits (highlights 1, shadows 2); `Balance` and `Gains` are structs for Swift's tuples; `region` returns a reference where Swift writes through a key path. `CameraRawDrag` sits in `CameraRaw.h`, where Swift's sits in `CameraRawColor.swift`: it holds the whole settings, which C++ needs complete. Swift's panel state sits on `FilterEdit` (the shown groups, pages, swatch, point index, the tools, the guide draft, clipping, sharpen mask, the scope and its mode, the readout); the twin gathers it in `CameraRawPanel`, `FilterEdit::rawPanel`, written through `setCameraRawPanel`, which notifies while an edit is open. `renderSettings` is Swift's settings with the hidden groups left out (`applying`); the commit renders and keeps those, so a hidden group stays out next time, and an identity grade closes as Cancel does. `previewJob` carries the panel's views; the preview's worker runs `CameraRawScope::preview`, whose scope lands on the panel. A fix beyond Swift: Swift's `preview` drops the point colour's view (it runs the job itself only for clipping or the sharpen mask, through 1.3.3), so Visualize never shows; the twin runs the job for it too. Geometry's warp is `QTransform::quadToQuad` from the image's rect to Swift's corners flipped to y down, drawn smooth and antialiased; a warp Qt cannot solve is a `logic_error`. Constrain Crop fits the frame inside `brush_alpha_bounds`; a crop whose copy finds no memory is `ExportError(render)`, where Swift's `cropping` returning nil falls back to the uncropped warp. Left out as dead: `apply`'s `isValid` guard and with it `isValid` (settings arrive normalized, and nothing else asks), `nudged`'s guard for an empty channel (`normalized` repairs every curve to two points; `at` throws if that breaks), the vectorscope's wrap of a negative hue (its angle's cosine and sine turn round alike), the parametric curve's `max(0, weight)` (a tone inside its region weighs 0 to 1), each group's `applying(shows:)` (no caller), and the defringe eyedropper's own `normalized` (`updateFilter` normalizes). Equivalent mutants: `tone < shadow` against `<=` (the regions meet with equal weight), the defringe tie's `<` against `<=`, the mixer's `weights > 0` against `>= 0` (a zero weight adds nothing), the identity commit's cancel (the history drops an unchanged step), and Auto's `committing` term (a committing edit refuses `updateFilter`, and the landing asks again). The Filter menu and the sheet leave Camera Raw out until its panel (12.8b). Tests: `CameraRawTests`, `CameraRawSessionTests` (Swift's session tests; the preview's views and scope), `CameraRawToolsTests`, `CameraRawRulesTests`, `CameraRawColorTests`, `CameraRawLensTests` (the kernel's calibration called directly), `CameraRawWiringTests` (Effects, Optics and Detail against their kernels called with the header's order, a clip view on an untouched grade, Auto in linear light (encoded again since 14.25), every eye's fields, Offset, Aspect and Constrain Crop against placed pictures, the scope's channels and hues, the curve, mixer and grading call with its floats written out, the sharpen-mask view, each lens flag alone, Grain's scale, a dull colour's reach, Rotate, Scale and Vertical against placed pictures), `CameraRawFixtures.h`, Grain's size in `ImageAdjustmentTests`, the kind in `FilterModelTests`.
- The Camera Raw panel (12.8b1, Swift 1.2.3): Swift docks the filter's `NSPanel` to the document window's right edge, full height, following it (`dockedToMainWindowRight`). Wayland lets no client place a window, and a tiling compositor would tile a second one, so the twin docks inside the window: `FloatingPanel::show` takes a dock slot, `ContentView`'s `panelDock` at the right of the middle row, `FloatingPanel::dockedWidth` (Swift's 440) wide, and there the panel is its own `PanelWindow` made a plain child (`Qt::Widget`), so `QDialog` still takes Escape as Cancel and Return as the default button; a header holds the title and a close button, which cancels. A docked frame is never remembered (`moveEvent` asks `isWindow`), so Gaussian Blur and the rest reopen where they were left, Swift's `dockedPlacementLeavesTheSavedFilterPosition`; `refocus` hands a docked panel the keys through `setFocus`, looking past the hidden floating panel of the same name, which is the window's child too. The dock takes room beside the layers panel and spans the middle row alone, so the tool bar and status bar keep their width, where Swift's panel lies over the window's whole height (known difference). Refocus hands the keys to the docked panel itself, not the field last typed in (known difference: `makeKey` keeps its first responder). `UI/CameraRawSlider` twins Swift's file: `CameraRawSliderTrack` with Swift's colours, the gradient drawn 4 points high across the groove under the style's knob, a double click on the knob (its rect two points out) resetting; a press on the track is `SliderSnap`'s (10.5): Swift's 0.18 s glide has no twin, and a drag goes on from the snapped knob, as every slider of the twin does (known differences); `display` keeps a knob under a drag, Swift's `isTrackingValue`; Camera Raw's own rows keep the track they are made with, since the rows whose tracks change (12.8b2's mixer) are made anew; since 12.14c `reshape` is Swift's track and bounds update in `updateNSView`, for the filter and Hue/Saturation sheets. `UI/CameraRawControls.h/.cpp` and `CameraRawControls+Basic.cpp` (Light, Color, Effects) twin Swift's view: the scope 110 high on black at 35%, the three ribbons at 55% in SwiftUI's dark red, green and blue (the Levels histogram's values) or the vectorscope's cells, square by the width and counted up from the bottom, so a 110-point graph shows its lowest 18 rows, as Swift's does, its context menu choosing the mode, the two clipping triangles lit in that blue and red, the readout; the words say Alt for Option and a right click for Control-click; the ten groups, Light and Color open, each eye shown while its group adjusts; a row's title (96 points at least, a double click resetting), `CameraRawSlider` and a 56-point `PickerField` showing Swift's `.number` to the row's decimals. A row's object name is its settings field (`vignetteAmountSlider`), since titles repeat. Alt is read from `QGuiApplication::keyboardModifiers`, Swift's `NSEvent.modifierFlags`, and its release (auto-repeat aside), from any window, ends the clipping and sharpen-mask views (Swift's local monitor). Curve to Calibration fill their groups in 12.8b2, and the Filter menu's entry waits for them. On the canvas (`EditorCanvas+CameraRaw.cpp`) a hover reads the readout (Swift's `mouseMoved`, never a drag), leaving clears it, the three eyedroppers sample and hand the panel the keys, the guide and the targeted drag begin, go on and end on release, and a move without the left button ends either, since Qt can lose a release; the four tools are `picking`, so the eyedropper shows. `EyedropperIcon::icon` came from `LevelsSheet`, which shares it. Left out as dead: the canvas's `kind == cameraRaw` and edit terms before the readout (the session asks), a hide of the other placement's panel in `show` (every edit closes its panel first), Swift's clipping reset before a row's reset (`assign` clears it), and the docked panel's own hide on close (its slot hides it). Equivalent mutants: the titles' minimum width, 96 points (a layout sizes labels offscreen alike), and the unlit triangle's 55% white (cosmetic, over whatever the scope shows). Two consequences of the named differences: a double click on a slider's track resets it, since the first press puts the knob under the second; and a canvas press takes the keys, so a field still being typed in commits at the click, where Swift's panel keeps its field editor. Tests: `CameraRawSliderTests` (Swift's three), `CameraRawPanelTests`, `CameraRawCanvasTests` (the dock in a real window among them), `FloatingPanelTests::dockedPlacementLeavesTheSavedFilterPosition`.
- The Camera Raw panel's other groups (12.8b2, Swift 1.2.3): `UI/CameraRawColorControls.h/.cpp` (Curve), `+Mixer.cpp` and `+Grading.cpp`, `UI/CameraRawDetailOpticsControls` and `UI/CameraRawGeometryCalibrationControls` twin Swift's three files, each view a `QWidget` over the session, synchronized on `changed()`. `UI/CameraRawRow` is Linux-only: the row each Swift view builds for itself (`sharpenSlider`, `opticsSlider`, `geometrySlider`, `calibrationSlider`, the curve's and mixer's sliders, 12.8b1's `slider`): a title of at least or exactly a width, `CameraRawSlider`, a field or none, a double click on the title resetting where Swift's main groups do; each view rounds the slider's value as its Swift twin does (Detail to whole numbers, Optics when its range starts at zero, Geometry and Calibration always, the rest not). Swift's segmented pickers are `cameraRawSegments`, touching checkable buttons in an exclusive `QButtonGroup`. The curve graph: a still press and release adds a point, a drag past two points moves the nearest divider or inner point, a double click removes the nearest inner point within 0.04 and its trailing release adds nothing, so the net is Swift's: SwiftUI adds on each click, `repair` drops the second as a duplicate and the double tap removes one. The graph is `CameraRawCurveGraph`: a class defined in a `.cpp` outside an anonymous namespace is global, and the Curves sheet's `CurveGraph` would share its vtable across the link. The mixer's rows whose help or track follow its tab, swatch or chosen point are made anew when those change, as SwiftUI renders them anew; a picked colour's swatch is shown at once, since a layout shows late children only later. The grading wheel fills a `QConicalGradient`, which runs counterclockwise from the right as the hue does (1.2.9's `96edd5f`), takes presses and double clicks inside its circle alone (Swift's `contentShape`), and Swift's `theDockedPanelFitsTheGradingWheels` holds; its stops are 96edd5f's own, pure HSV hues every 30 degrees (v1.2.3's six SwiftUI system colours predate that fix). Detail's idle noise rows are disabled under a 45% `QGraphicsOpacityEffect`, Swift's `.opacity(0.45).disabled`. Targeted Adjustment and Draw Guides are checkable push buttons, checked while armed, where Swift tints them (known difference); their SF Symbols, `scope` and `line.diagonal`, are `LayerIcons` paths. The Filter menu lists Camera Raw Filter… after Lens Correction, as Swift's. Known differences: a mixer family's field still being typed in loses its typing when the tab changes (the row is made anew), where SwiftUI's field would commit into the new tab; Equivalent mutants: the wheel's saturation cap (`normalized` clamps it), the picked swatches made anew only when their count changes (it spares rebuilds; Qt guards a swatch deleted in its own click) and the page menu's size policy (layout). The point-colour eyedropper is flat like the other two, where Swift leaves it bordered (cosmetic). Cosmetic, Swift's values and unpinned: the swatches' 18 points, Detail's idle 45%, the hue ranges' inset (title width and 10), the rows' spacing of 10 and the 11-pixel subheadlines. Tests: `CameraRawRowTable.h` walked by `CameraRawGroupRowsTests` (every row against Swift's values), `CameraRawGroupDrawTests` (drawing, pages, gestures, the panel through `FilterSheet`), `CameraRawGroupWordsTests` (every text, captions, buddies, the pickers' states, the mixer's pages, the wheel's lower half and rim, the curve's line, repaint and selected point), `CameraRawCurveMixerTests`, `CameraRawGradingLensTests`, the menu's entries in `CompositorMenusTests`.
- Hash containers on the render path (12.8e): Qt 6.4 declares `QHashPrivate::Data::findOrInsert` `noexcept` while it allocates, so a `QHash` or `QSet` insert that finds no memory calls `std::terminate`, past every catch (found by a backtrace of `ProjectExportFailureTests` under load: the JPEG worker's render aborted in `LiveMaskGraph::validate`). Code a worker runs under a catch that promises to survive memory running out uses `std::map` and `std::set`, whose inserts throw: `LiveMaskGraph::validate`, `FolderMaskClip::draw` and `LiveMaskRenderer`'s caches (and `LiveMaskBaker::bake` beside `validate`, though its workers catch `ExportError` alone, so a `bad_alloc` there still ends the app, by design), as `ImageExporter::render` already keyed its records. The UI thread's own `QHash`es stay: a failure there ends in Qt's event loop either way. `RenderStarvationTests` replaces `operator new` on its thread and fails each allocation of `validate`, `prepareStacks` and `FolderMaskClip::draw` in turn: each must throw `std::bad_alloc`; the three aborted with the old containers. Painting is left out of it: Qt's painter is not exception-safe, and a failure injected inside it can leave a lock held.
- Blur and noise adjustment layers, Image Trim and Inner Glow (12.8c, Swift 1.2.3: `98ad817`, `24052b5`, `feb39f3`, `cd0ca3c`). `AdjustmentKind` gains Add Noise, Gaussian Blur and Motion Blur after Grain, v1.2.3's order (it moved Add Noise ahead of the blurs), with `circle.dotted`, `drop.fill` and `wind` as `LayerIcons` paths. `LayerAdjustment` keeps Swift's seven optionals (`blurRadius`, `motionAngle`, `motionDistance`, `noiseAmount`, `noiseGaussian`, `noiseMonochromatic`, `noiseSeed`), their resolved getters and setters with Swift's defaults (10, 0, 10, 10, off, off, 0), `isValid`'s bounds on every kind, as Swift's, and `samplingMargin`; the manifest writes each only when set, the seed a `UInt32` read as Grain's; the store takes the three kinds from version 9, which it now writes (versions 1–9). `apply` runs the three through `PixelFilter::run` with the layer's own seed; `FilterJob::noiseOrigin` sits last, so the positional initialisers keep their order, and `kernels/NoisePixels.c` is v1.2.3's (`noise_add_at`, whose position hash reshuffles the Add Noise filter's grain too). A new Add Noise layer rolls its seed, as Grain does; the editor opens the kind's filter panel on the layer's settings and writes back that kind's alone. Adaptation: Swift's canvas surface holds a pixel a point, so its region's origin and `scale` are already the surface's pixels; the twin's surfaces hold device pixels, so `LiveMaskRenderer::adjust` passes `adjustmentScale` (the canvas's zoom, Swift's, 1 elsewhere) times the device's pixels a region unit, and `apply` counts the noise origin in the image's pixels, so a HiDPI canvas blurs at its own density and the noise stays one field across dirty rects. `AdjustmentSurface::draw` takes Swift's `padding` in the painter's units: the halo is allocated past the shown area, not cut to the device (the canvas's outer surface is dirty-sized), and drawn back under the painter's clip. The canvas pads by the widest visible `samplingMargin` times the zoom, and its crisp path (from 200%) now draws the layers through a padded surface as well, as Swift's `drawLayers` wraps its own there. Known difference, Swift's too: on the smooth path the canvas's noise follows the view, as Grain's does; on the crisp path the surface's region starts at the repaint region, so each partial repaint there draws a noise field of its own. The crisp path wraps its surface even without an adjustment, where Swift's `drawLayers` wraps only for one: pixel-identical (source-over onto a cleared raster), one allocation a repaint region, as the smooth path already does. The HiDPI product is pinned by its two factors (the zoom on the canvas, the device ratio on a scaled composite), not at a device ratio of two itself. Image Trim: `Document/ImageTrim` twins Swift's file: `TrimBasedOn` by raw value, `TrimOptions` (a tolerance per premultiplied channel, alpha included, which the sheet leaves at 0), `calculateTrimRect` (the alpha bounds, or each row scanned in from both ends against the corner's pixel) and `trim` (the export's render, then `CanvasResizer` by the content offset, or the snapshot itself when nothing is cut). `UI/TrimSheet` is Swift's sheet: 320 wide, the title, Based On as radio buttons, Trim Away's four checkboxes, OK resting while none is checked, Return and Escape through `NativeShortcut`. `ProjectController::trim` runs as Canvas Size does, through `resizeProject`, whose resize may now return nothing: Swift's trim with nothing left returns with no alert and no step. The Image menu's Trim… follows Image Size…, with no shortcut. Left out as dead: `TrimError` (never thrown), `trimImage` and the session's `trim` (called by Swift's tests alone; the twin tests the rect and the controller's two steps), the kept rect's `maxX > minX` guard (the early returns leave content, so left < right and top < bottom) and the colour scan's `last > first` (the row's first unmatched pixel stops it). A fix beyond Swift: a raster that cannot be converted is `ExportError(render)`, explained, where Swift's `try?` reports nothing left; that failure path is untested, since only a failed allocation reaches it. Inner Glow joins as Outer Glow did (12.5b): `InnerGlowEffect` (10 pixels, white, 75%; 0–500, 0–1), `LayerEffectKind::innerGlow` last; `LayerEffectsKernel` blurs the shape at sigma size ÷ 2 and keeps shape × (1 − blur), composed over the colour overlay and under the inner shadow, Metal's order; no margin (Swift's `innerGlowRendersInsideSourceWithoutBoundsExpansion`), the stroke surface's reach three sizes and two. Swift's CPU `innerGlowCoverage` is dead here, as Outer Glow's is, so its parity test has no twin. A fix beyond Swift: a reduced preview scales the glow's size. The sheet shows its swatch, Size (0–100, 500 typed, px) and Opacity. Equivalent mutants: the canvas's margin counting hidden layers (it only widens the halo), the glow's `opacity > 0` (a clear glow composes nothing; the term spares the blur) and Trim's return Line truncated
- Blur and noise adjustment layers, Image Trim and Inner Glow (12.8c, Swift 1.2.3: `98ad817`, `24052b5`, `feb39f3`, `cd0ca3c`). `AdjustmentKind` gains Add Noise, Gaussian Blur and Motion Blur after Grain, v1.2.3's order (it moved Add Noise ahead of the blurs), with `circle.dotted`, `drop.fill` and `wind` as `LayerIcons` paths. `LayerAdjustment` keeps Swift's seven optionals (`blurRadius`, `motionAngle`, `motionDistance`, `noiseAmount`, `noiseGaussian`, `noiseMonochromatic`, `noiseSeed`), their resolved getters and setters with Swift's defaults (10, 0, 10, 10, off, off, 0), `isValid`'s bounds on every kind, as Swift's, and `samplingMargin`; the manifest writes each only when set, the seed a `UInt32` read as Grain's; the store takes the three kinds from version 9, which it now writes (versions 1–9). `apply` runs the three through `PixelFilter::run` with the layer's own seed; `FilterJob::noiseOrigin` sits last, so the positional initialisers keep their order, and `kernels/NoisePixels.c` is v1.2.3's (`noise_add_at`, whose position hash reshuffles the Add Noise filter's grain too). A new Add Noise layer rolls its seed, as Grain does; the editor opens the kind's filter panel on the layer's settings and writes back that kind's alone. Adaptation: Swift's canvas surface holds a pixel a point, so its region's origin and `scale` are already the surface's pixels; the twin's surfaces hold device pixels, so `LiveMaskRenderer::adjust` passes `adjustmentScale` (the canvas's zoom, Swift's, 1 elsewhere) times the device's pixels a region unit, and `apply` counts the noise origin in the image's pixels, so a HiDPI canvas blurs at its own density and the noise stays one field across dirty rects. `AdjustmentSurface::draw` takes Swift's `padding` in the painter's units: the halo is allocated past the shown area, not cut to the device (the canvas's outer surface is dirty-sized), and drawn back under the painter's clip. The canvas pads by the widest visible `samplingMargin` times the zoom, and its crisp path (from 200%) now draws the layers through a padded surface as well, as Swift's `drawLayers` wraps its own there. The crisp path wraps its surface even without an adjustment, where Swift's `drawLayers` wraps only for one: pixel-identical (source-over onto a cleared raster), one allocation a repaint region, as the smooth path already does. The HiDPI product is pinned by its two factors (the zoom on the canvas, the device ratio on a scaled composite), not at a device ratio of two itself. Image Trim: `Document/ImageTrim` twins Swift's file: `TrimBasedOn` by raw value, `TrimOptions` (a tolerance per premultiplied channel, alpha included, which the sheet leaves at 0), `calculateTrimRect` (the alpha bounds, or each row scanned in from both ends against the corner's pixel) and `trim` (the export's render, then `CanvasResizer` by the content offset, or the snapshot itself when nothing is cut). `UI/TrimSheet` is Swift's sheet: 320 wide, the title, Based On as radio buttons, Trim Away's four checkboxes, OK resting while none is checked, Return and Escape through `NativeShortcut`. `ProjectController::trim` runs as Canvas Size does, through `resizeProject`, whose resize may now return nothing: Swift's trim with nothing left returns with no alert and no step. The Image menu's Trim… follows Image Size…, with no shortcut. Left out as dead: `TrimError` (never thrown), `trimImage` and the session's `trim` (called by Swift's tests alone; the twin tests the rect and the controller's two steps), the kept rect's `maxX > minX` guard (the early returns leave content, so left < right and top < bottom) and the colour scan's `last > first` (the row's first unmatched pixel stops it). A fix beyond Swift: a raster that cannot be converted is `ExportError(render)`, explained, where Swift's `try?` reports nothing left; that failure path is untested, since only a failed allocation reaches it. Inner Glow joins as Outer Glow did (12.5b): `InnerGlowEffect` (10 pixels, white, 75%; 0–500, 0–1), `LayerEffectKind::innerGlow` last; `LayerEffectsKernel` blurs the shape at sigma size ÷ 2 and keeps 1 − blur, applied after the colour overlay and before the inner shadow, Metal's order (since 14.1 the three recolour the pixels, below); no margin (Swift's `innerGlowRendersInsideSourceWithoutBoundsExpansion`), the stroke surface's reach three sizes and two. Swift's CPU `innerGlowCoverage` is dead here, as Outer Glow's is, so its parity test has no twin. A fix beyond Swift: a reduced preview scales the glow's size. The sheet shows its swatch, Size (0–100, 500 typed, px) and Opacity. Equivalent mutants: the canvas's margin counting hidden layers (it only widens the halo), the glow's `opacity > 0` (a clear glow composes nothing; the term spares the blur) and Trim's return of an uncut snapshot (the canvas resizer returns one unchanged at the same size and place). The padding branch keeps an unpadded surface to the shown pixels: mapped there and back, a rect rounds Line truncated
- The finishing filters (12.9a, Swift 1.2.4, `e08dd8c`): `kernels/AdjustPixels.c/.h` are v1.2.4's, with `adjust_colored_vignette` (Camera Raw's falloff pulled out as `vignette_mask`, Camera Raw's own vignette unchanged) and `adjust_tonal_contrast`. `FilterKind` gains Vignette, Bloom / Glow and Tonal Contrast after Add Noise, Swift's order, so the Filter menu lists them; `FilterSettings` keeps Swift's thirteen fields, defaults and clamps (the colour through `AdjustmentColor::clamped`). Vignette and Tonal Contrast hand the kernels every setting; Tonal Contrast's base is `PixelAdjust::gaussianBlur` at radius × scale, clear past the edge, Swift's unclamped `applyingGaussianBlur`. Known difference: Core Image's `CIBloom` is private, so the twin's Bloom / Glow is the pixels plus their Gaussian glow (radius × scale, clear past the edge) at amount ÷ 50, Swift's intensity, added byte by byte on the premultiplied values and rounded once, alpha included, so the glow shows past a clear layer's light as Swift's test asks; Core Image's own curve may differ. Bloom grows the grid by three radii and two and is trimmed on commit, as the blurs are. A commit with nothing to change closes as Cancel does: Vignette or Bloom at amount 0, Tonal Contrast at amount 0 or with all three tones at 0. Every filter's commit now keeps the layer's effects (Swift's rebuild gained `effects: current.effects`); shape and text still drop. `FilterEdit::previewSourceVersion` is Swift's: `prepare` moves it with each new grid, the worker hands back the version it rendered from, and a result for an older grid lands nothing, its waiting job rendering next (a render begun before `growForBlur` would otherwise show the old grid's pixels in the new placement). The twin's one watcher can hold a job waiting behind another edit's worker, which Swift's per-edit task never does, and Preview off keeps that job: a growth therefore rebuilds a waiting job on the new grid, so the version it starts with names its image. The picker gains Swift's Vignette target (`openVignetteColorPicker`, `previewVignetteColor`, "Color Picker (Vignette Color)"), closed with its colour by OK and without by Cancel, as the Gradient Map's; Swift's `committing` terms are left out as the Gradient Map's are (a commit keeps the project busy and closes the picker first), and so is `setVignetteColor`'s edit guard, read through `value()`: the Vignette picker lives only while its edit is open (Cancel and OK close it first), where the Gradient Map's may outlive an adjustment's editor. `UI/FilterSheet+Finishing.cpp` holds the three kinds' rows, FilterSheet.cpp running to 442 lines: Vignette's Color row (a 95-point title, the Gradient Map's 24-point swatch), Amount and Highlights with Swift's help, Midpoint, Roundness (no unit), Feather; Bloom's Amount and a logarithmic Radius; Tonal Contrast's Amount, Shadows, Midtones, Highlights and a logarithmic Radius. The sheet hands the picker's working colour to the Vignette and repaints the swatch on every change. Tests: `FinishingFilterTests` (Swift's five, then the kernels at a scale, the settings' bounds, the idle commits, Bloom's grid, a preview for an older grid, the picker, the sheet), the kinds in `FilterModelTests`, a filter keeping effects in `LayerEffectsTests`, the Filter menu in `CompositorMenusTests`.
- Keyboard zoom, the rail and the tab strip (12.9b, Swift 1.2.4: `383548c`, `8053fb6`, `75c4219`). `CanvasViewport::keyboardZoomLevels` holds Swift's seventeen stops (12.5% to 1600%) and `keyboardZoomTarget` the next stop past the zoom within a billionth, or the zoom itself at an end; `EditorSession::zoomKeyboard` steps about the view's centre, leaves fit mode and stays silent when nothing moves; Swift's `step != 0` there is dead and left out (the viewport answers the zoom itself for a step of 0). The View menu's Zoom In and Zoom Out and the toolbar's zoom buttons step through the stops, the menu entries doing nothing while a text field or open text holds the keys (Swift's `NSText` guard; the entries' `m_field` and `m_canvas` with a draft). Swift's `handleKeyboardZoom` catches its zoom keys on key-down and repeat because a Mac menu key equivalent does not repeat; Qt's shortcuts repeat, and a matched shortcut never reaches a key filter. The menu's Ctrl+= and Ctrl+- also take Ctrl with the keypad's minus (probed in the app), so the canvas's window-wide filter (`handleKeyboardZoom`, beside the blend keys, outside text) takes only what Zoom In's entry cannot hold: Ctrl with Shift and = (Qt's Ctrl++) and the keypad's plus, never with Alt or Meta, and only while that entry keeps its default key (a remapped entry leaves the plus alone, as Swift's check of `ShortcutSettings`). Swift's zoom-out chords are all the menu's here, so the filter has no zoom-out branch. Swift's `8053fb6` pads the zoom buttons by 4 points; Qt's toolbar spaces its actions itself, left as it is (cosmetic). The rail already scrolls without scroll bars at 56 points (4.3), what `IndicatorlessScrollView` gives macOS. The tab strip follows `ProjectTabScroller`: each title button is Swift's `projectTabLabelWidth` wide (the title's width in its 12-pixel medium or semibold font, 10 more when unsaved, 35 to 155) plus Swift's trailing 8, the title elided within it and painted from the left (`TabTitle`, Swift's leading frame; a `QToolButton` centres its text), so a title moves only the tabs after it; the unsaved dot is a character, wider than Swift's 10 points of dot and gap, and widens its button by the excess (known difference). Since 13.14 the strip no longer scrolls: tabs that do not fit gather under the overflow pill and the front tab is always shown (Swift's drop slot has no twin, 4.7). Tests: `KeyboardZoomTests` (Swift's five, the tolerance from each side, and the key test in the app's own window: each chord's route, repeat, Shift-minus, Alt, Meta, text fields, a remapped entry, no document), `ProjectTabStripTests`, the menu in `ViewMenuTests`, the toolbar and strip in `ProjectWorkspaceViewTests`.
- The format version in one place and the layer context menu (12.10a, Swift 1.2.5: `4cc9951`; `6fa115e`, `2f1e796`, `5c5f05b`, `1366f68`, `5d7adba`). `ProjectManifest::current` (9) is what saves write and `supported` (1 to it) what loads accept; the manifest's default, both version checks (`supports`) and the version error's range read them, so a bump is one edit. Swift moved the layer menu from each cell to the table; the twin moves it from `LayerCell::actions()` to the list: `NativeLayerList::contextMenu` is Swift's `contextMenu(for:)` (a row pressed with nothing selected is selected first; Duplicate Layer, Rename… for one layer alone, Delete Layer, Delete Selected Layers or Delete Mask, Create or Release Clipping Mask, Group Selected Layers, Move Out of Folder, the merge entry under `mergeTitle`, Add Mask with Reveal All (White) and Hide All (Black), Enable or Disable Mask, Delete Mask, Link or Unlink Mask, Show or Hide Layer, with Swift's separators and each entry enabled as Swift's `validateMenuItem`, the mask entries targeting the pixels first; 1.2.5 left Layer Effects out; a selection refused (Levels open) leaves no active layer, and every entry then rests); `menuFor` is Swift's `menu(for:)`, routing the press first: on an effect's row that effect is chosen (Swift's `selectEffect(at:)`), elsewhere the chosen effect goes, a row counts as selected only while no effect is chosen (`selectedEffect`, which a stale choice an undo left fails, as the row's painting reads it; Swift's table then shows none), the pressed row's id is read first (Swift's `targetLayer`), since a selection may finish text and rebuild the rows, a selected row keeps the selection with the pressed layer primary unless a thumbnail targets its mask or pixels, and an unselected row becomes the selection, the list takes the keys and its mask or pixels are targeted. `LayerCell::contextMenuEvent` executes that menu; below the rows none shows. Left out as dead: Delete Mask's `mask != nil` (the session chooses a mask only while it exists) and the routing's `effectSelection = nil` (`selectLayers` and `selectLayerTarget`, which every path calls, drop a chosen effect themselves). The entries act on the layer active when chosen, as Swift's `@objc` actions read it, where the old cell menu acted on its own row; Rename asks `canEditLayers` again then, as Swift's `renameSelectedLayer`. Tests: `LayerContextMenuTests` (Swift's regression tests, the folder duplicate included; titles; a real right-click), `LayerContextMenuEdgeTests` (the gates at their edges, a refused selection, action-time targets and an entry with no active layer, a chosen or stale effect, a rebuild under the press), `LayerContextMenuFixtures.h`, `theCurrentFormatVersionIsOneTheReaderAccepts` in `ProjectTests`, the old cell-menu cases in `NativeLayerListTests` and `LayersPanelTests` moved to the list's menu.
- Whole layers on the clipboard and several duplicates (12.10b, Swift 1.2.5: `5ef5b0a`, `c873453`, `245db93`, `2f1537f`). `CopiedLayer` (in `Document/SelectionClipboard.h`) is the session's `copiedLayer`: Copy with no selection and no chosen mask (`canCopyLayer`) takes the selected layers whole; a layer with pixels still copies them as well, for other apps, and a folder or adjustment puts only its id on the clipboard under Swift's `com.compositor.copied-layer`, dropping the pixel copy. Any copy of pixels drops it; it is judged as a pixel copy is (`clipboardHolds`), so a copy anywhere since, in this app or another, ends it for good, a history entry set again included, and Wayland's second announcement of the same copy does not. The copies' active one is the active layer's copy, else the first id's with a copy in order (Swift's `values.first` is any). `ProjectWorkspace::pasteCopiedLayer` finds the tab whose copy still stands, keeps the ids its document still holds, and duplicates them in place as one `Paste` step, refused while that project cannot edit its layers, or copies them into the front tab through `copyLayers`; false leaves Paste to pixels. The Edit menu's Copy runs with `canCopyPixels` or `canCopyLayer`, and Paste asks the workspace first, a text field and open text before both. Ctrl+J without a selection duplicates a folder too (`layerViaCopy`), and its entry is enabled for any active layer; a folder under a selection copies nothing. `beginDuplicateTransform` stacks its copies through `duplicateLayers`, the copies being what is selected after it that was not before, the active copy leading. The copy between projects moved to `ProjectWorkspace+Copy.cpp`, since the workspace's file reached 500 lines. Tests: `LayerClipboardTests` (Swift has none), the updated folder cases in `SelectionClipboardTests`, `CompositorMenusTests` and `TransformTests`, and `effectsTravelWithTheCopy` in `ProjectCopyTests`.
- Crop, Vignette and text colour from Swift 1.2.5 (12.10c: `8b5a6af`, `5ece82b`, `0488cab`, `3317d02`, `4e5f4ec`). The crop ratios gain 3:4 and 9:16 (`cropRatio`, `CropControls`). Choosing the Crop tool with a selection that is not empty starts the frame at the outline's box (`boundingRect`, which leaves control points out as CoreGraphics' `boundingBoxOfPath` does; `controlPointRect` would not) rounded out to whole pixels (`integral`) and cut to the canvas, the canvas when that is no valid frame (an outline nudged wholly off it; an empty line between rows, which rounds to a row, stays out through `isEmpty`). Vignette opens round (`vignetteRoundness` 100, the default a number that is none falls back to). On an empty layer (no pixels, no adjustment, no folder: `canVignette`, the gate the Filter menu's entry reads) it begins from clear pixels the size of the layer's box, grows its grid to the canvas and frames the canvas (`FilterJob::canvas`, mapped into the image's pixels through the inverse mapping; top-left rows, so Swift's flip is gone), painting clear pixels too (`AdjustPixels.c`'s `fillsClear`, Swift's kernel as it stands); on a layer with pixels it frames them and recolours only them, as before. The commit lands on a layer still without pixels (`startedEmpty`), and the canvas draws an empty layer while a filter previews it. `canAdjustColors` and `canVignette` share `canAdjust(allowingEmpty)`; Swift's separate `adjustment == nil` and `isGroup` terms are the twin's argument and `canAdjust`'s own. The text colour previews while a picker is open: the Type bar's picker and the foreground picker opened over open text (`ColorPickerState::editedText`, the draft and its colour), through `previewTextColor`, which the Type bar calls on every change of the working colour (Swift's `onChange`), and only on the draft the picker opened for; Cancel puts the draft's colour back, OK keeps it and moves the foreground, and a Cancel with no text open leaves the next text's colour. Swift's `background: false` term in `previewTextColor` is dead (only the foreground picker records `editedText`) and left out. The tab strip's edge fade (`1fb6a3f`, `e8ed83b`) has no subject here: the twin's strip draws no fade, and it sits in a `QToolBar` under the desktop's own title bar, which drags the window itself. Tests: Swift's `textColorPickerPreviewsAndRestoresDraft` and `theForegroundPickerPreviewsOpenTextToo` in `TypeToolTests`, `thePickersColourPreviewsOnOpenText` in `TypeControlsTests`, `theFrameStartsAtTheSelection` and the ratios in `CropTests` and `CropControlsTests`, `VignetteCanvasTests` (the kernel's frame and fill, the gate and menu, an empty layer's commit and canvas preview, a layer with pixels), the defaults and the kernel's arguments in `FinishingFilterTests`.
- Limits, ants and wheels from Swift 1.2.8–1.2.10 (12.11a: `7dadc88`, `bc865fb`, `8eba077`, `96edd5f`). `Document/DocumentLimits` twins Swift's enum as a namespace: `maxSide` (30,000), `maxSurfacePixels` (200 million, one canvas, export, filter target, layer, mask or generated surface), `documentPixelBudget()` (every layer, and apart every mask, of a document together: a sixteenth of physical memory in bytes, Swift's `physicalMemory / 16`, read once through `sysconf`, between one surface and 800 million) and the megapixels the messages quote; `budgetFor(memory)` is the budget's formula alone, so tests reach every memory size. Swift's `maxSideExtent` and `maxSurfaceExtent` (CGFloat copies) have no twin: C++ compares across types. Each literal Swift replaced is replaced as Swift's diff does it, the surface ceiling or the document budget, and the twin-only ones as their Swift counterparts: the RAW import's remaining pixels and the effects kernel's ceiling. Messages print the numbers (`maxSideText`, English grouping as the status bar). Tests fill budgets with layers that claim pixels they do not hold (`tests/BudgetFixtures.h`: `claimed`, `claiming`), so a boundary costs no memory whatever the machine; where an import or a save would still make the pixels, a boundary is read where it is judged before any pixel: the resizer judges a layer before it looks for its image (a missing image then says the budget passed; a canvas widened in one direction puts a layer on exactly the remainder), the store judges a file by its PNG header on load (`claimingPNG`, a header with a size its pixels never fill) and by the asset's size on save, before flattening it, as Swift reads a lazy `CGImage`'s size unrendered (the twin flattened first until 12.11a; a mask both invalid and oversized is now refused as too large where Swift says invalid). Save and load count through one `checkSize(size, isMask, budgets)`, images and masks apart. Every mask the resizer passes is drawn, so its exact edge is read under `AddressSpaceLimit`: the budget passes and the surface then finds no memory (`ExportError(render)`), one pixel more is refused as too large. The marching ants (`Rendering/TransformOverlay+Ants.cpp`, Swift's level of detail): an outline past 20,000 path elements, drawn below one screen pixel a document pixel, is filled into a gray mask (at the step at least, else as fine as about 40 megapixels allows, Swift's formula and its comment's bound alike; the path's own fill rule, where Swift's are winding and the twin's paths keep theirs), averaged down to its power-of-two step (`SmoothTransformation` for CoreGraphics' medium interpolation), any coverage counted, and traced along its pixels' edges through `MagicWand::outline`, in a `QtConcurrent` job whose watcher the overlay owns: a new source or step replaces the watcher, so an older result drops, and until a step lands the last one stands in, none at first. A trace whose tracer throws (`MagicWandError`) logs on `omaphoto.rendering` where Swift's `try?` is silent. The overlay asks the canvas to repaint the ants when a trace lands (`repaintAnts`); the canvas skips a tick while its last repaint is pending (`m_antsRepaintPending`, cleared as it paints: Swift's `needsDisplay` guard). The grading wheels' direction (`96edd5f`) came with 12.8b2: Qt's conical gradient already runs counterclockwise, and `theWheelsHueRunsCounterclockwise` holds it. Equivalent mutant: the landing's repaint (the ants timer repaints the same rect within a tick while a selection shows). Tests: `DocumentLimitsTests`, `MarchingAntsTests`, and every test that pinned the old ceilings, now read from `DocumentLimits`.
- Photoshop Large Documents, background-only files and oversized layers (12.11b1, Swift 1.2.8–1.2.10: `b8e3cd1`, `e6bcfa1`, `1fcf854`). Swift's `PSDReader.swift` runs to 543 lines, so the twin splits it: `IO/PSD/PSDReader+Records.h/.cpp` hold the cursor, the raw records, their pixels and the cut (`PSDRecords`), `PSDReader.cpp` the header, the assembly and the merged image. A version-2 header is a Large Document (`Header::isPSB`): the layer section's and layer info's lengths, each channel's length and the additional-info keys Swift lists (`psbLargeKeys`) take eight bytes (`Cursor::length`, a length past `qint64` too large), and PackBits rows count in four bytes (`PSDChannelCoder::decode`'s `largeDocument`); another version is `unsupportedVersion`, whose message names no format now (Swift's words, OmaPhoto for Compositor). The import panel offers `*.psb`, a dropped picture prefers the PSB type right after PSD's (`ImageFileDrop`, Swift's order), and the MIME package declares `image/x-photoshop-large-image` (a `*.psb` glob and the version-2 signature as magic) for the desktop entry, as Swift's Info.plist declares the type. A file whose records do not fit the budget together (`fitsBudget`, the check `decodeChannels` makes, summed) has every layer and mask cut to the canvas (`cropToCanvas`, the bounds Swift's `crop` gives, the channels' source bounds kept) and is judged again, refused only if it still does not fit; a cut channel is decoded straight into its part (`PSDCrop`: raw rows copied in part, PackBits rows outside it skipped by their counts, one routine with the whole image as its crop for an uncut channel), and each cut layer's report says so (`PSDRecord::croppedToCanvas`, the note first in its layer's notes). A file with no layer records, Photoshop's background alone, reads as its merged image (`PSDReader::merged`, ImageIO's read for Swift): the header, its ICC profile (resource 1039) attached as Qt reads it and sRGB when it has none, the sides and the remaining budget judged from the 26-byte header before the file is read, its ICC profile (resource 1039) copied only for this read, since a layered read skips it as Swift's does, as Swift asks ImageIO's properties before it decodes (the layered canvas's one-surface ceiling does not apply, as those properties know none), the first three of its channels' planes from the image data section (every channel's row counts precede the rows), opaque; `ImageImporter::decode`'s `flattenedPhotoshop` sends it through the importer's own colour conversion and thumbnail, and when the Photoshop worker finds no records (`PhotoshopRead::layerless`) the session ends the reading first, as Swift does, then decodes the merged image through the ordinary decoder (`decode(url, flattened)`, which also takes the merged reader's `PSDError` and `ExportError`) and inserts the one layer without the conversion sheet. Known difference: ImageIO also reads 16-bit and non-RGB merged images; the twin's reader keeps the layered path's 8-bit RGB rule. The second `fitsBudget` refuses before any pixel is read (decoding the first layer could fail otherwise, and differently), and the crop's `max` cuts an inverted record to nothing, reported. A source's bounds and the cut stay 64-bit (`PSDCrop`, the coder's widths): once a cut makes a source past the side limit acceptable, its width can pass `int`, and Swift's `Int` holds it; PackBits rows decode straight into the crop's columns, so no row the width of the source is held, and the counts table is judged against the data before it is allocated. A PackBits row past the crop writes through a checked `std::span`, so a lost skip aborts. Tests: `PSBImportTests` (Swift's three; the merged image, its profile, budget and surface rule; the session's import at a point), `CropToCanvasImportTests` (Swift's five; the layers summed before any cut, a layer wholly before the left edge), the version and oversized cases in `PSDRoundTripTests` and `PSDReaderLayerTests`, the MIME package in `DesktopIntegrationTests`.
- Photoshop text (12.11b2, Swift 1.2.10: `b8cce6e`, `fa2b342`): `IO/PSD/PSDText.h/.cpp` twin Swift's file, a namespace; Swift's 682 lines split into `PSDText+Engine` (the text engine's PostScript dictionary, a `std::variant` tree read through `walk`, `number`, `boolean`, `string` and `array`) and `PSDText+Descriptor` (Adobe's action descriptors, `Reader` and the item lookups; a rectangle is a normalised `QRectF`, and a side's key is chosen by presence before its type is read, Swift's `??`). A type layer (`TySh`, else `tySh`; 8,000,000 bytes at most) reads as Swift's: version 1, a finite matrix, version 50, the text descriptor; vertical text, shear and uneven scale refuse (the matrix's scale alone maps engine sizes to pixels, never the document's resolution); a warp other than none, faux bold or italic, runs whose twelve-field signature differs from the first, and justification other than left, right or centre each add Swift's note; the words come from `Txt ` (else `Txt`), cleaned of a leading BOM or NUL, trailing NULs and CR line ends, and only when that item is missing from the engine's `EngineDict/Editor/Text`; 1 to 100,000 UTF-16 units. A frame (`bounds`) wider and taller than the letters (`boundingBox`) by more than four makes box text, its corner mapped through the matrix; rectangles read standardised, as `CGRect` reads them. `render` draws through `EditorSession::textImage` and places the anchor (the frame's padded corner, or point text's first baseline at its alignment's x, `TextLines::baseline`, new here) on the document point through the layer transform, a transform that is not valid being `ProjectError(tooLarge)`. The reader parses type first, so a parsed type layer never becomes a live shape or a rasterized vector mask; the builder draws the text (any `std::runtime_error` keeps the pixels with Swift's rasterized note, Swift's `try?`), reports the parser's notes and a missing face, and makes a live text layer. A missing face is one fontconfig does not list by PostScript name (`TextLayout::availableFonts`), where Swift asks `NSFont`; its note says the text was drawn with a substitute font, since Qt draws fontconfig's match where macOS draws the system font. Engine numbers read as Swift's `Double(text)` (whole token, C locale, overflow infinite, through `strtod_l`); a name with a high byte reads empty and an identifier with one refuses, Swift's ASCII decoding; an odd count of UTF-16 bytes in an engine string reads empty, and a lone surrogate in either reader becomes U+FFFD (Qt's decoder keeps it; Swift's strings repair it), a valid pair kept. Fixes beyond Swift: nesting deeper than 512 in either reader refuses the block, where Swift's recursion overflows its stack, and a font index or justification past `Int64` reads as absent (left, with the note), where Swift's `Int()` traps. Left out as dead: `placement`'s finite-scale guard (a scale over 1e-6 from finite numbers), `parse`'s font-size guard (every path clamps it), the inner frame guard (the final `isValid` refuses the same style), `baseline`'s empty sample and no-glyph fallback (the words are never empty, and point text's image holds its first line), the frame's `width > 1` and `height > 1` (each side already passes the letters' by four) and `Double(text)`'s empty token (a number starts with a byte it keeps). Equivalent mutants: `runs.size() > 1` as `> 0` (it only keeps `begin() + 1` off an empty list), `rounded`'s `Int64` bound (past it the cast is undefined, and x86 yields a value no index or justification matches), the string's end after a lone backslash (the dictionary cannot close then) and the retry's `start > 0` (a retry at zero fails again). Tests: `PSDTextTests` (Swift's ten, the builder's notes, rendered pixels and exact transform, the matrix's two per cent, each signature field, a rendered frame's corner, the baseline in ink, frames placed and flipped, the style's fallbacks, a type layer with a shape), `PSDTextReaderTests` (the engine's forms, deep nesting, every descriptor type and cap, the list and reference counts, nesting and names, lone surrogates, colours in one, three and four channels, each channel as a second style, the sources' precedence, rounding, scaled leading, the `Txt` key, negative exponents, both versions and the limits at their edges); `PSDFixture+Text.h` twins `tySh`, and `PSDFixture::data` now takes several additional blocks.
- Text at the click, text on the Move tool's double click, and SVG (12.11c, Swift 1.2.10: `292cb6b`, `072fa08`, `a5721b2`, `0534e00`): a click with the Type tool puts new text's first baseline on the pointer, the box its padding to the left and its first baseline above (`TextLines::baseline(0)` of the style, the laid line's descent, where Swift reads the font's descender: they agree within a hundredth of a pixel); a dragged box sits exactly where it was drawn. Swift's `setHiddenUntilMouseMoves(false)` has no twin: Qt never hides the pointer while typing. The canvas's press is `press(event, clicks)`, Swift's `mouseDown` with its click count: `mousePressEvent` passes one and a double click's fall-through two, so a triple click counts one (the Type tool keeps its own count); with the Move tool a second click on the topmost shown live text under the pointer (a hidden folder hides what it holds) runs `commitTransform`, selects it, opens its editor and synchronizes the inline editor (`beginLiveTextEdit`, in `EditorCanvas+Transform.cpp`), else the press goes on to guides and the transform drag. No transform edit is open there (`canEditLayers`), but `commitTransform` also ends an opacity drag and a blend preview, which `selectLayer` leaves when the text is already active. In the layer list a live text row's size column says "Text · Double-click to edit"; a double click on its thumbnail opens it and on its name renames it, as Swift's merge (`3258c66`) kept, where the branch commit opened text anywhere. Opening existing text puts the caret after it once the editor takes the keys, unless a click already placed it (that merge's step in `InlineTextEditor.synchronize`): the queued focus request moves an untouched caret (0, 0) to the end, for a draft on a layer alone; the editor keeps its canvas as a `CanvasView` to reach itself there. SVG goes by its suffix, `svg` or `svgz` in any case (the importer's filter lists both, the desktop entry `image/svg+xml` and `image/svg+xml-compressed`, which shared-mime-info keeps apart) (Swift's `UTType.svg`), after camera raw and before Photoshop's bytes, through the ordinary decoder with the canvas's size: `ImageImporter::decodeSVG` renders once through Qt SVG's `QSvgRenderer` (a new dependency: `qt6-svg-dev` in the dev image, `qt6-svg`, `qt6-qtsvg-devel` and `qt6.qtsvg` in the distro images, the PKGBUILD and the flake; the DEB and RPM find the library themselves), fitted to the canvas by the smaller ratio, else at the declared size, each side rounded and one pixel at least, refused past the side limit or the remaining budget, tagged sRGB, named without its suffix. Drops need nothing, since a dropped picture of any `image/` type already imports. Known differences: Qt SVG draws SVG Tiny 1.2 where macOS's renderer draws more of SVG 1.1 (filters, masks, some CSS), `defaultSize` truncates a declared size to whole pixels (40.9 by 20.9 draws 40 by 20; a side that truncates to nothing falls back to Qt's bounds, which round, so a declared side under half a pixel refuses and one from half a pixel draws a pixel wide, where Swift draws any positive side a pixel wide at least), and a drawing whose declared size is empty takes its content's bounds, where `NSImage` measures nothing; Swift's `isValid` guard is folded into the size test, since Qt measures a drawing it cannot read as nothing. Tests: Swift's two `TypeToolTests` changes and the baseline in ink (`clickedTextSitsOnItsBaseline`); `LiveTextDoubleClickTests` (zoomed and turned text, a hidden folder, the cleanup, the caret); `SVGImportTests`. Tests that set text up at a known corner use `beginTextAt` (`SessionFixtures.h`), which pins the draft's corner as Swift's clipping test does.
- Typed text as pixels, effects kept on (12.12a, Swift 1.2.11: `fe6cb2b`, `b683acb`): the canvas draws text being typed as the pixels `textImage` makes of the draft's style (remade only when the style changes and let go at the first draw without a draft, as Swift's `drawNewText` asks with no draft, each remake a debug line on `omaphoto.rendering`, a style that cannot be drawn logged and drawn as nothing), where the editor shows it: an edited layer in its place with its opacity and blend mode (its mask left off, as Swift's), new text just above the active layer, or on top when the active layer is not drawn (a folder, a hidden or blank layer, none). The editor's glyphs are clear: it draws its frame, handles, caret, marked text's underline and a selection at 45% of the highlight over those pixels. An underline without a colour of its own, the preedit's or an input method's, takes the text's colour, since clear glyphs would hide it (Swift's `NSTextView` draws marked text itself). `DisplayState` carries the draft's style and shown transform, so typing repaints the whole view, as effects reach past the box; a handle drag at a zoom can move the box a fraction with the style unchanged, so the transform counts on its own. The edited layer's effects are redone as it is typed, synchronously at the preview size (`EffectsPreviewCache::renderNow`, the worker's scaling moved into `scaled`), through its enabled mask or its placed mask clipped over the typed box; until a change is redone the last effects stand in. When the edit ends and the layer holds the text as last typed, the next draw seeds those effects (`handOnDraftEffects`); a cancel of changed text, or other text, seeds nothing. The cache keeps the last three finished results per layer: pixels an undo puts back show their effects at once, a known result clearing any seed; a mask added, removed or replaced on the same pixels keeps the last preview until the new one lands, where it dropped before. A painted mask keeps its layer's effects through the stroke: `LayerEffectsSurface::MaskStroke` holds the stroke's tiles, their map into the surface's grid and the coverage of any region (the old mask, then the tiles over it, as a tile already holds the old mask it covers; Swift's white fill beneath is dead, since past the old mask there are no pixels), and the surface redoes the regions the mapped tiles touch; a placed mask's surface keeps the layer's grid and takes coverage from `placedMaskPreview` (`placedMaskSurface`). Swift's `restoreToolCursor` has no twin: `syncPicking` already sets the cursor both ways, and a widget's cursor cannot be left over from another window. `strokeDirtyRect` moved to `EditorCanvas+Brush.cpp`, the typed text to `EditorCanvas+DraftText.cpp`. Left out as dead: the cache's filter of a result already recent (a request matching one returns before it renders). Equivalent mutant: the mapped tile rect's one-pixel inset (the redo region is widened by the effects' reach). Tests: `TypedTextCanvasTests` (Swift's `textLooksTheSameWhileEditingAndOnceCommitted` at 1.5 and 4, with the box's inside compared since the editor's frame draws on the canvas; placement above the active layer, opacity and a blend mode with and without effects, effects kept and handed on, masks linked and placed, cancelled text, remakes counted and the image let go once editing ends, typed effects redone when a handle moves a placed-mask box a fraction, the whole view repainted when typing or when a handle moves the box a fraction), `MaskEffectsCanvasTests` (linked and placed masks keep effects mid-stroke, a placed mask sixty times finer against a whole render; untouched tiles keep the old mask), `EffectsPreviewCacheTests` (recent results, `renderNow`, the looser keep), `LayerEffectsSurfaceTests` (a mask stroke redone where its tiles map), and the inline editor's tests read the 45% selection through `highlightedOver`.
- Masks painted anywhere (12.12b, Swift 1.2.11: `e753f26`, `8d2eade`, `2ffb092`, `a247676`, taken as their net change from 1.2.10): `BrushStroke` takes `growsMask`, which the Brush, the Gradient and fills (`applyPixelEdit`) pass, so on a mask their grid reaches the canvas as a pixel stroke's does; Spot Healing, Clone Stamp and the Smear keep the mask's grid. A solid placed mask (two pixels a side at most) is painted at a pixel per document pixel of its placement rather than grown from its pixel. `maskBackground` (`LayerMask::background` of the old mask's thumbnail, white or black, 1 without a mask) fills each new tile, the committed snapshot past its base and patches (`RasterSnapshot::fill`, drawn where neither reaches, the box of a mask grown by distant tiles holding such corners) and the asynchronous commit's context (`BrushCommit::Input::fill`). A mask stroke's snapshot keeps every touched tile (`committedBounds`). Committed, a mask grown past its old bounds, or placed already, takes the stroke's transform as its placement (`placedMask`, for the snapshot and the raster commit alike), keeping its place on the document while a linked one still moves with its layer. On the canvas an unplaced mask stroke draws in its possibly grown grid (`paintTransform`), a placed one in the layer's place, and a placed mask's preview and surface map through the stroke's `paintTransform`. `CanvasThumbnail::mask` fills past the mask with `LayerMask::background`, so a stroke touching the edge leaves no gray mean; the thumbnail's own `edgeTone` is gone. `8d2eade`'s canvas fix (the distortion's effects asked with the mask as stored) needs no twin: the twin asks for a layer's effects once, with the mask as shown (9.6c), and its y-flip in `LayerEffectsSurface` has no Qt counterpart. The linked mask surface's white fill (12.12a) stays dead: the layer's pixels lie only within the old mask's rect, however far the grid grows. Equivalent mutant: `placedMask`'s `mask.placement` term (a placed mask keeps its placement through `replacing`, and its transform over unchanged bounds is that placement). Tests: `MaskPaintAnywhereTests` (the brush grows a mask and keeps it linked and in place, a hide-all mask's new area hides, the Gradient and a fill grow it, the Smear's blur does not, a solid placed mask's grid (two pixels a side, a box not square, grown and not), the snapshot's and the commit's fill in uncovered corners, a grown stroke drawn in place, a grown placed mask's tiles mapped under effects), `CanvasThumbnailTests` (Swift's edge case), and `MaskEffectsCanvasTests`' finer placed mask through the stroke's transform.
- Scrubbable labels (12.12c1, Swift 1.2.11: `2d36d6e`, `680a522`): `UI/NumericScrub` twins Swift's modifier as a `QObject` living as its label's child and filtering its events; `scrubbable` and `unitSuffix(_:scrubValue:)` are its constructor on the label or unit (`Options`: sensitivity, range, optional step, value, set, onStart, onEnd). A left press on an enabled label, a double click's second press included, is taken (a disabled label's presses reach its filter before Qt drops them, so the filter asks), so the label keeps the drag past its edge; the first move off the press begins (Swift's `minimumDistance` of one, reached by any move of whole points), keeps the start value and calls `onStart`; each move sets the start plus the sideways distance (in the label's own points, which stay exact, where Qt's `notify` rounds global ones) times the sensitivity, snapped to the step when positive, then clamped; the left release calls `onEnd`. Swift's Int overload is a step of one. The cursor is the label's own `SizeHorCursor`: Qt keeps a grabbing widget's cursor through the drag and shows a disabled label's parent's, so Swift's hover and restore dance has no twin. Qt can lose a release: the left release is watched application-wide while pressed, and a move without the left button ends the drag (the label tracks the mouse, so such a move arrives). The tool bars scrub as Swift's: the Brush's Size, Hardness, Opacity or Strength and Smoothing, the Gradient's Opacity, the Magic Wand's Tolerance, Object Select's Edge, the Expand, Contract and Feather units (Expand's and Contract's rest with their rows; Feather's amount and unit stay enabled without a selection, its button alone resting, as Swift's, which the twin's amount had missed), the amount sheet's title (writing its input), the Shape's Width and Radius (a scrub writes its field's text over any typing, as a step does: `showValue` on `SelectionAmountField` and the Type bar's `TextStyleField`), the Type bar's size unit, Tracking and Leading (whole numbers), the Move bar's labels (whole numbers in Swift's new ranges: ±30,000, sides 1 to 30,000, Scale 0.1 to 30,000%, rotation ±360; the scrub writes its field's text over any typing, then the value, so an unfocused field shows what the session keeps; another active layer ends each field's drag under way, `TransformValueField::endScrub`, as it ends the opacity's), the zoom's unit (through the field's `step`) and the layer opacity (one opacity drag, `beginOpacityEdit` to `finishOpacityEdit`; another active layer ends a drag under way through `NumericScrub::end`, since the twin keeps the controls where Swift's `.id` remakes them, and later held moves wait for a fresh press). The sheets and panels follow in 12.12c2. `075a71b`'s narrowing fix in `AdjustPixels.c` is taken (the kernels match Swift's); its other changes and `725b934`'s are Swift's concurrency, plist and CI, with nothing to twin. Swift's `emptyCanvasLeavesTheScrubberCursorAloneAndRestoresTrackingForADocument` has no twin: the canvas never sets a global cursor, only its own. Equivalent mutant: removing the application filter when a drag ends (it then sees nothing to end). Tests: `NumericScrubTests`, `ScrubbableLabelsTests`, `ScrubbableTransformTests` (the Move bar, the zoom unit, the layer opacity), `ScrubFixtures.h`.
- Scrubbable titles in the sheets and panels (12.12c2, the rest of `2d36d6e` and `680a522`): each title (or channel letter) is a `NumericScrub` with Swift's sensitivity, range and step, and every scrub replaces its field's typing (`setModified(false)`, as the row's slider does) before the sheet shows the value. Effects' rows scrub to the typed limit (Swift's `inputRange`, the stroke to 500); Hue/Saturation's titles take their range from Colorize, the Canvas and Image Size titles theirs and their sensitivity from the unit, Relative, Lock, Resample and the resolution, so the owner hands new limits to `NumericScrub::reshape` on every synchronize, as SwiftUI's next render hands the modifier new ones. Levels' captions move a tone a level a point and gamma a hundredth, within 0 to 255 and 0.1 to 9.99; a filter's title moves the field's last decimal a point (`1 / 10^decimals`) over the linear range, logarithmic sliders included, as Swift's; the colour picker's R, G and B move whole levels. Canvas Size snaps to whole units in pixels, percent, inches or centimetres, its range Swift's `scrubRange` (a locked ratio keeps both sides within 1 and 30,000; Relative counts from the current size); Image Size's titles rest while a physical unit has no positive resolution (Swift's `canScrubDimensions`), resampling spans Swift's pixel and hundred-megapixel limits, a print size moves a hundredth of an inch (0.0254 cm) a point down to the pixels at 9,600 per inch, and Resolution scrubs whole pixels per inch from 1 to 9,600, through the same `setResolution` a typed one takes (a physical size keeps, its pixels scale). Camera Raw's rows opt in through `CameraRawRow::Spec::scrub` (the sensitivity), scrubbing through the row's typed setter, unrounded and unclipped, as Swift's bindings do: the main rows a point per last decimal, the curve's amounts and Refine Saturation, the HSL family titles (the Color and Point Color rows stay passive, as Swift's `colorSlider` and `pointSlider`), Detail, Optics, Geometry and Calibration a unit a point; Color Grading's Blending and Balance do not scrub, as in Swift. A title's double-click reset runs first and lets the press through, so the second press still scrubs, as Swift's simultaneous gesture does. A sheet destroyed while one of its fields holds the focus used to commit into its destroyed members; 12.14c fixed that (`releaseFocus`), though the app hides a panel's content before deleting it anyway. Equivalent mutant: the colour channels' step of one (the picker's colour is quantized to 8 bits, so a channel lands on whole levels anyway). Tests: `ScrubbableSheetsTests`, `ScrubbableCameraRawTests` (each row group's value, setter, sensitivity and limits; the passive titles).
- Projects changed on disk (12.13a, Swift 1.3: `ed5ddea`, `cd4061a`, `cba6efc`): `IO/ProjectWatcher` twins Swift's class with a `QFileSystemWatcher` (inotify) on the package folder, its manifest and its images folder, where Swift opens kernel vnode sources: an event restarts a 300 ms single-shot delivery (Swift's coalescing) and re-arms every path from scratch each 100 ms, up to 20 times, until all three are watched (both timers are named children, `delivery` and `rearm`, so tests read them, and precise, since a coarse timer may fire 5% early where Swift's sleeps never end early; so is the controller's recheck); Qt's inotify engine subscribes to no `IN_MOVE_SELF`, where Swift's sources watch `.rename`, so a package exchanged for another one kept aside would pass unseen: the watcher also watches the package's parent folder and counts an event there only when the package path's device and inode differ from the ones it armed on, because a package swapped in (our store's `renameat2` exchange, another app's rename) retires the watched inodes. `IO/ProjectDigest` is a namespace: SHA-256 (`QCryptographicHash`) over the manifest's bytes, then each regular file in `images/` (links and folders skipped, names sorted by UTF-16 units) as its UTF-8 name and its size as a little-endian 64-bit number; a manifest that cannot be opened, or reads short of its size (a failed read, as Swift's `Data(contentsOf:)` throws), or an image gone mid-listing, throws, which the callers read as no digest (Swift's `try?`). `ProjectController+ExternalChanges.cpp` holds Swift's extension, its state `ExternalChangeState` (`ProjectController+ExternalChanges.h`) a public member, as Swift's: the watcher, the known digest, `checking`, `pending`, `saving`, the recheck `QTimer` (single-shot, a child of the controller), `recheckAttempt` and `reloadCount`. Swift's async loop runs as callbacks: each pass takes the pending flag, stops without a path, a document or while our own save writes, computes the digest off the UI thread (a `QFutureWatcher` child of the controller, so an answer dies with it), passes over a digest that matches, backs off at 250 ms × 2ⁿ (n to 7) while the project cannot start an operation, a transform is open or the workspace manages, reloads an unmodified document, and for unsaved work asks Swift's alert (Revert, Keep Mine) over the window, only for the front tab; without a window, or behind another tab, the change waits for `resumeExternalChangeCheck`, which the workspace's `select` calls. Keep Mine remembers the new digest. A reload (`reloadFromDisk`) marks the project busy, loads through the store off the UI thread, lands only on the same path with a document still open, calls `EditorSession::reloadProject`, remembers the digest as loaded and then counts the reload; a package that fails to load leaves the document alone and logs on `omaphoto.io`. `reloadProject` installs the snapshot and puts back the viewport, the collapsed folders that still exist, and the active layer with the selection's surviving members when the active layer still exists, as Swift's; history starts over. An open (the controller's own, which the workspace's tabs use) and a save remember the digest, then watch the package; a save marks `saving` from before its worker starts until the digest of what it wrote is remembered, or, when it fails, until its alert is answered (Swift's `defer`), so its own events pass; a new canvas and a close stop the watch. Swift's decoding from bytes in memory (`cba6efc`) has no twin: `QImageReader` decodes a PNG into memory the image owns, never mapped from the file, so a later save that replaces the file cannot reach an image kept for undo. Equivalent mutants: the try count's reset on a new event (a missing folder's creation is itself an event on the package, which re-arms), and the reload's two log lines. Tests: `ExternalChangeTests` (Swift's six; the revert question both ways, Keep Mine remembering, a background tab asking when brought forward, a busy project backing off then reloading, new canvas and close stopping the watch and its recheck, one check at a time, a check ending without a project or during our save), `ExternalChangeGateTests` (a failed save's guard, a managing workspace, the back-off's intervals, a load landing elsewhere, the reload's whole view and last signal, an unreadable manifest, Save As and open moving the watch, a second change while the question waits), `ExternalChangeFixtures.h`, `ProjectDigestRaceTests` (a binary of its own: `readdir` and `readdir64` take libc's place and remove an image as its folder's listing ends, so the digest's throw is ordered, not raced), `ProjectWatcherTests` (one report a burst, the quiet time from the last event, twenty re-arm tries, each watch speaking alone, an exchange keeping the old package, precise timing, re-arming and giving up, the digest's exact input and order, an unreadable manifest).
- Editing while a project saves (12.13b, Swift 1.3: `940e188`): a save holds the tools only while it captures the document (`prepareSave`, Swift's: the revision, the snapshot, and the Save panel when there is no path yet or for Save As), then reserves the writer and only then frees the project, so no save asked in that signal slips in beside it, and writes in the background; a capture finds no document when Undo took it while the save waited (Swift's `guard let snapshot`), and the save answers false; `DocumentHistory::currentRevision` and `markSaved(QUuid)` twin Swift's, so only the captured version counts as saved: edits made while it writes stay modified, and undoing back to it is unmodified. Swift's `writing` task is `m_writing` with a list of waiters: `finishWriting` runs its callback at once when nothing writes, else when the write lands, each waiter posted to the event loop and passed through `finishWriting` again, so one writer runs at a time where Swift's store actor serialises the disk; `save` waits there before `begin`, so a second save writes what changed since, and `confirmReplacement` waits there first, so a close, a quit, a new canvas or an open never cuts a file short and asks only once the write is whole; an open also waits before its first read, and so does an external reload (12.13a's `reloadFromDisk`), since the twin's store is no actor and could read a package mid-save: a Revert answered while our save writes loads what we wrote. A digest that comes back while our save writes is looked at again once the write lands (pending, through `finishWriting`): after a save it matches what we wrote, after a failed one the change is taken up; Keep Mine remembers its digest only when no save landed while the question was up. A save queued behind a quit that already holds the tools is refused, as Swift's `begin` refuses it. A save that fails frees its waiters after its alert is answered. The refusal of a completed Save As name that exists sits in `prepareSave`, beside the panel. Swift has no tests for this commit: `ProjectSaveWhileEditingTests` pins it (edits while writing, a second save, a new canvas waiting, the panel alone holding the tools, a failed write, the last signal), and, over `tests/SaveHooks.h` (`fsync` slowed and counting overlapping writes, `renameat2` held at a package's swap, `readdir` and `readdir64` held at one folder, the store's loads counted from its log, every hold let go on cleanup), `ProjectSaveOrderTests` (a reopen reading nothing while the write is held, a Revert mid-save, the guard through the save's digest, Canvas Size and Image Size each committed before and after the held write lands, a digest out as our save begins, a failed save keeping an external change in line, a second save waiting through the first save's digest, Keep Mine after a save) and `ProjectSaveQueueTests` ( three saves one at a time, a save asked as the tools free, a quit waiting, a document undone away, a failed write holding its waiters, a reopen, a tab closing, a controller gone mid-write, an export beside a write).
- The guide for agents (12.13c, Swift 1.3: `c317306`): `docs/writing-comp-files.md` twins Swift's guide and `docs/project-format.md` Swift's format page, both adapted to Linux (a project is a folder, the example lives under `~/Pictures`, the save swaps folders with `renameat2`, a replaced project folder is noticed too, sources named by the twin's files). Swift's top-level `AGENTS.md` for agents becomes the section at the head of this file. README gains Swift's lines (scrubbing, saving while editing, projects changed on disk, working with AI agents). The format page's claims follow the twin, where Swift's page is out of date: the pixel budgets scale with memory, the save's exchange has a two-step fallback, only `maskPlacement` needs a mask, the common adjustment fields are required and later kinds default to their own values, folders pass adjustments through, text becomes pixels after Canvas Size, Image Size and Crop, and a missing face may fall to another family. `WritingProjectsGuideTests` reads the guide itself: the minimal manifest loads with the fields it shows (visibility, opacity, blend, placement, the image named after its layer), the Curves layer's points are pinned and its rendered pixel is the background through those curves, removing any common adjustment field refuses the project, a folder passes an Invert through, `hsvSettings` wins over the legacy Hue/Saturation fields (the guide says where to edit a saved one), and the guide's lists of blend modes, adjustment kinds and samplings equal the twin's `rawValue`s. The guide asks for 8-bit RGBA layers and accepts what the store accepts: RGB, grayscale and lower-depth grayscale layers, never 16 bits. `ExternalChangeTests::theGuidesSameSizeRecipeReloads` runs the same-size recipe against a watched project.
- Quitting while text is edited (12.14a, Swift 1.3.1: `a18d86c`, `afd622e`, `4a4cf85`, `221d6cc`): `ProjectWorkspace::confirmQuit` first applies every tab's open text in quit order (`finishTextEditing`, Swift's), so closing the window (the twin's quit) no longer stops at `canSwitch`, which an open draft refuses; text that cannot be applied (a busy project) stops the quit with the draft kept. Swift's `applicationShouldTerminate` guard has no twin: closing the window reaches `confirmQuit` directly. Swift's cursor commits (the arrow restored off the canvas, the I-beam kept across it while text is open) need no code here: a Qt widget owns its cursor, so leaving the canvas shows the next widget's, and the canvas already shows the Type tool's I-beam away from the box. Tests: `QuitWhileEditingTextTests` (Swift's two through the real window's close: the text applied as one `New Text Layer` step before the question, Cancel keeping the window, then Don't Save; every tab in quit order with its own content and its undo; a refused draft in the middle of three tabs stopping the quit where it stands, the tab before applied, the tab after untouched, nothing asked; edited existing text as one `Edit Text` step on the same layer; an unchanged existing draft and a blank new one closing with no step and no question), `InlineTextHandleTests::theIBeamHoldsAcrossTheCanvas` (the I-beam away from the box, after a resize drag, no application-wide cursor on leaving, and neither the window nor a sibling taking the I-beam).
- Version 1.3.2 (12.14b): `IO/RecentProjects` twins Swift's class as a `QObject` singleton with one signal, `changed()`. Swift mirrors the list macOS keeps (`NSDocumentController`); Qt has no API for the desktop's list, so the twin keeps its own in `QSettings` under `recentProjects`, ten places at most, newest first, each resolved as `samePlace` resolves it (so a link and its target are one entry); `refresh` drops places that no longer exist and signals only on a change, and runs on activation, where a GUI application exists (a GUI-less run has none). The controller notes a place after a save lands and after an open installs, never after a failure; `open` refuses a place that no longer exists before any read, refreshing the list, with the alert "Couldn’t open the project" naming it in Cocoa's words ("The file “Name.comp” couldn’t be opened because there is no such file."). The File menu's Open Recent follows Open Project…: each place titled without its suffix (`ProjectTab::nameWithoutSuffix`), opening it through the front tab's controller (so the workspace, as Swift's `projects.open`), then a separator and Clear Menu (`clearRecent`), resting while the list is empty; the whole submenu (`openRecent`) rests while the controller cannot start. Swift's `showEditor` has no twin (the window is the editor). The Image Size sheet keeps `m_lastResolution`, the last usable resolution: a zero or negative entry leaves the print size alone, a new valid one scales the pixels from it, and a width or height typed in Inches or Centimeters meanwhile is ignored (`555417e`); a resolution set by a print size without resampling goes through `setResolution`, as Swift's `onChange` sees every write. Camera Raw's Color Grading sits under Color and opens with Light and Color (`7e9afbe`): the `Section` enum, titles, eyes and `adjusts` follow the new order. Swift's `d62a132` makes every window dark whatever macOS is set to; the twin's windows, alerts included, already take the built-in dark or the Omarchy theme through the application palette, and follow Omarchy by design (Linux-only behaviour); file panels are the desktop's own, which on Omarchy follow the same theme, so nothing changes. A reload after a save over the opened place notes the place again, since another note may land between. The alert's words are plain text (`qt_msgbox_informativelabel`), so a name is never read as markup, and a menu title doubles its ampersands. Tests: `OpenRecentTests` (the stored list at start, activation checking again, nothing noted for a cancelled replacement, a cancelled Save As or a failed save, a save noted once it lands, the reload's note after another, the submenu's title and place, a literal ampersand and markup, opens and saves noted newest first, a failed open noting nothing, ten kept and a link counted once, moved projects dropped with one signal, the menu listing, opening, clearing and resting, a deleted project named), `RecentProjectsCoreTests` (the list without a GUI application, and across launches: note, read and clear each in a fresh process of the binary), `ImageSizeSheetTests::aNewResolutionKeepsThePrintSize` and `aDocumentsOwnResolutionIsTheLastUsable` (a 300-ppi document, zero, negative and infinite entries in both physical units with and without resampling, Pixels and Percent edits allowed meanwhile, positive resolutions past Resize's range remembered), `CameraRawPanelTests::eachGroupOpensAndItsEyeHidesItFromThePreview` and `eachEyeHidesItsOwnGroup`, the alert's palette in `OmarchyThemeTests`, the File menu's count and Open Recent's gate (busy, the new-canvas sheet, importing, the importer, the workspace's panel) in `CompositorMenusTests`.
- Coloured filter sliders (12.14c, Swift 1.3.2: `6b709b4`, `c8ed9ba`, `0c969af`): `CameraRawSliderTrack` gains Swift's `opposing` (two colours, `from` and `to`) and `spectrum` (thirteen stops every 30 degrees, the hue centred), and `colors` returns a list spaced evenly along the track, where Swift returns an array. `CameraRawSlider::reshape` is Swift's `updateNSView`: new bounds and track, the value shown again unless a drag holds the knob; `track()` reads the track. The filter sheet's `control` takes an optional track (`FilterSheet+Controls.cpp`, since `FilterSheet.cpp` would pass 500 lines): with one the row is a `CameraRawSlider` over the same thousand steps, linear (a logarithmic coloured row is a `logic_error`), helped "<Title>. Double-click to reset.", rounding what it sets to the field's decimals as the plain slider does, and a double click on the knob or the title puts that one setting back to the filter's default (`resetting`, Swift's static), Preview as it was; a title resets only for the left button and while enabled, as a SwiftUI tap gesture does; the title's filter is installed after the scrub's, so it runs first and passes the press on to the scrub, as Swift's drag still sees it. Black & White's families take `luminance` at 0, 60, 120, 180, 240 and 300 degrees, Tint's Hue a plain track (still reset by a double click, as Swift's `.plain` is a track) and its Saturation `saturation` at the tint's hue, following it; Color Balance's nine take Swift's three `opposing` tracks (`cyanRedTrack`, `magentaGreenTrack`, `yellowBlueTrack`). Hue/Saturation's three rows are `CameraRawSlider`s whose bounds and tracks follow Colorize and the range: Hue the spectrum round the range's middle (its index in `colorRanges` times 60, Master red; Colorize 180, red to red), Saturation the chroma track for Master, `saturation` at the range's hue, or at the chosen hue under Colorize, Lightness black to white; the slider sets whole numbers, leaving the knob's travel when the whole number is unchanged (as the filter sheet's rows do), so arrow keys walk it, and a double click on the title or the knob puts that value back to Colorize's start or none, Preview as it was. The sheet's spectrum editor moved to `HueSaturationSheet+Spectrum.cpp`, keeping the file under 500 lines. Swift's `LabelWidthKey` is `alignTitles`: on every synchronize each control's title is fixed to the width of the widest title among the rows shown (a row under a hidden `if` does not count), sixty at least. A fix beyond Swift, found by the item's tests: a `PickerField` commits on `FocusOut`, and a sheet destroyed while one of its entries held the keys (a panel shown alone at quit, a test's sheet) sent that `FocusOut` from `~QWidget`, after the sheet's own members were gone, so the commit read freed memory (a crash, or luck). `releaseFocus` (`ColorPickerSheet.h`, beside `PickerField`) takes the keys off the owner's focused entry, which commits as hiding would; every owner of a `PickerField` calls it first in its destructor (`EffectsSheet`, `ColorPickerSheet`, `HueSaturationSheet`, `FilterSheet`, `ImageSizeSheet`, `LevelsSheet`, `CanvasSizeSheet`, `CameraRawRow`, `ColorRangeSheet`, `GridSettingsSheet`), and so does every widget whose entries' callbacks read its own members through rows it makes (`CameraRawControls`, `CameraRawCurveControls`, `CameraRawMixerControls`, `CameraRawDetailControls`, `CameraRawOpticsControls`, `CameraRawGeometryControls`, `CameraRawCalibrationControls`; the grading rows have no entries). `SheetTeardownTests` destroys each with its first entry typed in and names the owner's class as the entry loses the keys: past its own destructor the owner reads as `QWidget`, so a missing release fails whatever the memory holds; the colour sheets' typing lands in the session. A one-off AddressSanitizer build of Debug Qt, not kept as a target, showed each colour sheet fault without the fix (heap use after free in `FilterSheet` and `EffectsSheet`, Qt's "class destructor may have already run" in `HueSaturationSheet`, `LevelsSheet` and `ColorPickerSheet`) and none with it. Tests: `ColoredSliderTests` (Swift's `colorBalanceTracksRunFromEachColorToItsOpposite` and `resettingAColoredFilterSliderRestoresTheDefault`, the spectrum's stops, each row's track and help, the tint's saturation following its hue, a whole value from a drag, double clicks on titles and knobs resetting their own row with Preview left off and a plain filter's title changing nothing, titles aligned with and without Tint's rows, the row's layout, a reset replacing the field's typing, the spectrum's stops painted in place, a new track repainting a still knob, mid-drag and from Tint's hue, Swift's literal Color Balance ends, other buttons and a resting sheet refused, a reset that scrubs on, each of Color Balance's nine rows reset alone with Preview off, titles shrinking back), `HueSaturationSheetTests` (the tracks by range and Colorize, double clicks resetting to none or Colorize's start, Line truncated
- Colouring only the selected letters (12.14d1, Swift 1.3.2: `2bfc4ea`, `4503998`, `b5fe653`, `a1d56ac`): `LayerTextStyle` gains Swift's optional `colorRuns` (`LayerTextColorRun`: location and length in UTF-16 units as `qint64`, Swift's `Int`, and sRGB red, green and blue), valid when sorted, apart, not empty, within the content and within Int, with Swift's `color(at:)` (`color`), `setColor(_:in:)` (an empty span or the whole text recolours the base and drops the runs) and `replaceCharacters(in:withLength:)` (new units take the colour before them, or at the start the first letter's), both working through Swift's per-unit colours and joining neighbours of one colour. `TextSpan` is Swift's `NSRange`; `TextDraft.selection` sits last. `TypeTool.h` declares `PaletteColor` opaquely and `ColorPalette.h` includes it, because `ColorPickerState::EditedText` now holds the text's whole style (Swift's `(draftID, style)`). The manifest writes `colorRuns` only when set, each run's five keys required, and the store refuses runs before version 10, which saves now write (versions 1–10). `TextLines` gives each paragraph its runs as `QTextLayout` formats before laying it out, so a run's letters draw once in their colour, as CoreText's runs do: formats handed to `draw` are an overlay, which would leave the base colour in their antialiased edges. The palette follows Swift's: the foreground swatch and the Type bar's picker paint the draft's selection (`setDraftTextColor`), previews too; Cancel puts back the base and the runs the text had when the picker opened (`restoreDraftTextColors`), the runs only while the content is unchanged; the Type bar's picker now keeps that style as the palette's does; `typeColor` is the first selected letter's colour, or with a caret the letter before it; Fill (`recolorText`) recolours every letter, runs gone, even when the base colour already matches; the text defaults never keep runs, so new text starts in one colour. Swift's clearing of the runs when new text begins is left out as dead, since the defaults it starts from never hold runs. The inline editor is the twin's own, so Swift's `shouldChangeTextIn` bookkeeping sits in `publish`, which takes each edit's exact replacements (`Step`: the range taken, the new length) in order: typing and deleting, the clipboard (the preedit out, the selection replaced, the preedit back), input methods (the old preedit out, the selection taken and the commit's replacement as one step when the commit lands where the selection was, as NSTextView replaces it, the new preedit in, and a lone preedit over the selection one replacement for the colours, as `setMarkedText` makes it, the selection's removal still history; an update, a cursor's move or a commit of the preedit where it stands is one replacement of the marked text for the colours, as `setMarkedText` and `insertText` replace it whole, even with the same string, so a leading preedit keeps the colour it took) and undo and redo; each step moves the runs against the text's length as the steps before it left it, as Swift's content changes between its calls. Each undo entry keeps the plain steps from its text to the next newer state (`since`: preedits never count, a preedit left as text by a key does), and undo and redo replay their inverses newest first, as NSTextView undoes each replacement, so a letter put back takes the colour before it, as Swift's does; a guess from the text alone would put colours on the wrong twin of a repeated letter. The draft learns the selection on every publish and every move (`noteSelection`, Swift's `textViewDidChangeSelection`). Swift's reset of runs that no longer fit after an edit is left out: every edit moves them. The editor lays its lines out from the style without runs: its glyphs are clear, and the formats would paint them in their colours over the layer beneath. The caret takes the colour of the letter before it (Swift's `insertionPointColor`). Swift's see-through selection under the picker (`b5fe653`) needs nothing: the twin draws the selection translucent, in the inactive colour when the canvas lacks the keys, and `4503998` only adds a test. Equivalent mutants: the inherited colour's `colors[start]` for `colors.front()` (at the start they are one) and `TextLines`' clip of a run to its paragraph (Qt clips a format to its layout's text). Docs: `project-format.md` and `writing-comp-files.md` say version 10, as Swift's. Tests: `TextColorModelTests` (Swift's model and invalid runs, every channel checked, runs of one colour joining only where they meet), `TextColorRunsTests` (Swift's other two: the selection painted and reopened with version 9 refused, Cancel restoring; the swatch's colour, the Type bar's picker, Fill and new text, the manifest's keys, runs across paragraphs, the run's Codable refusals, Cancel over mixed colours for both pickers), `TextColorEditorTests` (the selection reaching the draft, runs through typing, deleting, undo and paste, twins of one letter through Line truncated
- Faces for the selected letters (13.2, Swift 1.3.4: `0eaeb98`, `1ffc8f4`, `916d1ab`, `bc29006`'s test): `LayerTextStyle` gains Swift's optional `fontRuns` (`LayerTextFontRun`: location, length, fontName), `fontNameAt` (Swift's `fontName(at:)`: C++ cannot give a method a member's name), `uniformFontName` and `setFont`; `replaceCharacters` moves colours and faces alike, so the editor's steps, undo and the clipboard carry faces as they carry colours. The run members live in `TypeTool+Runs.cpp`, since `TypeTool.cpp` would pass 500 lines. A run's name follows Swift's guard: not empty, 200 graphemes at most, no newline character (LF, CR, VT, FF, NEL, U+2028, U+2029). `TextLines` lays each run in its face as a format (`TextLayout::font(style, face)` keeps the style's size, spacing, kerning and hinting); the editor lays out the faces, for the glyphs' places, and drops the colours alone. The manifest writes `fontRuns` when set; the store writes version 11 and refuses faces before it. `TypeFontPicker` shows a selection in several faces as (Multiple), a top item that is no font (its item data), and drops it once one face shows; a chosen face applies to the draft's selection, and the face shown or (Multiple) changes nothing. A fix beyond Swift: Swift's `textDidChange` copies the pending style's runs but not its face, so deleting until every letter left is in one run's face drops the run and turns those letters back to the old base face; the twin keeps the folded face (`deletingToOneFaceKeepsItsLetters`). Swift's `holdsSelection` has no twin: AppKit's text view loses its highlight when the menu takes the focus, while the inline editor keeps its selection unfocused (`TextFontControlsTests` holds it). Swift's reset of `fontRuns` in `beginText` is dead, as the colour's is: the defaults never carry runs. Equivalent mutants: `uniformFontName`'s clamp of its index (runs are sorted and apart), the layout's clamp of a run to its paragraph (Qt clips a format past the text, undocumented, so the clamp stays) and the decoder's string check (any other value reads as an empty name, which validity refuses). Tests: `TextFontRunsTests` (Swift's three; the store's rules; layout in mono, bold and across a break), `TextFontControlsTests` (the menu, the kept selection, typing and undo, the folded face, a click placed by the faces), version 11 across the store's tests.
- Sampling and open text (12.14d2, Swift 1.3.2: `810c88a`, `d448625`): closing the colour picker, by OK or Cancel, while text is open asks for canvas focus (`closeColorPicker` counts `canvasFocusRequest` before its final signal), and `CanvasView::consumeFocusRequest` now hands the keys to the canvas with a draft open too, where the editor takes them, as Swift makes the text view first responder, so typing and Ctrl+Return reach the text after sampling from the canvas. Swift also takes a sampling click for an open panel before its window sees it, so the panel stays key; a window manager activates whatever window is clicked, and handing the panel the keys back at the press would take the canvas's focus and end the sampling it began, so the twin hands them back at the release, as Swift did before that commit (known difference). `canAdjust` (so `canAdjustColors`, `canVignette` and the fill) and `canInvert` refuse while text is open, since the editor draws that text, not its layer, and a preview of it would be wrong; the Image and Filter menus follow. Tests: `PickingTests::openTextTakesTheKeysBackFromThePicker` (the foreground, background and Type bar pickers, new and existing text, OK and Cancel, one request each, typing after and Ctrl+Return applying, any focus request, no request without text for any picker by OK or Cancel, the owner's field keeping the keys), `InlineTextDrawTests::aFreshDraftTakesTheKeysButSparesAField` (a request now takes the keys from a button, a modal dialog keeping them with text open, a tool panel's field keeping them while the canvas waits for its window's turn), `TextEditingGatesTests` (the gates and the entries with new and reopened text, open and closed, Levels, Hue/Saturation, filters and Invert refused with nothing changed, content-aware fill and Camera Raw, Vignette on an empty layer).
- The Dither filter (12.15, Swift 1.3.3: `f3cd4e9`, `71635e2`, `d0aae29`, `09ab78a`): `kernels/DitherPixels.c/.h` are Swift's, unchanged, built with the other kernels. `Document/Dither.h/.cpp` twin Swift's file: `DitherStyle`, `DitherPixelShape` and `DitherColors` by raw value in Swift's order (`allDitherStyles`, `allDitherPixelShapes`, `allDitherColors`; `ditherGroup` stands for Swift's `groups`, with `diffuses`, `hasTones`, `isHalftone` and `drawsMarks`); `DitherSettings` keeps Swift's fields, defaults and ranges, `normalized` rounds and clamps as Swift's (a value that is no number falls back) and keeps the first 64 graphemes of the characters that are no line break (Swift's `Character.isNewline`: LF, CR, CRLF, VT, FF, NEL, U+2028, U+2029). `apply` converts the image to premultiplied RGBA first. Chunky pixels are each block's area averaged exactly, a block past the image's edge counting its missing pixels as clear, where CoreGraphics draws the image at its high quality into a fractional rectangle (known difference: a box average, not CoreGraphics' filter); the small image is dithered and blown back up pixel for pixel, and a dot shape runs `dither_dots` with the dark colour or black for its gaps. `glyphs` draws each distinct grapheme of the characters into a gray map of the cell's size, white on black, centred on its ink (`QPainterPath::boundingRect`, Swift's `CTLineGetImageBounds`), in fontconfig's bold monospace at a whole pixel size of 1.15 cells (known difference: Swift's bold SF Mono at the exact size), filled by the winding rule so overlapping outlines (a letter and its combining accent) stay inked, as text rendering fills them (Qt's `addText` paths are odd-even), sorted by coverage, stably. A kernel that finds no memory is `ExportError(render)`, as is a source whose conversion finds none (`FilterFailureTests`); a glyph map (4 KB at most) cannot be starved alone under the address-space harness, so its refusal is untested, as is the glyphs' order when two coverages tie (no two characters are known to ink alike in every font). Equivalent mutant: the conversion's own null check (the small or working context of a null source refuses the same way). Making the small image gives the same pixels without it (averaged into the top left of a full-size copy whose clear rest the kernel skips), but its kernel planes would then be full size: `FilterFailureTests` dithers chunky 4000-pixel Original colours within the limit the unchunky case exceeds. Swift's `Set<Character>` treats canonically equivalent graphemes as one: the glyphs' seen set compares their NFC forms, and draws the first. Leaving the Characters field (`editingFinished`) shows what the settings kept of the typing. `FilterKind::dither` sits after Bloom / Glow, as Swift's menu has it; `FilterSettings.dither` normalizes with the rest; `PixelFilter::run` applies it at the job's own pixels; its previews are made at full size, as noise's and grain's are. The picker's `ColorPickerTarget` gains the `dither` kind and a `light` flag (Swift's `.dither(light:)`, titled "Color Picker (Dither Dark Color)" or "Light"), with `openDitherColorPicker`, `previewDitherColor` and `setDitherColor`; Cancel and OK of the filter close it, as they close the Gradient Map's and the Vignette's (`filterColour`). The sheet's `dither` (`UI/FilterSheet+Dither.cpp`) is Swift's `ditherControls`: Style as a menu with separators between its groups, Pixel Size, Cell Size (halftone and ASCII), Angle (halftone), Characters (ASCII: a monospace field that writes as typed and shows the settings while unfocused), Tones (diffusion and ordered), Diffusion (diffusion), Density, Contrast, Colors as a menu, the Dark and Light swatches (Two Colors), Pixel Shape (chunky pixels) and Light on Dark (marks), each with Swift's help; menus and swatches follow the settings through `m_follows`. The busy line now shows only for a commit or an automatic kind's preparation, Swift's rule against a quick preview's flicker. `e70da06` needs nothing: `main` shows the window before it hands the files named at launch to the workspace. `29f0bea`'s guide sentence joins `writing-comp-files.md`. Swift has no Dither tests: `DitherTests` (styles, groups and defaults, normalization, Bayer's exact pattern and tones, diffusion, two colours and the original ones, alpha, chunky blocks and their cut edges, dots, marks either way, glyphs) `DitherKernelTests` (each diffusion, Bayer matrix, halftone shape and Mac pattern by hand-worked pixels, Original and Two Colors marks either way, a premultiplied block average, colour-byte rounding, Original dots' black gaps and smoothed rims, each range's clamp, rounding and fallback, a lone CR, a straight-alpha source premultiplied first, a non-default cell size on the screen and in ASCII, an accent's overlap inked as text is) and `DitherSheetTests` (the kind's place and full-size preview, the commit, an ASCII commit on the worker, the two pickers and their refusals, the controls by style, the sLine truncated
- Dither's ASCII as text (13.3a, Swift 1.3.4: `2309a85`): `kernels/DitherPixels.c/.h` are v1.3.4's, whose glyph cells take `glyphWidth` and `glyphHeight`. `DitherSettings` gains `textSize` (6–64, 14, rounded); ASCII draws at full resolution, chunky pixels and their shape left alone. A glyph's cell is one bold monospace M wide (its advance, rounded) and `textSize` tall; the face is `lround(lineHeight / 1.2)` whole pixels where CoreText takes the fraction (known difference, as before); every letter stands on one baseline, centred in the line as Swift's AppKit metrics set it (`(height − (ascent + descent)) / 2 + descent` from the bottom, rounded), and is centred on its advance. The sheet shows Text Size for ASCII ("The height of each line of characters") and hides Pixel Size, Cell Size and Pixel Shape there. Equivalent mutants: the horizontal centring (a monospace advance is the cell, so the offset rounds to 0; only a double-width fallback glyph would move, and the dev image has none) and the kernel's cell rows counted by the width (a cell is narrower than tall, so only rows past the picture are added, never read). Tests: the glyph geometry and coverage against references drawn here at line heights 20 and 64 in `DitherTests`, the tiles, the picked table over dark and light columns and every cell's pick from its whole cell's ink (edge cells included, predicted here) in `DitherKernelTests`, the rows, ends, steps, field and unit in `DitherSheetTests`.
- Select › Color Range (13.3b, Swift 1.3.4: `c3e360a`): `kernels/WandPixels.c/.h` are v1.3.4's, with `color_range_mask`. `Document/ColorRangeSelection.h/.cpp` twin Swift's file, `ColorRangeSelection+Session.h` its session half; `ColorRangeEdit` is a class in the session's `std::optional`, its `id` standing for Swift's object identity, its `image` the live composite at document size (`selectionSample` with every layer, premultiplied), so the kernel reads it directly where Swift draws it into a context first. `colorAt` averages the 3 × 3 pixels round the floored point, those past the picture clear as in Swift's context, and unpremultiplies with Swift's rounding; a point off the picture, clear or no number samples nothing. Each update is a worker of its own (`QtConcurrent::run` and a watcher a change, as Swift starts a task each) and lands only on the same edit, generation and document; it says so at once and again when its match lands, which sets the selection without a step (antialiased as the selection tools are) or, on a `MagicWandError` or `ExportError`, keeps the selection, shows the error and logs it on `omaphoto.app`. The preview is the mask as `Grayscale8` scaled smooth to twice the panel's fit, where Swift draws it at medium quality. OK applies the match as one `Color Range` step through `setSelection` (or `deselect` for a match of nothing) and always announces the close; with no colour or an error it keeps the old selection without a step; Cancel puts the old selection back. The gates (`canEditLayers`, `canUseHistory`, `canStartProjectOperation`) wait for the panel, and closing it resumes file requests (Swift's `didSet`; on opening it is a no-op, since an open panel blocks every waiter, and is left out). The sheet's writes (`setColorRangeSampleMode`, `setColorRangeHeld`, `setColorRangeFuzziness` clamped whole to 0–200, `setColorRangeInvert`) notify, an unchanged one silent. On the canvas Color Range is `picking`; a left press, Space aside, samples with Shift adding and Alt taking away (Alt first, as Swift's switch reads them) and refocuses the panel, after Levels' checks; the eyedropper cursor carries a black + or − on a white disc bottom right (`eyedropperCursor(ratio, badge)`) for the held key or the chosen eyedropper, and `syncPicking` redraws it when either changes; Shift and Alt reach `setColorRangeHeld` through the modifier monitor. Swift's `keepColorRangeCursor`, `pointerOverCanvas` and cursor-update monitor work round AppKit's cursor rects and have no twin. `UI/ColorRangeSheet` twins Swift's sheet: the three eyedroppers (`UI/SampleButton`, moved unchanged from the Hue/Saturation sheet so both share it), lit for the effective mode; the preview (black, the picture, a 20% white rim, the canvas's shape within 292 by 200); the caption in `PlaceholderText` (Alt for Option); Fuzziness as a scrubbable title, a slider and a 48-point field; Invert; the error in `BrightText`; Cancel and the default OK; 340 wide, 24 of padding. `ContentView` shows it in `colorRangePanel` while an edit is open, its close button and Escape cancelling, and the Select menu's Color Range… follows Subject, enabled by `canSelectColorRange`. Swift has no tests for this commit: `ColorRangeTests` pins the kernel, the sampling, the modes, Fuzziness, Invert, the preview, OK, Cancel, the gates, the announcements and an outline too detailed (a 2100-pixel checkerboard); `ColorRangeCanvasTests` the canvas's clicks and keys, the badged cursors, the sheet and the panel with its menu entry; `ColorRangeSheetTests` the sheet's layout, field, preview, Invert and error. Equivalent mutants: the no-colours branch's reset of the selection and preview (before any colour both hold those values already), the watcher's `deleteLater` (memory alone), the warning and `syncPicking`'s memory of the shown badge (it only spares cursor updates). Swift's `document` and `colorRange == nil` terms in `canSelectColorRange` are dead (`canEditLayers` reads both) and left out. Untested: the preview's two allocation checks and the worker's `ExportError` catch (only memory starvation reaches them), and the sheet's `releaseFocus` (without it the commit reads members that happen to live; no sanitizer runs).
- Dialog colours (13.4a, Swift 1.3.5: `c580a38`): Export JPEG's background for transparency and Canvas Size's custom extension open the app's picker, where they opened the desktop's `QColorDialog`. `ColorPickerTarget` gains `dialog` with its `dialogTitle` (`Color Picker (Export Background)` since 14.18); the change Swift keeps in the session as `dialogColorChange` rides in `ColorPickerState::dialogChange`, so it goes with its picker. `openDialogColorPicker` refuses while a picker is open; `previewDialogColor` hands the dialog the working colour and changes nothing of the session's, so it is silent, as Swift's `@ObservationIgnored` closure; `closeColorPicker` tells it the chosen colour or the original. `pickingForDialog` keeps the canvas from picking, and the picker's sheet leaves out "Click the canvas to sample" for a dialog. `DialogColorSwatch` lives in `UI/ColorPickerSheet+Dialog.h/.cpp`, where Swift keeps it in `ColorPickerSheet.swift`: it derives from `SwatchButton`, whose header includes the picker's. It is drawn as the brush's swatch (34 by 18, radius 4, a white ring, a black rim), named by its title, opens the picker on a click, hands it each new working colour on `changed()` (Swift's `onChange`, the last colour remembered), and closes it, keeping the colour, when hidden (Swift's `onDisappear`: Canvas Size's swatch hides when the extension leaves Custom). The sheets close it before Cancel, OK and Export, OK first so an open picker's colour is the fill, and in their destructors, before their members go, since the swatch's change writes into them. Both sheets take the session; Canvas Size reads the palette from it. A window-modal sheet blocks its window's other windows, the picker's panel among them, where a macOS sheet leaves the app's panels free: `FloatingPanel::show` parents a panel to `QApplication::activeModalWidget()` while one is up, else to its owner's window, moving a panel made earlier across; a panel under a sheet dies with it and the next is the window's again. Judged: `908bed9` (Qt delivers `applicationStateChanged` on the GUI thread, so `RecentProjects` hops nowhere) and `158cd53` (`ProjectDigest` already hashes a local's bytes). Tests: `DialogColorPickerTests` (the session, the swatch, the panel over a modal sheet, the canvas), the sheets' swatches in `ExportAsSheetTests` and `CanvasSizeSheetTests`, the panel over the real Canvas Size sheet in `ProjectResizeTests`. Equivalent mutants: the swatch's memory of the picker's colour, kept or not (a sheet ignores a colour it holds).
- Export JPEG's zoomable preview (13.4b, Swift 1.3.5: `ac6f309`): the exporter decodes the preview at full size, 8192 a side at most, so 100% shows the file's own pixels. `JPEGPreview` (declared in `UI/ExportAsSheet.h`, defined in `ExportAsSheet+Preview.cpp`, as Swift keeps it beside the sheet) is a `QAbstractScrollArea` of Swift's 560 by 330 frame: Fit, or a zoom from Swift's steps (25% to 800%, `step` a thousandth either side), 1 being one image pixel a device pixel (`shownSize` divides the export's size by the device pixel ratio, as Swift by `displayScale`, so a preview decoded smaller still spans it); an image smaller than the view sits in its middle; a new zoom keeps the view's middle on the same part of the image, or starts at the image's middle from Fit (Swift's `keepCentered`, whose `before.width > 0` guard is dead and left out); a left drag moves it (the open hand, closed while dragging, the arrow in Fit); the wheel scrolls; a left double click switches Fit and 100%; from 100% up it draws nearest, below 100% through an area-averaged copy kept per size (`QImage::scaled`, Swift's high quality; QPainter's bilinear filter aliases a big reduction), dropped only for a new image. Without mouse tracking Qt delivers no buttonless move, so a lost release cannot be seen, and a new press restarts the drag. The zoom reaches the sheet through a callback given at construction. The sheet's title row carries Fit (resting in Fit) and the two magnifiers (`LayerIcons`' `plusMagnifyingGlass` and `minusMagnifyingGlass`, redrawn on `PaletteChange`), each resting at its end, with Swift's help naming the key and the zoom; the title row sits 8 above the preview (Swift's `padding(.bottom, -8)`); the size (since 14.18 the format's note), the result and the buttons share the bottom row, 12 apart. Swift's `previewZoom` hook routes the View menu's Fit Canvas, Actual Pixels, Zoom In and Zoom Out to the preview; a window-modal sheet blocks the window's menus, so the sheet binds those four keys itself as `QShortcut`s, as remapped (`ShortcutSettings::menu`), again on every remap, and no session hook is needed. The import error's second OK has no twin: the alert already holds one button. Tests: `JPEGPreviewTests` (each drawing against `QImage::scaled` references), `ExportAsSheetTests`, `JPEGExportTests`' limit, the keys through the real menus in `ProjectExportTests`, device pixels in `HighDpiCanvasTests`. Equivalent mutants: the nearest branch from above 1 rather than from 1 (at 100% the averaged copy is the image itself), and dropping the averaged copy for an image already held (it only costs a new average).
- Snapping while drawing (13.5a, Swift 1.3.5: `d6e3e93`, `8b1369a`): `snappedPoint` and `snappedSelectionOffset` live in `Crop.cpp`, Swift's file. A drawn point moves each axis to the nearest crop target within reach (the first of the nearest, reach inclusive) and shows the lines met; a moved selection snaps its rounded box's edges or middle through `TransformSnap::offset`, an axis Shift holds left out; with snapping off, or no move begun, the input comes back and the lines go. The canvas's `snappedCorner` snaps a Marquee's start (a Lasso's never), a Marquee's dragged corner, a shape's start and dragged corner, ten view points' reach, and the selection drag; Ctrl, Swift's Control as the crop and the Move tool read it, draws and moves freely and clears the lines. Every release clears them, as Swift's `mouseUp`. Tests: Swift's `drawnPointsSnapToTheSnapToTargets` in `GuideTests`; a moved selection's offsets (rounding, a locked axis, a turned ellipse's own box) and the gestures at 100% and 200% in `SnapDrawingCanvasTests`.
- Grid Settings (13.5b, Swift 1.3.5: `1c819d0`, `db5eafa`): `LayoutGrid` is Swift's struct: spacing 2–4096 and subdivisions 1–64, never finer than a pixel, clamped as it is made, plain members where Swift has `let`, so it stays assignable (as `CanvasSizeDraft`); its lines are counted from the origin, so an uneven step stays on the majors. `GridAppearance` holds Photoshop's presets by Swift's raw values (`allGridPresets`, `rawValue`, `presetColor`, none for Custom), the styles (`allGridStyles`, `dashes` in screen points), Custom's colour and the majors' opacity (1% at least when drawn; subdivisions at 28/45 of it). Both are the person's: `ToolDefaults` gains `integer`, `text` (empty when nothing is stored) and their `set`s, and the session reads `LayoutGrid::stored()` and `GridAppearance::stored()` as its members are made, under Swift's keys (`gridSpacing`, `gridSubdivisions`, `gridColor`, `gridCustomColor` as hex, `gridStyle`, `gridOpacity`); a stored value that cannot be read is warned of on `omaphoto.app` and left at its default, where Swift falls back in silence. `setLayoutGrid` and `setGridAppearance` store and notify, silent when unchanged. Snap To Grid, the guide snapping and the overlay read the session's grid; the overlay draws the majors in the chosen colour, opacity and dashes (counted in pen widths for Qt) and the dotted subdivisions in that colour, and `GuidesShown` carries both, so a change repaints. `ProjectController::gridSettings` lives in `ProjectController+Resize.cpp` beside the other sheets, since the controller's own file would pass 500 lines: refused without a window or while a modal sheet is up (Swift's `attachedSheet`), it shows the grid, opens `GridSettingsSheet` in the window-modal `sheet` titled Grid, previews every change through the session, and on its answer puts back the grid's visibility and either keeps the settings or restores them; nothing is undone or saved, and the project is not busy. `UI/GridSettingsSheet` twins Swift's: the title, Color (a preset menu and the `DialogColorSwatch` "Grid Color"), Style, Opacity (a half-point scrub, a slider and a 48-point field with "%", stepped by arrows, clamped), a rule, "Gridline every" with "pixels" (a point's scrub) and Subdivisions (a fifth of a point's), the note (the step to two places, grouped as Swift's `formatted()`, or the limits in `BrightText`), Cancel, Restore Defaults (Custom's colour kept) and the default OK, resting while invalid; 360 wide, 24 in, 18 apart. A field takes any whole number, as Swift's binding, so OK rests until the grid is valid, and previews only valid grids. A picked colour becomes Custom's, remembering the preset it left so picking that colour again returns to it; a preset chosen from the menu ends that. A fix beyond Swift: the picker reports 8-bit colours, so Swift's comparisons miss presets that are no byte (Light Gray's 0.7): opening the picker on Light Gray turns the grid Custom at once. The twin compares both sides in the picker's 8 bits, since its Cancel hands back the exact original, so opening and Cancel both keep a preset. Left out as dead: Swift's reset of the pick in its return branch (returning to a preset ends the pick through the preset's own change, in Swift too) and `ToolDefaults::text`'s fallback (Swift's callers pass ""). Untested: the destructor's `releaseFocus` and picker close (without them the swatch's hide writes into members already gone; no sanitizer runs). Tests: `GuideGridTests` (Swift's four grid tests, the settings kept), `GridSettingsSheetTests`, the drawing in `GuideCanvasTests`, the flow in `ProjectResizeTests`, the entry in `ViewMenuTests` and `CompositorMenusTests`.
- Typing's undo and font previews (13.6, Swift 1.3.5: `ee68ba9`, `1bd1df4`). Swift's first commit lets the text view answer Undo and Redo itself, since the window's history took them; the twin's editor has answered them since 8.9b (the keys win `ShortcutOverride`, the menus reach the focused canvas's editor), so only Swift's case is added as a test: typing in a new box and Ctrl+Z through the real menus clears it in one step. The session's `previewFont`, `keepFontPreview` and `endFontPreview` twin Swift's: open text only, each face tried from the style as it was before the first (`m_fontPreviewOriginal`, Swift's `@ObservationIgnored` member) on the draft's selection, refused when invalid or unchanged, kept on a choice, put back on a close without one. `TypeFontPicker` draws each face's name in that face through `Qt::FontRole`, which the combo's menu delegate honours, at the menu's pixel size; a face that cannot draw the letters of its own name (`QRawFont`, the face itself and not fontconfig's fallbacks) keeps the menu's font, as Swift's symbol fonts do. Which faces can is worked out once on a worker when the first picker is made (Swift's `prepareStyledNames`), joined when the menu loads, a name still missing worked out then. The picker takes Swift's preview closure at construction: a highlighted face shows (the `(Multiple)` row does not), and every close posts the revert to the event loop, since Qt reports a choice after `hidePopup`; a choice keeps the preview first, which empties the original, so the revert finds nothing. Swift's `chose` flag does the same work twice and is left out as dead. Equivalent mutants: each preview built from the draft rather than the original (the selection holds still while the menu is open), `previewFont`'s validity term, Swift's too (`setFont` keeps a valid style valid), the worker's start-once guard and its join when the menu loads (without either the same answers are worked out again, costing only time), and the letters-only test of a face's name (every installed name in the dev image is letters and hyphens DejaVu covers); untested in the dev image: a face that cannot draw its own name (only DejaVu is installed). Tests: `TypeFontPreviewTests`, the Ctrl+Z case in `InlineTextDrawTests`.
- New Canvas presets and the Quick Look preview (13.7, Swift 1.3.6–1.3.7: `aaf3dc0`, `14aad18`, `4868465`): `NewCanvasSheet` twins `aaf3dc0`: the subtitle goes; beside "New canvas" a 28-point `QToolButton` (`presetSizes`, an instant popup) draws Swift's three 2.5-point dots in `WindowText`, flush with the fields' right edge, its own `paintEvent` drawing no menu arrow; Swift's tooltip and the accessible name "Preset sizes". `CanvasPreset` (in `NewCanvasSheet.h`, as Swift's file holds it) keeps Swift's `groups` and `all`; the menu is Custom, then each group after a separator, checkable `QAction`s in an exclusive `QActionGroup`, each holding its index in `all` (Custom −1). `checkPreset` checks the first preset whose pixels the fields make (since 14.3; the fields' exact text until then), else Custom, on every edit of either field, as Swift's binding reads it; choosing a preset fills both fields, in the unit in use; Custom fills nothing and checks the match again, since Qt checks a triggered action where Swift's picker sets nil and re-reads. Known difference: Swift's label is clickable ten points past the dots into the padding; the twin's button ends at the content's edge. The Quick Look preview twins `14aad18` and `4868465`: `ImageExporter::quickLookImages` returns nothing past 50 megapixels (logged as info), else renders the snapshot and `scaledJPEG`s it: the long side at most 1024, sides rounded, a pixel at least, `QImage::scaled` with `SmoothTransformation` (area averaging when shrinking) for CoreGraphics' high quality, drawn on white into `RGB888`, JPEG quality 80, 72 dpi as `CGImageDestination` writes; a render or scale that fails logs a warning on `omaphoto.io` and saves without one, Swift's `try?`. `QuickLookImages` (Swift's, in `ImageExporter.swift`) is declared in `ProjectStore.h`, since the exporter includes the store. `ProjectStore::save` takes it optionally: with one, the staged folder gains `QuickLook/Preview.jpg`, written and synced with its folder before the project's folder is synced and swapped in; without one the swapped-in project has none. Loading reads only the manifest and `images/`, so the folder is ignored, and `ProjectDigest` reads neither, so writing or deleting it is no external change (a test rewrites and deletes it under the digest). The controller's save worker makes the preview and hands it to the store. `docs/writing-comp-files.md` gains Swift's line, OmaPhoto's name. Equivalent mutant: the heading's stretch (the title label takes the room). Untested: the staged `QuickLook` folder's `mkdir` failure (a fresh staged folder; the write after it would fail with its own message), and the null checks after scaling and allocating the flattened image (an allocation failure); without them a null scale would save a white preview. Tests: `NewCanvasSheetTests` (the dots filled and never outlined, `presetsAreSwiftsGroups`, `aPresetFillsTheFieldsAndTheMatchIsChecked`, `theMoreButtonDrawsThreeDotsAndOpensTheMenu`), `QuickLookPreviewTests` (the store's folder and its absence, load ignoring it, the digest unchanged when the folder is rewritten or deleted, the JPEG byte for byte against smooth scaling on white at quality 80, sizes and rounding, the 50-megapixel bound, a failed render, a controller save), and the sync order in `ProjectReplaceTests`.
- Tabs reorder and overflow (13.14, Swift `1faf7a0`): `UI/ProjectTabLayout.h/.cpp` twin Swift's file: `projectTabSpacing`, `ProjectTabSlot`, `ProjectTabPillSlot`, `ProjectTabOverflow` (`contentWidth()` for Swift's computed property), `projectTabOverflowLabel` and `projectTabOverflow`, widths a `std::map` read through `at` (every tab has one), the pill's width a `std::function`. Left out as dead: `span`'s empty guard (never asked of no tabs) and `contentWidth`'s pill fallback (a pill always has a tab after it). `ProjectWorkspace::moveTab` is Swift's, clamped, and emits `changed()` only when a tab moved. `ProjectTabStrip` is now a plain `QWidget` that takes the toolbar's free width (the spacer after it went; its size hint is every tab in a row, its minimum none) and places its children by hand from `projectTabOverflow` over its own width: the overflow pill at x 0 and the visible tabs at y 3, 28 high, each as wide as its button's size hint (so the unsaved dot's extra width counts, as before), hidden tabs hidden; it lays out again on `resizeEvent`, on the workspace's signal and on each tab's session signal (a title or a dot changes widths; a hidden button posts no layout request). The QScrollArea, its offset and `showFront` are gone, as Swift's scroller and reveal are; the selected tab is always shown by the pinning. `OverflowTabsPill` (a `QToolButton`, `projectTabsOverflow`) paints an inactive tab's capsule, the label "N more tabs" at 12 pixels medium and a drawn `chevron.down`, the label and chevron at half opacity while the workspace cannot switch (the capsule stays, as Swift's `.opacity` sits before its background); tooltip and accessible name are the label; its width is Swift's `projectTabOverflowPillWidth` (the label's ceiling plus 11, 4, 10, 11). Its press opens a `QMenu` (`projectTabsOverflowMenu`, deleted on close) four points below it, as `popUp(positioning:)` does, listing the hidden tabs in order, "• " before an unsaved one, a choice selecting; while the workspace cannot switch no menu opens. The reorder twins Swift's: `ProjectTabButton` watches its title button's left press, move and release in global coordinates (Swift's `.global` space) and reports `TabDragPhase::changed` once the press has moved three points and `ended` on the release; the strip's `Reorder` is `TabReorderState` (others, widths, compacted x, start, origin, translation, target), `nearestSlot`, `renderX`, `makeReorder` and `commitReorder` as Swift's (a neighbour closed mid-drag has no place, as Swift's `if let`s). A drag begins only while the workspace can switch and across at least three points, selects its tab first and raises it over the others (Swift's `zIndex`); it commits on release only if the workspace can still switch; the others ease to their places through a 150 ms `QPropertyAnimation` of `pos` (`OutQuad` for SwiftUI's `easeOut`), the dragged tab following the pointer at once, and every tab eases home when it ends. Qt can lose a release (a modal alert opened mid-drag, such as the Revert question `select` may raise, blocks the strip's window): Swift's `dragTimer` is `tabReleaseWatch`, a 100 ms `QTimer` running while a drag lasts, which ends the drag uncommitted once `QGuiApplication::mouseButtons()` holds no button, so the tabs ease home; a drag whose tab closes is dropped on the workspace's signal too. Swift's `guard reorder?.id == id` stays. Known difference: Swift's empty title bar beside the tabs drags the window (`TitleBarDragView`); Qt's toolbar is no title bar, so the strip's empty part does nothing. `NewTabDropSlot` has no twin, as before. Equivalent mutant: reporting `ended` on every release, a press that never moved three points included (with no drag begun, `ended` only lays the strip out again; Swift's `onEnded` fires once the gesture moved three points, begun or not, as the twin's does). Tests: `ProjectTabLayoutTests` (Swift's six, the bounds at exactly fitting and a point below, the pill's spacing included, a lone wide pinned tab, the pill's width asked by count), `ProjectWorkspaceTests::moveTabReordersWithoutTouchingSelectionOrDocuments` (Swift's, plus its signal), `ProjectTabDragTests` (following the pointer, clamping, the gap, the slide's duration, curve and end, stacking, landing left and right and in place, a small or upward move as a click, a busy project at the press and at the drop, a tab closed mid-drag, a release lost under a modal alert ending the drag while a held one lasts past the timer, and another tab pressed before the timer looks moving nothing (the drags go through the window, so the application tracks the buttons), a three-point move and a move under another button, the overflow pill's label, place and width, the menu's entries, place and choice, pinning, the busy pill), and `ProjectWorkspaceViewTests` (overflow in the window; the strip ends before the zoom buttons). `ProjectTabStripTests::onlyANewFrontTabScrollsIntoView` went with the scrolling.
- Ungroup Layers and the mask pointer (13.15, Swift `1318f1e`, `6c3b9a5`, `6955a6f`): `EditorSession::canUngroupLayers` and `ungroupLayers` sit in `LayerGroups.cpp` (declared in `LayerGroups+Session.h`), as Swift's extension: the folder's direct children, their parent now the folder's own, take its place in the list in their order; `releaseDetachedClipping` then frees clips that no longer sit on their base; `finishOpacityEdit` ends a drag first; one `Ungroup Layers` step selects the children with the lowest active (none for an empty folder) and forgets the folder's collapse. Swift's `LayerHierarchy.validate` guard is dead and left out: unwrapping a folder only shortens parent chains, so nothing it refuses can appear; its `document` guard is `canEditLayers`'. The Layer menu gains Ungroup Layers (Ctrl+Shift+G) after Group Selected Layers, enabled by `canUngroupLayers`, and `ShortcutDefinition::all` Swift's "Ungroup Layers" entry (g, Cmd+Shift), so it can be remapped; the list's context menu shows it after Group Selected Layers for a right-clicked folder alone (Swift reads the clicked row, not the active layer), enabled by `canUngroupLayers`. `NativeLayerList::showMaskCursor` twins Swift's `showMaskCursor`: the duplicate pointer drawn over an eye in a 7.5 by 5.5 box at the hot spot plus (15, 18), haloed by sixteen white copies one point out, the image widened to hold it (29 by 32 at 1×), the hot spot the pointer's; Swift's `eye.fill` is drawn as paths (an almond of two quadratic curves, a ring cut round a filled pupil). Over a mask thumbnail with Alt, `cursorFor` shows it whatever `canEditLayers` says, as Swift's now does (13.12 brings the Alt-click that shows the mask alone). Equivalent mutants: the eye's black fill drawn after the arrows (it never meets an arrow pixel; the halo's order is pinned), and the context entry enabled by `canEditLayers` (the right-click makes the folder active, so `canUngroupLayers` says the same). Tests: `GroupSessionTests` (Swift's three ungroup tests; collapse forgotten, signals, a plain layer refused silently, a nested and an empty folder, an opacity drag ended, busy), `LayerContextMenuTests::aFoldersMenuOffersUngroup`, the menu entry and its gate in `CompositorMenusTests`, the shortcut count, Swift's chord and a remap of the entry in `KeyboardShortcutsTests`, the eye pointer's pixels, hot spot and the arrows over the eye in `LayerDragGestureTests`.
- Compositor 1.4, part one (13.8: `8c0417b`, `fe7a83d`, `af3b568`, `3ccc125`, `d0135c8`, `dcf1c66`). A sharper canvas: `LayerRenderer::interpolation` takes Swift's `upright` (a layer whose `radians()` is 0, drawn within a thousandth of pixel for pixel, takes no filter), from `LayerRenderer::draw` and `TiledLayerRenderer`'s frames alike; since 14.4 `LayerRenderer::upright` also counts a quarter turn whose corners land on whole device pixels (14.4's bullet). The twin's adjustment surfaces already held device pixels (9.5b) and its adjustments already ran at the surface's scale (12.8c), so 1.4's surface and `adjustmentScale` changes were held; what was missing is ported: `AdjustmentSurface::draw` falls back to a pixel a painter unit when device pixels pass the budget (Swift's "falls back to points"), drawn back smoothly (Swift's `.high`), and nothing only when that passes too; `LayerAdjustment::apply` counts the noise origin per axis (`top × height / max(1, region height)`), where the twin used the width's scale for both. PSD (`IO/PSD`): Levels gamma in hundredths; `hue2` read as Swift's: 16 bytes at least, Colorize's own values or the Master's, then for each of the six ranges its band (degrees wrapped into 0–360) and values, 14 bytes each, until the data runs out; the reader keeps each mask's bounds and default byte (`PSDRecord::maskBounds`, `maskDefault`), and `PSDDocumentBuilder::maskOnLayerGrid` draws the stored patch, unfiltered, onto the layer's grid (its pixels, or the canvas for a layer without) at its place, the default round it; already the grid, or with no size, the patch is kept; a grid that cannot be allocated logs on `omaphoto.io` and takes the builder's existing "couldn't be converted" note, as Swift's nil does. `PSDAdjustments::levels` and `hue` are public, as Swift made them for its tests. Photoshop's Saturation: `HueSaturationFilter::adjustedSaturation`, Swift's to the letter. All cores: `kernels/LevelsPixels.c/.h` are Swift's file again (`cube_apply`); `BrushRaster::copy` (bytewise for the context's own format, drawn otherwise) and `BrushRaster::inBands` (one band under 250,000 pixels, else twice the ideal thread count, through `PoolMap`; a `bad_alloc` there is `ExportError(render)`, since the workers' callers catch that alone); `LevelsFilter::run` copies and runs `levels_apply` in bands; `HueSaturationFilter::run` copies and runs `cube_apply` in bands, where the twin had its own trilinear loop (the kernel's float order now: within a level of it, Swift's tests allow eight); `cube` keeps the last eight tables under a mutex, Swift's `cubes`. A blur's preview kept while the layer grows: `FilterEdit` gains `preparedTransform` and `pendingTransform`; a render carries the grown layer it was made from (`Filtered::placement`) and lands there, shown until the next replaces it; `displayedTransform` and `displayedMaskPlacement` read `preparedTransform`. Left out as dead in the twin: Swift's `current` terms (only a blur grows its grid, and only the automatic kinds, which never grow, refuse a stale preview or read `preparedSettings`), so the grid's version (`previewSourceVersion`, `Filtered::sourceVersion`) goes with nothing left to read it, and so does the twin's own remaking of a waiting job on growth (with Preview off nothing lands; with it on, the update makes a new job). The hierarchy kept: `LayerOrder` (`Document/LayerGroups.h`, its code in `LayerGroups+Order.cpp` with the document's hierarchy members, since `LayerGroups.cpp` would pass 500 lines): `Node`, `Result` (`order`, `visible`, `drawn`) and `resolve`, which keeps the last nodes and answer under a mutex; `CanvasDocument::hierarchy()` replaces `hierarchyEntries()`; `effectiveVisibleIDs`, `renderLayers` (a checked `std::map` index, after Swift's empty-order guard) and `effectiveOpacities` (a map of opacity and folder, no whole layers) read it; `groupSelectedLayers` reads `hierarchy().order`. `dcf1c66` was held: the canvas's `DisplayState` already works out the visible set once. Tests: `SharpCanvasTests` (the rule's edges; plain, masked and tiled layers crisp within the tolerance, filtered past it and when turned 2° at 1:1), `PSDAdjustmentTests` (Swift's three, plus the wrap, Colorize leaving the ranges, a cut record, the builder, a pixel layer's grid, a stretched layer, the patch kept, bounds or a layer of no height or width, the grid's bounds with a smaller patch, a patch the grid's size a pixel along), `PSDReaderTests` (the new layouts, the mask's bounds and default read and placed), `HueSaturationTests::positiveSaturationMatchesPhotoshop` (Swift's values, the clamps, a pixel), `AdjustmentBandsTests` (bands cover each pixel once, the copy's bytes, Levels and Hue/Saturation in bands against one pass of the kernels, cached tables), `HueSaturationFailureTests` (the filter now fails at its copy, the row list being gone), `FilterTests::growingABlurKeepsThePreviewUpUntilTheNextOne` (Swift's) and `aMaskKeepsItsPlaceUnderAPreview` (a covering maLine truncated
- Blending (13.9a: `6dc2219`, `8ab32c9`, `5a8f6ce`, `e6c964b`). Held, proven: clipping stacks and live-mask coverage at device resolution (the twin's `LiveMaskRenderer` has no `bounds`: its group, alpha and coverage surfaces are the painter's device since 4.x, so Swift's `resolution` and `pixelSize` have no twin); an adjustment in any mode, over the document and inside a stack, blended at full coverage, its alpha restored, then mixed through mask and opacity (`adjust`'s `blended` goes through `LayerRenderer::composite`, which hand-blends every mode QPainter lacks through `HslBlend`, so Swift's `SeparableBlend.blend` has no twin); a clipping stack composited in its base's mode in every mode (`drawComposite` hands `m_stackModes` to `LayerRenderer::composite`, the same path), on the canvas, in export and in Copy Merged alike. Ported: Soft Light by Photoshop's formula, as Core Image computes it (2bs + b²(1 − 2s) up to half, 2b(1 − s) + √b(2s − 1) above), in `SeparableBlend::channel`, and `needsSurface` names Soft Light, so `HslBlend` blends it by hand everywhere a layer, a stack or an adjustment is drawn; `LayerRenderer::compositionMode` loses its Soft Light case (dead now). Compared first: Qt's `CompositionMode_SoftLight` is the PDF's (W3C) formula, which equals Photoshop's below a source of half and above it wherever the backdrop passes 0.25; under that it takes the PDF's cubic for √b and runs up to 17 levels darker (backdrop 5/255, source 1: 19 where Photoshop gives 36). Swift's 0.9 over 0.5 gives 170 under either formula. Known difference: Swift's Core Image is "within 5 levels of Photoshop's"; the twin is Photoshop's own formula, so it may differ from Swift's by those few levels. Tests: `BlendingTests` (Swift's `clippingStacksBlendInTheirBasesMode` for Linear Dodge, Color Dodge and Multiply: Swift's numbers, Copy Merged equal to export, the canvas within a level; adjustments in Difference and Linear Light over the document and Vivid Light and Subtract inside a stack, by the numbers, at full and half opacity, in all three places; Swift's `softLightMatchesPhotoshop` plus a dark backdrop and the lower branch; Swift's `softEdgedLayersBlendTheSame` for Soft Light, Overlay, Multiply and Color Dodge: a radial soft layer over noise, Copy Merged equal to export, the canvas within a level), `BlendModeTests::everyBlendModeMatchesThePdfFormulas` (its Soft Light reference is Photoshop's now; its backdrop of 0.1 under sources past half catches the PDF's cubic (at 0.25 the cubic equals √0.25)), `ClippingStackTests::stacksAndCoverageTakeTheDevicesPixels` (a half-unit stripe stays sharp at a device scale of two, in a stack and through coverage from a source further down). Equivalent mutant: Soft Light's `source <= 0.5` for `< 0.5` (both branches give the backdrop at a half). Judgments: stacks and coverage at device resolution, adjustments in any mode and a stack in its base's mode were held (proven by the tests above); Soft Light ported. Mutants: 5 on Soft Light, 4 caught, one equivalent (above).
- The Metal canvas (13.9b: `cdf8674`, `4b04323`, `5708ed3`, `93de9e7`, `54dd707`, `91cb0b1`, `f773f7b`, `4c5e1a6`, `d04f159`, `b1b7490`). OmaPhoto stays a CPU canvas (the port's decision): Swift's GPU canvas, its `GPUCanvas`, `GPUNoise`, `MetalWarp` and `CanvasLinesOverlay` and the GPU branches of `EditorCanvas`, have no twin; the twin's canvas is the Core Graphics canvas those commits compare against. Ported: Grain and Add Noise anchored to the document (`54dd707`): `LiveMaskRenderer::adjustmentRegion` (Swift's `adjustmentRegion`) maps what the painter shows into the document, and the canvas's `drawLayers` sets it from its centre mapping and zoom, so the smooth path counts noise in frame pixels from the document's corner and Grain in document pixels, as export does, and the crisp path's surface maps to document pixels, so a partial repaint keeps one field; the blur's scale still comes from the unmapped region. This retires 9.5b's and 12.8c's known differences (Grain and noise following the view, a partial repaint's own field); `CanvasDisplayTests::grainFollowsTheViewOnTheCanvas` is gone. Export and `drawLiveComposite` already draw in document pixels and set no region, as Swift's. Tests: `AdjustmentCanvasTests` (Swift's `matchesEveryAdjustment`: every kind through a ramp mask at 80% over a photo and a translucent layer, the canvas within a level of the export over its checkerboard; Swift's `patternsStayWithTheDocument` for Add Noise and Grain: equal to export at 100% and 200%, panned and not, and at 80% a whole-pixel pan moving the pattern with the image and a partial repaint showing the whole's pattern within two levels of resampling), `CanvasEditContractTests` (Swift's `aNewMaskRevealsTheWholeLayer`, upright and turned: a one-pixel mask on a scaled layer changes no covered pixel, the canvas as the export; `matchesWhileMovingPixels`, moving and duplicating on a masked layer at 60% under a soft selection: the preview equals the commit and the export outside the ants, the hole only for a move; `matchesWhileDraggingAGradient` in a selection at 70%: the pending gradient equals its commit outside the overlays, nothing outside the selection; `5708ed3`'s failed fill cancelling with `brushError`, no step, the document unchanged; `matchesALiveMaskOutsideAClippingStack`: a turned source with a layer between, the canvas as the export), `CanvasContractFixtures.h` (the scene, the export over the canvas's checkerboard, a gap that skips the overlays' bands, the ramp mask). Judgments, each Swift contract held unless named above: `cdf8674`: rotation, flips, scale, sharp successive reductions, premultiplied blending in every mode with layer and ancestor opacity, the checkerboard anchored to the document, the crisp composite at high zoom, sampling modes and rounded-up reduction extents, premultiplied blending in sRGB, the surround, shadow and border, placed own masks and multiplied folder masks on pixel layers, filter, Levels and Hue/Saturation previews in the layer's place: held; the CPU paths are the reference and are pinned by `LayerRendererTests` (placement, sampling rules, halvings, overhang), `DownsampleTests`, `BlendModeTests`, `CanvasDisplayTests` (checkerboard, shadow and border, crisp from 200%, the rich scene against the export), `FolderOpacityTests`, `FolderMaskClipTests`, `MaskPlacementTests`, `LevelsCanvasTests`, `HueSaturationCanvasTests`, `VignetteCanvasTests`. `4b04323`: a moved or duplicated selection's preview and commit: held and now proven on a masked, translucent layer (above); the rounded offset is pinned by a move of 5.6 landing at 6 (`EffectsCanvasTests::movingPixelsKeepTheEffects`). Swift's laziness (`PixelMove.applyOffset`, `holed`, `liftedTarget`) only spares the GPU canvas a rebuild per move; the twin rebuilds eagerly in `movePixels`, which realises the tiles before display and commit by construction, and a failure there cancels the move with `brushError` (`PixelMoveTests::aPixelMoveHoldsTheSessionAndAFailedMoveCancels`). `5708ed3`, `91cb0b1`: live paint, erase and mask paint, paint past the old bounds under the old mask (`TiledStrokeTests`, `BrushPreviewTests`, `MaskPaintAnywhereTests`), Smudge and Liquify previews through the mask, opacity and placement (`SmearCanvasTests`, `FolderOpacityTests`): held. `GradientEdit.Fill` and `applyFill` are the GPU's laziness; the twin fills in `refreshGradient`, before display and commit by construction, a failure cancelling with `brushError` (now tested), and the commit publishes what was filled. `MetalWarpTests.matchesTheCPU`: the twin's `WarpStroke` is the CPU reference (`SmudgeLiquifyTests`); nothing to port. `93de9e7`: a one-pixel white mask revealing its layer: held (QPainter's smooth draw clamps an image's edge, as Core Graphics draws it; now tested); the live paint grid's extent and edge sampling: held (`TiledStrokeTests`, `DownsampleTests`, `TiledLayerTests`). `clampedToExtent` is GPU-only. `54dd707`: every adjustment kind with alpha, masks and opacLine truncated
- Shift on a pixel drag and Select All from the list (13.10): `CanvasView::moveSelectionTool` (`EditorCanvas+Selection.cpp`) twins Swift's `60bde4f`: with Shift held a Ctrl-drag of selected pixels zeroes the shorter axis of the offset, lengths compared (`std::abs`), a tie keeping across, as `dragSelection` already does for the outline; read from each move's own modifiers, as Swift's `event.modifierFlags`. `451281e`: Swift's table now hands Select All to the canvas (`session.selectAll()`, guarded on a document) instead of selecting every row, so the twin's `NativeLayerList::selectAllRows` (12.5b's round-3 fix) and the menu's `focusedList` branch are gone: a focused list falls through to `session().selectAll()`, which guards on the document itself. A rename's editor is a `QLineEdit`, so it keeps its own Select All first, as Swift's field editor. Tests: `SelectionCanvasTests::shiftKeepsMovedPixelsOnALine` (across, down, a tie, a long left beating a short down, without Shift, the release keeping the last offset), `SelectMenuTests::selectAllInAFieldIsTheFields` (a real Ctrl+A on the focused list selects the canvas, history "Select All", layers and the active one unchanged, the list keeping focus; Swift's `selectAllInTheLayersPanelSelectsTheCanvas`). Mutants: 5 on the Shift branch, all caught (the `abs`-less comparison caught after the left-beats-down step was added). No survivors.
- Why a brush cannot paint (13.11, `133c34a`): `EditorSession::paintRefusal` (`EditorSession+Brush.cpp`, declared in `EditorSession+Brush.h`) twins Swift's `paintRefusal`: none while `canEditLayers` refuses, without an active layer or when `canPaint` holds; then several layers, a folder's pixels, a hidden layer or one in a hidden folder, a mask turned off, an adjustment's pixels, an empty selection, in Swift's order and words, with Ctrl+D for ⌘D and the curly quotes kept. `beginBrush` splits Swift's guard: a tool that cannot paint the target (Spot Healing or Clone Stamp on a mask, a tool that is no brush) returns silently, a refused target sets `brushError` to the refusal (none when busy, so a waiting error is cleared, as Swift's `brushError = paintRefusal`). `beginWarp` sets the mask's words or the refusal. Fix beyond Swift: Swift's gradient sets the refusal only past its first guard, which is reached with a refused target only while a gradient is pending, where `canEditLayers` refuses and the refusal is always nil; so Swift's gradient says nothing on an empty selection, which its commit message says it explains. The twin's `beginGradient` sets the refusal at the first guard (no pending gradient, `canPaint` refusing); Swift's second assignment, always nil there, is left silent. Like the brush, the gradient's refusal while the editor is busy is none, which clears a stale alert where Swift's first guard returns untouched. `invertSelection` leaves no selection when the inverse is empty (Select All then Inverse), one `Inverse` step, as Swift's. Tests: `PaintRefusalTests` (every refusal, and the order at each seam that two reasons can share: two layers before a folder, a folder before its hiding, hiding before a mask turned off, an adjustment before an empty selection, a folder's or adjustment's mask past the folder or adjustment; the mask-off and adjustment tests exclude each other, so their order is unreachable; a layer inside a hidden folder; the gradient and the Smear; a busy session clearing, another tool silent), `SelectionTests::inverseOfEverythingDeselects` (Swift's). Equivalent mutants: `paintRefusal`'s `canPaint()` term (when `canPaint` holds every later test is false) and the empty-selection test without `isEmpty` (reached only when `canPaint` refuses for no listed reason, which needs an active layer outside `selectedLayerIDs`, which no session call makes). Painting at the layer's own resolution (13.11, `d3170ab`): `BrushStroke::Clone` is Swift's tuple `{image, placed, inGrid}`: the image painted through the tip and where it sits, in document pixels or in the stroke's own grid. `clonePixels` maps tile pixels through the grid (and `pixelToDocument` unless `inGrid`) into `placed`, scaled to the image, so a translation alone still copies or shifts by the fraction as before. `BrushStroke::gridRect` (in `BrushStroke+Commit.cpp`, beside `transform`, as Swift's) carries a document offset into the grid through the inverse map's linear part (`CGSize.applying`), turned, scaled and flipped as the layer. `cloneSample` takes the stroke and the offset: the stroke layer's own pixels placed at `gridRect(sourceRect, offset)`, or, sampling all layers, the composite at document size placed `offset` away; `blurSample` takes the stroke: the layer's pixels or mask drawn nearest at the margin of a region `ceil(3σ)` round `sourceRect`, σ the bar's Radius (13.16; Swift's tenth of the brush, 1.5 to 30, at 13.11) divided by the grid's document size per pixel (`sqrt|det|`) and capped at half the longer side, a mask on its edge tone first (Swift's `if let` on the mask and its `?? 0` for a missing document are dead, since the mask's asset is the sample and a stroke needs a document, and left out), softened through `PixelAdjust::gaussianBlur` (a mask's edge repeated, pixels clear past theirs) at σ × fit, in the pieces the brush reaches since 13.16, where fit shrinks the sample to a budget of `min(maxSurfacePixels, max(16 M, four canvases))`; it returns the region as `placed`, `inGrid`. Swift's old mask sample (the placement's black box, `drawCoverage`) is gone with it. `beginBrush` makes the stroke first, then samples into it, as Swift's; a sample that fails returns without a stroke. The Smear's commit places its copy at document size. The brush and eraser: Swift's commit only changes its CPU fallback's stamp (`stampDiameter`), dead in the twin (8.1); the held judgment holds: the twin's coverage is rendered in the stroke's grid, so `NativeResolutionPaintTests` passes Swift's erase cases (scaled down, unequal, turned, and on the canvas scaled back up) and the blank layer's document resolution without any change to `BrushStroke::grid` or `BrushCoverage`. Tests: `NativeResolutionPaintTests` (Swift's seven, plus the offset carried through a flipped and a turned layer, pixel for pixel), `BlurToolTests` (`theSampleFollowsTheRadius`, `theSampleIsInTheLayersOwnPixels`: twice as fine, and a tiny layer's cap; `aHugeLayersSampleIsMadeLine truncated
- Masks as Photoshop's (13.12, `b3419ab`): `addMask` with a selection now reveals it (white inside, black outside, the coverage as drawn) and, `revealing` false, hides it (the coverage inverted), one `Reveal Selection` or `Hide Selection` step; the row menu's Reveal All and Hide All call it as before, so Hide All with a selection hides the selection. `LayerMaskMenu` reads Alt from `QGuiApplication::keyboardModifiers()` when clicked, Swift's `NSApp.currentEvent`, so Alt-click adds the opposite, with Swift's help in Alt's words. The session keeps `viewsMaskAlone` (a getter and a setter that notifies on a change, the badge's write; `EditorSession.h`, defined in `EditorSession.cpp` beside Swift's `didSet`) and `maskAloneLayer()` (Swift's computed property: the view asked, the mask targeted, the active layer with a mask); `isMaskSelected` gains Swift's `didSet` as a private `setIsMaskSelected` (declared in `LayerMask+Session.h`, since `EditorSession.h` sits at 499 lines; defined in `EditorSession.cpp`), which every former write of `m_isMaskSelected` now calls, so targeting the pixels, another layer, a deleted mask or a load ends the view. `maskAloneLayer` and `toggleMaskAlone` live in `LayerMask+Alone.cpp`, since `LayerMask.cpp` already held 495 lines. `toggleMaskAlone` targets the mask through `selectLayerTarget` and shows it unless it already showed; where that refuses and leaves another target, so does the toggle, but with the project busy and the same mask already targeted the refusal is silent and the view still flips, as Swift's does. The canvas's `DisplayState` gains `maskAlone` (the mask asset's `ImageIdentity`, whether or not enabled, since the composite may skip it), and `drawLayers` hands off to `drawMaskAlone` (`EditorCanvas+Masks.cpp`, beside `liveFolderMaskClip`, which moved there to keep `EditorCanvas.cpp` under 500 lines): the canvas filled with the mask's edge tone, then a brush stroke or gradient laid into that mask through `LayerRenderer::drawBrushPreview` (the painted mask's own raster when it has one), else the mask at its displayed placement or the layer's transform, gray where it shows; the crisp zoom path draws through `drawLayers` too. The list's mask thumbnail: Alt's release without a drag calls `toggleMaskAlone`, only inside the thumbnail after a move under `startDragDistance` (4.7's rule; Swift's `dragMaskCopy` toggles on any left-up before a 3-point drag) (Shift no longer toggles there, as Swift's), the tooltip names Alt-click, and the targeted ring is white while that mask shows alone (`setTargeted`'s `alone`; Swift's `maskAloneLayer?.id == layerID` is left out as dead: the ring shows only on the active row, whose layer is the one shown alone). `LayerListFocusTests` and `LayerDragGestureTests` expected Alt-Shift to toggle the mask; they now expect Swift's view. `MaskAloneBadge` (in `UI/LayerMaskMenu`, Swift's file) is 26 points tall, 75% black with a 14% white rim, radius 13: the mask glyph at 11, "Layer Mask" 12 px semibold, the layer's name 12 px at 60% white, elided within 220 points, and an `xmark` button (a new `LayerIcon`, Swift's bold `xmark` as two round-capped strokes) with Swift's tooltip in Alt's words and accessible name, which ends the view. `ContentView` keeps one badge, shown while `maskAloneLayer` holds, in a layout nested in the canvas slot (it starts hidden through `synchronize`, which the constructor runs) (bottom, centred, 14 points up), so the room beside and below it stays the canvas's; Qt child widgets take their own clicks, so Swift's AppKit hosting has no twin. Swift's GPU fix (a mask stroke's grid starting at the mask's background) is the twin's behaviour already: `allocateTile` fills a mask tile with `maskBackground`. Tests: `MaskAloneTests` (Swift's three, plus another layer ending the view and the setter's signal, a stroke and a gradient shown as committed, a disabled and a placed mask, the crisp zoom, the badge's words, place, elision and clicks, the list's Alt-click, Shift ignored and the white ring; the view across undo and redo and when the shown mask is targeted again; a pending pixel gradient on the layer and another layer's pending mask gradient kept out of the view while their commits are posted), `SelectionEditTests` (Swift's renamed two and the scaled layer's hiding mask), `LayersPanelTests::theMaskButtonUsesTheSelection` (plain and Alt clicks with and without a selection, the tooltips, the row's Hide All). Mutants: the session 10 + 1 rerun (the active-layer term of `toggleMaskAlone` pinned by a busy refusal with another layer's mask targeted), equivalent: `maskAloneLayer`'s `m_isMaskSelected` and `layer->mask` terms (every path that clears either clears the view through `setIsMaskSelected`; Swift's guards, kept); `drawMaskAlone` 6 + 3 reruns (the placement pinned off the old box), the stroke's `isMask` and layer terms are live, not equivalent: `resolveGradient` only posts the commit, so a pending pixel gradient, or another layer's mask gradieLine truncated
- Quit mid-edit and Scanlines (13.17, Swift 1.4.4; since 14.19 Scanlines is a filter of its own, below). Quit (`1aad384`): `ProjectWorkspace::settlePendingEdits` twins Swift's: on the tab on screen a pending gradient is applied (`commitGradient`), then a pixel move (`finishPixelMove`), then a filter, Hue/Saturation, Levels, Color Range, the Expand/Contract/Feather panel (`setSelectionAmountOperation(nullopt)`) and the colour picker are cancelled (their Cancel, so nothing un-OK'd lands). It takes a completion callback (Swift's `async`), run from the event loop once both commits have landed, and only while the workspace lives (`QPointer`), the tab kept alive in the callbacks as every workspace flow keeps it. `confirmQuit` refuses at once while the workspace manages (Swift's guard moved there from `applicationShouldTerminate`; the twin's window close reaches `confirmQuit` directly, so this guard is what stops a second close mid-quit), applies open text, settles, and only then asks `canSwitch`; a refusal there (a busy project, a commit still running) is a warning on `omaphoto.app` where Swift beeps. Swift's `gradientEdit != nil` and `pixelMove != nil` tests before awaiting are left out: both twins return (posted) when there is nothing to commit. Swift's `finishAdjustmentEditing(commit: false)` is left out as dead: `cancelFilter`, `cancelHueSaturation` and `cancelLevels`, each called before it, call it first, and an adjustment's editor is always one of those three (its request and original are set and cleared with them; while the editor still renders, `finishAdjustmentEditing` does nothing in Swift either, and `canSwitch` refuses the quit). Tests: `QuitPendingEditsTests` (the tab on screen settled and another left as it is; a quit refused while an adjustment's editor is asked for and not yet rendered, then cancelled once open; Swift's `quittingAppliesAPendingGradient` and `quittingCancelsAnOpenDialog`; a pixel move applied as one `Move Pixels` step; Color Range putting the selection back, the selection-amount panel, the colour picker not taking its colour; an adjustment's editor putting the settings back; a workspace gone mid-settle calling nothing; closing the window applying the gradient before the Save question; a busy project refused with the warning, nothing asked or applied; a second quit while the first asks refused unsettled). Scanlines (`9c99853`, `cd2f998`): `DitherStyle::scanlines` ("Scanlines (CRT)") joins the fourth group; `usesPixelSize` (false for ASCII and Scanlines: no chunky pixels, no Pixel Size or Pixel Shape rows) and `drawsMarks` without Scanlines, as Swift's. `DitherSettings` gains `lineSpacing` (2–32, rounded, 4), `glow` (0–100, 35), `dots` (0–100, 0) and `wobble` (0–64 px, unrounded, 0), normalized as Swift's; the kernel's cell is the line spacing for Scanlines. `kernels/DitherPixels.c/.h` are cd2f998's, with one adaptation: Swift's kernel threads through GCD (`dispatch_apply`) and Apple blocks, which GCC cannot compile. `in_bands` keeps Swift's split (one band under 64 items, else 32) but takes a C function and a context; the three block bodies (the tone pass, the scanlines, the glow) are moved word for word into static functions with context structs (`tone_job`, `scan_job`, `glow_job`), and `__block int failed` is a C11 `atomic_int`. The bands run through `dither_bands`, a C hook defined in `Document/Dither.cpp` on `PoolMap::blocking` (QtConcurrent, so a filter worker lends its pool place). The arithmetic is unchanged; this is a forced difference (GCC has no blocks or GCD), agreed with the coordinator. `bandsDrawAsOneBandDoes` holds 32 bands against one: the top rows of a 300-row picture equal a 62-row picture's, which runs as one band in both passes. `glowing` twins Swift's: sigma `lineSpacing × 3 + 3`, shrunk by `max(1, floor(sigma / 4))`, blurred at `sigma / shrink`, scaled back and added through `dither_glow` at `glow / 100 × 2.5`. Core Image clamps to the extent before it shrinks, so the twin pads the image by the blur's reach (three sigmas, a whole number of shrink steps) with its edges repeated, the far sides filled to a whole number of steps so the shrink and the enlargement are both exactly the shrink (`theGlowScalesBackExactlyOffTheStep`), shrinks that with `QImage::scaled`'s smooth filter (an area average, where Core Image samples bilinearly), blurs with the edge repeated (`PixelAdjust::gaussianBlur`), scales back bilinearly and crops the middle; it stays within three levels of light of Swift's first commit's full-size blur (`theGlowIsTheBlurredLightAtItsAmount`, both axes). The sheet adds Line Spacing (px), Glow (%), Dots (%) and Wobble (px) with Swift's help under Scanlines, and Pixel Size and Pixel Shape follow `usesPixelSize`. Tests: `ScanlinesTests` (Swift's four `DitherTests`; the beam's rows for white, 0.35 gray and a striped average; the style's flags and that pixel size, shape and cell size change nothing; the four settings' clamps, the rows' units and raLine truncated
- Camera Raw curves as Photoshop's (13.18, Swift 1.4.5: `60d9117`): `kernels/AdjustPixels.c/.h` are 60d9117's: the tone curve runs on red, green and blue alike (`toneLut`, Swift's rename of `lumaLut`), and Refine Saturation means what Swift's now means: 0 the curve per channel, below 0 easing toward the colour scaled to the curved brightness (−100 brightness alone), above 0 pushing the curved colour from its luminance by 1 + the amount. `CameraRawCurveSettings::parametric` twins Swift's: unchanged with all four amounts at 0; otherwise 33 anchors, each `bend` of `bend` (shadows and highlights past the outer dividers, then darks and lights either side of the middle one, gamma curves of strength 1.66), run through the same curve as Image › Curves (`point`), so the regions meet without a corner. `bend` is a private static, as Swift's; `lumaTable` is `toneTable`. The twin's normalization of the dividers (5–90, +2 apart, 98 at most) still clamps what a drag writes. The graph's gestures (`UI/CameraRawColorControls+Curve.cpp`, beside the controls' file) are Swift's single `DragGesture`: a left press picks once (`parametricDrag`: within 18 points of the bottom the nearest divider, the first of equals; elsewhere the region whose tone range holds the press, a tone on a divider joining the region above; `pointDrag`: the point nearest by distance within 0.055, chosen; else a new point there, if fewer than 16, inside x 0.01–0.99 and over a hundredth from every point's x, sorted in, stored and chosen), then `continueDrag` runs at the press and on every left-button move: a point's output follows the pointer, an inner point's x stays a hundredth inside its neighbours, an end keeps its x; a divider follows, shadow below dark − 2 and dark below light − 2; a region's amount is its start minus the drag's height over the graph's times 200, within ±100, rounded. Left out as dead, since normalizing does each (`CameraRawCurveSettings::normalized`: amounts, dividers 5–90, +2 apart, 98 at most, and the repair's 0–1 outputs): Swift's 2–98 clamp of a divider, dark's `shadow + 2` floor and light's `dark + 2` floor, and the 0–1 clamps of a dragged or new point's output (a press inside the graph is inside 0–1 anyway). The new point's `x > 0.01` and `x < 0.99` are dead too: the ends sit at 0 and 1, and a point within a hundredth of any x is refused. `bend`'s `lower > 0` and `upper < 1` are dead (no tone lies below 0 or above 1). The release lets go. Qt can lose a release, so every press picks afresh. A double click is Qt's second press: it picks and drags as a press (Swift's drag sees it too), then on the point page removes the nearest inner point by x within 0.04 and drops the choice, and lets go of the drag (Swift removes at the tap's end, which ends the drag). Swift re-picks on each move while a press picked nothing; with the same start and points it picks nothing again, so the twin leaves that out. The chosen point (Swift's `selected`) draws in `Highlight`, the accent, and the readout shows only while it exists, following the drag; another channel drops the choice and the drag (Swift's `onChange`). The graph's help and Refine Saturation's are Swift's new words. Swift's `CameraRawMixerControls` gains the same `Drag` and `selected` state but never reads it: dead, left out. A fix beyond Swift: Swift holds an inner point exactly a hundredth from its neighbours, where the curve's repair (`x > kept.x + 0.01`) drops it (or drops the next point), and the drag's index then names another point; the twin holds it a billionth further inside, so the repair keeps it and the drag stays on it (`aPointIsPickedByDistanceAndKept`, `endPointsStayAndARealDoubleClickRemoves`, both failing before the fix; `CameraRawColorTests` pins the repair's strict `>` at exactly a hundredth, which the fix rests on). A fix beyond Swift: a preset's points drop the choice (TASKS asks for it; Swift's `applyPreset` keeps the index, which then lights whatever point holds it); Custom changes nothing and keeps it. Tests: `CameraRawCurveTests` (Swift's `parametricCurveIsSmooth`, `parametricCurveMatchesPhotoshop`, `curveDeepensColorLikePhotoshop`; each anchor against the bend written apart, and the smooth curve between; each region bending only its range, dividers at the ends bending nothing; the tone table through the point curve; Refine Saturation at 0 per channel through the table, −100 brightness with the hue kept, −50 halfway, +50 half again from luminance), `CameraRawCurveGraphTests` (a double click's 0.04 reach by x, and one in the divider strip moving the divider; regions lifted and lowered from their start, clamped and rounded, picked once by tone; dividers in the strip, nearest and first of equals, kept ordered and within the settings' limits; points by distance, kept through the drag, a new point added and dragged by the same press, ends moving their outputs alone; refusals near an edge, a point's x, at sixteen; the chosen point lit and read Line truncated
- Held keys, field values and resize snapping in the Move tool (13.13: `bca8f13`, `03d692c`, `686d8c7`, `c459f88`): `UI/HeldModifiers` twins Swift's class, a `QObject` singleton (`shared()`) with one signal, `changed()`. An application event filter reads key presses and releases and left-button presses and releases, Swift's `flagsChanged`, `keyDown`, `leftMouseDown` and `leftMouseUp`; a modifier key's own event sets or clears its bit, since Qt's event may not count it yet. Ctrl, Shift, Alt and Meta count (Swift's Command, Shift, Option, Control). Typing counts as none: Swift's `NSText` first responder is a focus widget with `Qt::WA_InputMethodEnabled`, so a `QLineEdit` and the canvas holding open text alike. `applicationStateChanged` stands for the two activation notifications (inactive clears; active reads `queryKeyboardModifiers`), and a `QMenu`'s `Hide` for `NSMenu.didEndTracking`. `TransformInspector` shows Auto Select as `transformAutoSelect != Ctrl` and the lock as `locksTransformRatio != Shift`, and a click while held writes the other way, as Swift's bindings; it synchronizes on `HeldModifiers::changed` too. Tooltips carry Swift's new words, Ctrl for Command. `transformPressLayer` follows Swift's: `autoSelect = transformAutoSelect != Ctrl`; Ctrl picks the layer under the pointer only with Auto Select off or with Shift (Ctrl+Shift adds either way). `TransformEdit.fromFields` sits last; `beginTransform(persistent, fromFields)` sets it, where Swift writes `transformEdit?.fromFields` in place (the twin's edit is read-only from outside). The bar's `change` opens a non-persistent `fromFields` edit; `finish` commits it, one step, when a field's scrub ends (`NumericScrub`'s `onEnd`) or the field loses focus. A focus borrowed by a menu, a popup or another window in front (`ActiveWindowFocusReason`, new: AppKit keeps the first responder when the app goes to the back) finishes nothing and keeps the typing. Escape in a field throws a `fromFields` edit away before handing the canvas the keys. The Swift path assumed: AppKit sends the Cancel button's Escape key equivalent (`configuredNativeShortcut(.escape)`) before the field editor, the only way `686d8c7`'s "Escape still throws the change away" can hold, since the field's own `onExitCommand` would end the focus and so apply the value. That path would also cancel a Ctrl+T edit from a field (and Return apply it); the twin keeps 6.1's reading there, a Ctrl+T edit waiting for the canvas's keys, a known difference held from 6.1. Flip H, Flip V and Sampling open a `fromFields` edit with no finish of their own, as Swift's do: it applies with the next field's finish, a canvas drag or anything that commits. `beginTransformDrag` commits a `fromFields` edit first, after `transformPressLayer` (so it picks nothing, as Swift's `picks`), so the two stay separate steps. Qt moves the focus before a press arrives, where SwiftUI's focus changes after `mouseDown`: a field left for a mouse press (`MouseFocusReason`) finishes from the event loop (a zero `QTimer`), so a typed value then a press over another layer drags the active layer under Auto Select, as in Swift (review 2's finding; `MoveBarPressTests`). `snappedResizePoint` lives in `Document/Crop.cpp` with Swift's: the edges each axis moves snap on their own to `transformSnapTargets` (the file's `nearest`, the first of ties), the nearer alone when proportional, the pointer nudged by one pixel's measured step; an upright layer only; guides set, or cleared when snapping is off, the drag is no resize or the layer turned. `dragTransform` snaps a resize unless Ctrl is held, proportional as `locksTransformRatio != Shift`. Dead or equivalent: `beginDistort` keeps `fromFields` where Swift's rebuild drops it, but a `fromFields` edit never reaches it (the canvas commits it before any handle drag). `snappedResizePoint`'s `before` read from `update(result)` or `update(point)` is equivalent (unlocked axes are independent, and proportional leaves one snap). The edge's tie (`<=`, the low edge) is reachable (the right handle half a pixel from its anchor, a one-pixel box) and held by `aTieTakesTheLowEdge`; the dragged handle's carried place by a drag past the anchor (`aLongDragSnapsTheEdgeItCarries`); Alt's centre resize by `altResizesFromTheCentreAndSnaps`. `QRectF::normalized` was dropped as dead (a resize keeps sizes positive and flips instead). Untested paths: the menu `Hide` and the activation's `queryKeyboardModifiers` (offscreen holds no real keyboard state). Tests: `MoveBarPressTests` (a typed value then a real press on another layer drags the active layer, two steps; Ctrl with a folder active and Auto Select on drags the folder past its box; a value left by a click in another field applies alone, one step), `MovePressFixtures.h` (the canvas fixture the three press files share), `TransformPressTests` (Swift's `commandFlipsAutoSelectWhileHeld` both ways and `commandShiftClickAddsALayerWithAutoSelectOn`, Ctrl outside the active layer, a fieLine truncated
- The Smear's 1.4.2–1.4.3 fixes (13.16, part one: `7164dd4`, `ad9ad7c`, `504129d`, and `095da2f`'s commit half): Liquify is Swift's new GPU stroke on the CPU, as the twin has no Metal: `WarpStroke` keeps the layer as it found it (`m_original`, a copy made in the constructor) and a source offset per pixel (`m_offsets`, two floats each, zero at first); each dab copies the offsets under it to `m_scratch`, reads them bilinearly from behind the brush's travel, less the travel times the weight, writes them back, and draws the pixel afresh from the untouched layer, bilinear, held to its edges, each byte rounded once: `warp_push` of `MetalWarp.swift`, float for float (bytes over 255, mixed, times 255). Swift allocates the two textures on the first push; the twin allocates them with the stroke, before any dab (nothing changes the copy between), so a failure is `ExportError(render)` where `beginWarp` catches it, where Swift's guard returns in silence; Smudge's carried colour, allocated at the first dab inside `beginWarp`, fails the same way, and a dab's scratch that finds no memory skips that dab with a warning on `omaphoto.rendering`, Swift's guard on its scratch texture (review 2: it ran outside any catch). The colour reads keep the shader's `size - 2` as they are: the offsets' read above needs two columns and rows of the dab's area (`ix`, `iy`), which a canvas a pixel wide or tall lacks, so no read leaves the image; the clamps first written here for such canvases were dead and are removed (review 2). Smudge is `ad9ad7c` in both of Swift's paths: dabs every 0.005 of the width (a pixel at least), each dab lays what it carries at weight × strength, and carries what it just left, nothing older. `BrushSettings.blurRadius` (5) is the Blur's own Radius: the sample's sigma follows the Radius (see part two for the merged formula). `BrushControls` shows Radius for the Blur mode alone, after Strength: a scrubbable label (a tenth a point, 0.5–50), a 100-point slider over 0.5–20 in hundredths (past 20 Qt rests it at its end, so Swift's `min(20, …)` is left out), and a 42-point `PickerField` with "px": a readable number applies on Return or leaving, clamped, a number that is not finite takes 5 (Swift's binding), other text goes back, arrows step one (Shift ten) from the session's value, clamped; it prints Swift's `fractionLength(0...1)`, never grouped. `BrushStroke::maxDiameter` is 2100, so `finishWarp`'s tip four wider than a 2000 brush is no longer refused; `finishWarp` appends a dab centre only every twentieth of the brush (the last always), as Swift's; the thinning is `WarpStroke::committedPoints`, so the test reads it directly (`aCommitKeepsATwentiethAndTheLastPoint`). Part two (`095da2f`'s Blur half, on 13.11's grid sampling, cc9bf95 merged into port-d): `BrushStroke::Clone` is 13.11's `(image, placed, inGrid)` plus `render`, a function that makes a part of `image` (Swift's `cloneRender`; Swift keeps it beside `clone`, the twin inside it, so one assignment sets both), and `clone` is private behind `clone()` and `setClone()`, which drops the pieces cut so far (Swift's two `didSet`s). `clonePixels(key, tile)` is Swift's `clonePiece`: the tile mapped into sample pixels through 13.11's chain (grid, `pixelToDocument` unless `inGrid`, then `placed` to the image's pixels, Swift's `scaleX` and `scaleY`), two pixels round, made whole outward (`toAlignedRect`, Swift's `integral`), held to the image; an empty part paints nothing on that tile (the fill is skipped, as Swift's `if let piece`); otherwise the piece is `render(part)` or a copy, cut once per tile key and kept in `m_clonePieces`, and the tile's sample is drawn from it through the same transform shifted by the piece's corner, so every path reads the pixels the whole image would give. A piece that cannot be allocated is `ExportError(render)`, thrown on the calling thread with `publish`'s other copies, where Swift's `cropping` returning nil paints nothing. `blurSample(stroke)` returns the sharp sample in the stroke's grid (13.11's `region`, `fit` and budget unchanged) with `render` blurring a part at `sigma × fit`, padded by the Gaussian's reach (`ceil(3σ)`) and cut back, clear past the sample for pixels (`QImage::copy` fills outside with zeros), its edges repeated for a mask (Swift's `clampedToExtent`), so a piece equals that part of the whole byte for byte (Swift allows one level). The Radius sigma is Swift's `min(min(50, max(0.5, blurRadius)) / perPixel, sidePixels / 2)`. Merge: port-c's direct `clone =` writes became `setClone` (`beginBrush`, `finishWarp`, `cloneSample`'s callers, the tests and `documentClone`), every `Clone` initialiser names `render` (GCC's `-Wmissing-field-initializers`), and port-c's Blur sample tests compare the sharp sample (`framed`) and its softening through `render` (`soft`), with each brush size's old sigma now set as the Radius. Equivalent mutants and untested paths for part two: a piece, a padded copy or a cut-back blur that cannot Line truncated
- Inner effects keep a pixel's alpha (14.1, Swift 1.4.6: `e7e295e`, `9cb57db`, `f25effa`): `LayerEffectsKernel` follows v1.4.9's `effects_compose`: the colour overlay, then the inner glow, then the inner shadow move each pixel's colour toward theirs times its alpha (MSL's `mix`, `recolour`), the overlay by its opacity, the inner two by their strength (`effects_inside`: 1 − the softened or moved shape, no longer times the shape); then the pixels go over the shadow, the outer glow and an outside stroke, and an inside stroke goes on top. So a half-clear pixel takes a colour fully and stays half clear, and effects beneath are not tinted. Swift's CPU path (`overlaid`, `recolor`, the surface's inner glow) serves machines without Metal and stays dead here, as 9.6b's. The effects button's tooltip says "Add layer effect". Tests: `ColorOverlayTests` (Swift's three; the inner shadow and inner glow on half-clear pixels, a shadow beneath untinted), the shader replay in `LayerEffectsKernelTests`.
- RAW rounding and Importing… (14.2, Swift 1.4.6: `6345432`, `196342c`, `6e6ca38`, `0907f51`): `kernels/DitherPixels.c/.h` gain `196342c`'s `hash_noise` and `dither_quantize16`, its block body moved into `quantize_band` with a context struct, as 13.17's bands. `RawImporter::develop` works the look at 16 bits (`lround(65535 × …)`, opaque) into a buffer it allocates first (`ExportError(render)` without memory), then rounds it to 8 bits through `dither_quantize16`, where Swift renders Core Image at 16 bits and does the same; Swift's half-float working format names Core Image's own and has no twin. The session's `rawImporting` and `endRawDevelop` are Swift's: Import keeps the request and sets `rawImporting` while an answer waits, Cancel or an answer nobody waits for ends at once, and `finishDecode` ends it once the frame lands or fails (Swift's `defer`), after the layer is in. `RawDevelopSheet::setImporting` shows Swift's plate (a 32-point busy bar over "Importing…" in `PlaceholderText`, padded 16, on the window colour at 85% rounded 10), hides the preview's spinner, rests the sliders, Reset, Cancel and Import, and filters its dialog's Close and Escape while importing (Swift's `interactiveDismissDisabled`). Tests read RAW tones as a mean over the frame, rounded, since the noise moves single pixels a level; Qt's area average truncates, so a fitted preview reads a level low (`theQueueKeepsThePreviewsDecodeUntilReleased`). Holding the global pool freezes painting (Qt's raster engine fills on it), so the sheet's tests read its state before the event loop runs the develop, or use a frame large enough to take a while. Tests: `RawDitherTests` (Swift's two on the kernel; the triangular noise's three in four kept, alpha never passed, a develop dithered and repeatable), `RawDevelopTests` (importing, cancelling, failing, an answer nobody waits for, the plate, the locked dialog, the spinner and Reset while importing).
- New Canvas in units, at a DPI, on a background (14.3, Swift 1.4.6: `b0b4f01`, `ab9779a`, `a00d8b6`): `NewCanvasUnit` and `NewCanvasBackground` (in `NewCanvasSheet.h`, Swift's file) are enums with free functions for Swift's members (`rawValue`, `name`, `next`, `pixels`, `text`; `title`, `next`, `color`). `pixels` trims Foundation's `.whitespaces` (`trimmedWhitespaces`), reads a comma as the point and parses as Swift's `Double(text)` (the whole text through `strtod_l` in the C locale, a leading white space refused), takes a positive number, turns a print size into pixels at the DPI rounded half away from zero, and wants a whole count from 1 to 30,000; Swift's `isFinite` term is dead (infinity fails the range, NaN the whole number) and left out. `text` prints whole pixels, or a print size to two decimals with none trailing, rounded as Foundation's format style rounds: the shortest decimal that reads back as the double (`QLocale::FloatingPointShortest`), then halves to even on its digits (1.905 cm prints 1.9, 3.175 prints 3.18, where rounding the binary value gives 1.91 and 3.17); checked once against Swift 6.0.3 for 1 to 4,000 pixels in every unit at 72 and 300 DPI, all 24,000 alike. The sheet starts in pixels at 72 DPI on a clear canvas each time (`a00d8b6`), and its three settings are Swift's `CyclePill`s, `QAbstractButton`s that paint their text in `PlaceholderText` at 12 pixels and, hovered, a capsule of the text colour at 10% (macOS's quaternary label): the background (Transparent, White, Black canvas), the unit (switching rewrites both fields in the new unit when both read, else leaves them) and the DPI (72 or 300; in a print unit its tooltip adds the pixels it makes). The fields' units follow the unit; the note shows only for a size that does not read, in the unit's words; presets and the clipboard's size are written in the unit. `createNewProject` and `createDocument` take the resolution (1 to 9,600, else 72) and the background; `firstLayer` makes a Background layer of that colour at full size with its thumbnail, or a blank Layer 1, and a background that cannot be allocated logs on `omaphoto.app` and falls back to the blank layer, Swift's `try?` (untested: only starvation reaches it). Tests: `NewCanvasUnitTests` (Swift's four; rounding, trimming, the DPI's ends, the thumbnail), `NewCanvasSheetTests` (the pills, the units, the DPI's tooltip, presets in units, Swift's number parsing).
- Rotate Canvas 90° (14.4, Swift 1.4.6: `754f188`): `Document/CanvasRotation.cpp` twins Swift's file, `CanvasRotation+Session.h` its session half; `LayerTransform::quarterTurned` and `CanvasGuide::quarterTurned` are declared in their own headers and defined there, as `mirrored` is. `rotateCanvas` commits a transform edit and drops a crop frame first, asks `canEditLayers`, then turns every layer and placed mask about the canvas (the middle carried, the angle by ±90 through `fmod`, which keeps Swift's sign), swaps the sides in place, turns the guides and the selection's path (its feather and antialiasing kept), fits the view, one `Rotate Canvas 90° Clockwise` or `Counterclockwise` step; Swift's `finishOpacityEdit` is dead (`commitTransform` runs it, as `flipCanvas` notes) and left out. `restore` fits the view when the canvas changes size too. The Image menu lists both entries before the flips, enabled by `canEditLayers`. A fix the item's exact test found: QPainter turned a layer through `rotateRadians`, whose cos(π/2) is 6e-17, so a turned edge missed its pixel boundary and antialiasing shaved it, and its fixed-point bilinear fetch strayed a level or two (40 read 39) where CoreGraphics' low quality copies a pixel-aligned turn exactly. Every layer placement now turns through `LayerTransform::degrees()` and `QTransform::rotate`, which Qt builds exactly at ±90, ±270 and 180 and as `rotateRadians` elsewhere, and `LayerRenderer::upright` keeps Swift's upright (no turn) and adds a draw that takes image pixels onto device pixels one to one: the whole map from the image's pixels through its target rect, the placement and the painter's device transform is a signed permutation (no stretch on either axis, no shear, no perspective, the painter's own turn counted), within a thousandth of a pixel across the image, with the image's corner on a whole device pixel. Such a draw takes no filter; a turn half a pixel off, a stretched side or a turned painter still filters, as CoreGraphics' low quality blends them. Equivalent mutants: `degrees()` in `BrushStroke`'s grid map, `Distort` and `LayerMask` (the two matrices differ by 1e-16 there; the change keeps every placement one turn). Tests: `CanvasRotationTests` (Swift's four, a turned layer and a placed mask, the crop frame, the refit, refusals and steps), `SharpCanvasTests::aQuarterTurnOnWholePixelsCopiesAcross` (the rule's edges: every quarter, a nudge on either axis, a stretched side, a 30,000-pixel side leaning 0.0001°, painters turned, sheared and in perspective; plain and painted layers exact on whole pixels and filtered half off or stretched), `everyLayerAndEveryPlacedMaskTurns`, `anOpenEditClosesBeforeTheTurn` (an opacity drag and a pending transform each their own step), the menu in `ImageMenuTests`.
- The command palette (14.6, Swift 1.4.6–1.4.9: `cb139a1`, `384632d`, `07b8b1e`, `94b57f7`, `bba92dc`, `088a5c1`, `720c121`): `UI/CommandPalette.h/.cpp` twin Swift's `CommandPalette.swift`: `CommandPaletteEntry` (an aggregate, `title` given beside `id`), `CommandPaletteSearch` (`score` and `rank`, a stable sort), `CommandPaletteMenu` (`entries` of a `QMenuBar`, `plain` for a title without its mnemonic, `&&` read as one ampersand), `CommandPaletteCommands` (`layerCommands`, `tools`, Swift's statics on the entry) and `CommandPaletteModel`. Scores work on graphemes (`QTextBoundaryFinder`) of the NFC text, Swift's Characters, which compare canonically (a decomposed accent in a recent project's name matches a composed one); `isLetter` and `isNumber` read the first code point's general category (`QChar`), where Swift reads its Alphabetic and numeric-type properties (known difference: marks and numeric ideographs). `CommandPaletteTests::scoresAreSwiftsOwn` pins scores Swift 6.0.3 computed with v1.4.9's own code. An entry holds a `QPointer` to its `QAction` and triggers it, where Swift finds the item again by its path (SwiftUI rebuilds menus; the twin's actions live with the window, and Open Recent's rebuilt entries become a warning on `omaphoto.app`, Swift's beep); Swift's app-menu skip has no twin (no app menu on Linux), and the menus are current already, synchronized on every `changed()`. Shortcuts print as `QKeySequence` native text. `UI/CommandPaletteView.h/.cpp` twin the view and controller: View › Search Commands… (Ctrl+F, first in View, `Search Commands` in `ShortcutDefinition::all` after Fit Canvas) toggles a `Qt::Popup` of 460 by 290 centred on the window's frame, so a click elsewhere puts it away (Swift's `resignKey`) and Wayland places it relative to its window; the menu's action is added to the popup, so its key, remapped too, closes it from inside. The field (17 pixels, 16 and 13 of margin) takes Up and Down (wrapping, the row scrolled into view), Return and Enter; Escape is the popup's own. The rows (`CommandPaletteRows`, painted: 13 pixels, the chosen row in the accent at 35% rounded 6, a drawn checkmark twelve points with six after for a toggle that is on, the key in a 12-pixel fixed font in `PlaceholderText`, a disabled entry in the disabled `WindowText`) run on a left release; "No commands match" shows over an empty list. Running closes the popup, activates the window and posts the entry to the event loop; a disabled entry warns and keeps it open. A hidden popup clears the focus it held: Qt returns focus only to a window's own focus widget, so the menus would otherwise keep the hidden field as the one whose undo Undo drives. Equivalent mutants: `score`'s `words.size() > 1` (one word is the phrase, which the tier before tried) and `rank`'s `!query.isEmpty()` (an empty query scores every entry 0). Tests: `CommandPaletteTests` (Swift's four model tests and Swift's scores, the model, a hand-built bar's paths, mnemonics, sections, hidden entries, numbering and a gone action, the real bar's entries and a toggle run through them, the mask commands, every tool and each named mode reached from its sibling, a refused tool), `CommandPaletteWindowTests` (Ctrl+F in the window, place, focus, Ctrl+F inside, ranking and wrapping with scrolling, Return and Enter running the chosen row once the loop is free, a disabled row, Escape, clicks in and out, the checkmark's room, a key drawn at the row's end with an elided title six points before it, the empty words).
- Canvas Only (14.7, Swift 1.4.6–1.4.7: `ad4bddf`, `088a5c1`, `720c121`): the session keeps `canvasOnly` (a getter and a setter that notifies), `escapeHasNothingToCancel` and `canToggleCanvasOnly` (Swift's terms). `ProjectWorkspaceView::toggleCanvasOnly` is Swift's delegate's: it keeps the window's state, hides the menu bar (Swift hides macOS's menu bar and Dock; the twin's menu bar is in the window) and the toolbar, and makes the window fullscreen, which on Linux also covers the desktop's own bars; again, it shows both and puts the state back. A window already fullscreen keeps the screen and hides only its toolbar, as Swift's. Swift's black frame over the window's rounded corners and rim has no twin: a fullscreen Linux window has neither. View › Toggle Fullscreen (after Search Commands, checkable, a plain F, Swift's unconfigured key; its definition joins the canvas group first, as Swift's) rests while `canToggleCanvasOnly` does not hold; a `QLineEdit` takes a plain letter through `ShortcutOverride`, so a field types its F, Swift's monitor that hands an F to an `NSText` first, and open text holds the entry resting. A hidden `QMenuBar` disables its actions' keys (Qt's shortcut context reads the bar's visibility), so every menu action is also added to the window, which keeps them live. Escape leaves (an application event filter, Swift's local monitor) when it carries no Ctrl, Alt, Meta or Shift, the window is active, nothing is left to cancel and the focus is neither a `QLineEdit` nor inside a `QDialog`, so a docked panel keeps its Escape (Swift's docked panel is a window of its own). `ContentView` hides the tool bar, the rail, the rulers, the layers panel and its edge, the status bar and their dividers (`m_panels`); `showHeader` clears `m_header` after deleting it, since a new bar's constructor signals and `synchronize` then finds no bar. The canvas's surround is black, and `DisplayState` carries the flag so it repaints. Tests: `CanvasOnlySessionTests` (the flag's signal, each gate term and each Escape term), the definitions' place in `KeyboardShortcutsTests`, `CanvasOnlyWindowTests` (F in and out with every bar and the layers edge, the canvas filling the window, keys through the hidden menus, black, Escape after a cancel and with held keys, a field's F, a docked panel's and another window's Escape, an already fullscreen or maximised window, the entry resting under text, a picker and no document).
- Camera Raw's colour noise reduction (14.8, Swift 1.4.6: `a2718d9`, `42a559b`): `kernels/AdjustPixels.c` is 42a559b's, unchanged: Color smooths each pixel's red and blue differences from its Rec. 709 brightness toward the area's (each weighted by coverage, so clear pixels add no colour and pull none toward gray), green following from the brightness kept, over a reach that grows with the amount and Color Smoothness; Color Detail keeps edges where the smoothed colour itself changes across the blur's width. Tests: `CameraRawColorNoiseTests` (Swift's two: colour grain under a quarter with brightness within a level, and a run that reads nothing clear pixels left behind, made sharp by `mallopt`: the planes on the heap, `M_PERTURB` filling what is handed out, so a plane left unwritten differs from run to run; then half the amount, a red beside clear pixels kept, an edge kept by Color Detail down and across, a speck taking its surround's colour at its own brightness, translucent colour kept, and exact pixels of the upstream kernel for a faint neighbour's coverage, Color Smoothness at 0 and 100, a partial amount, a reduced preview, a lone pixel among clear ones, edges in the red or the blue difference alone, down and across, and half the Color Detail).
- Last Filter (14.9, Swift 1.4.7: `0415aa4`, `6b81d4a`): `repeatsAsLastFilter` (the Filter menu's own kinds), `FilterEdit::repeating`, and in the session `lastFilter` (a getter and a setter that notifies, Swift's public var, which its tests write), `canRepeatLastFilter` and `repeatLastFilter`, which takes a completion callback: a pending gradient lands first (`commitGradient`'s continuation), then `beginFilter(kind, true)` opens the edit without a preview unless the kind is automatic, `commitFilter` applies it with the settings it had, and its callback cancels an edit still open, so nothing is left without a panel. `commitFilter` records the kind. Swift's `edit.repeating` check after `beginFilter` is dead and left out: the repeat commits the gradient first, so `beginFilter` either opens a repeating edit or none. Filter › Last Filter is first, then a separator, titled `Last Filter: <kind>` once one is recorded, on Ctrl+Meta+F (Swift's Command-Control F), resting while `canRepeatLastFilter` does not hold; `ShortcutDefinition::all` gains it after Select Subject. `synchronizePanels` keeps the filter panel shut for a repeating edit. Equivalent mutant: `canRepeatLastFilter`'s `m_filterEdit` term (`canAdjust` refuses an open filter; Swift's too). Tests: `LastFilterTests` (Swift's two; an Image adjustment not recorded, the setter's signal, the gates, Vignette's on an empty layer, a pending gradient as its own step, Remove Background through its preview, the menu's title, key and panel, a preview only when automatic, a failed preview closed, a blur that cannot grow, a newer edit outlasting the repeat), the key and Swift's unique menu keys in `KeyboardShortcutsTests`, the Filter menu in `CompositorMenusTests`.
- The closed hand (14.10, Swift 1.4.7: `c0a1cd0`) was held already: Swift sets the hand once a drag and lets Space's key repeats put the open hand back, so its fix holds the drag's cursor; the twin's `updateCursor` ranks a pan (`m_lastDragPoint`) above Space and the Hand tool, a repeated Space release is no release, and the release asks the cursor again, so the hand opens while Space is held. `CanvasNavigationTests::theHandToolAndSpaceDragTheView` holds it through repeats, and through Space let go mid-pan, the drag going on closed until the button comes up.
- The Layers footer's ink (14.11, Swift 1.4.7: `7fb40a9`, `790195f`): `footerIcon` (in `UI/LayersPanel`, Swift's `FooterIcon`) gives the plain buttons (new layer, folder, mask, delete) the primary ink, `WindowText`, and for their disabled state the menus' own: the shape in `PlaceholderText` dimmed by the style's `generatedIconPixmap`, as Qt dims the effects and adjustment menus' icons, so a disabled button is exactly as dim as a disabled menu (Swift measures a quarter-strength white for the same end). Both inks are read from the active group, since a disabled widget's palette answers with its disabled group. The menus keep `PlaceholderText`. The panel redraws its icons from the event loop on a palette change, once its buttons carry the new palette. Tests: `LayersPanelTests::footerButtonsTakeThePrimaryInkAndDimAsTheMenus` (disabled from the start and after a busy project disables them), and the inks after a new palette in `LayerListFocusTests::aNewPaletteRedrawsEveryIcon`.
- Don't Save (14.12, Swift 1.4.7: `283d360`, `4e035bf`) was held: Swift marked the button destructive and took it back the next day, so its net change is none, Save the one coloured button. The twin's alert keeps `QMessageBox::DestructiveRole` for Don't Save, which in Qt sets only the platform's button order, no colour; Save, the first accepting button, is the default, and Cancel and Don't Save are both plain. `ProjectControllerTests::saveIsTheOneDefaultAndDontSaveIsAsPlainAsCancel` holds it.
- The name field (14.13, Swift 1.4.7: `8f7d344`): a rename's `QLineEdit` has no frame and the name label's height and top, two points further left and wider, since a `QLineEdit` insets its text by two where the `QLabel` does not, so the text stays where the name was; its ground (`Base`, set as the rename begins) is the active `WindowText` at a tenth, Swift's quaternary label colour, and the name label hides beneath it until the rename ends, since Swift edits the label itself. `LayerListFocusTests::theNameFieldKeepsTheNamesSizeAndPlace`.
- Tool bars that scroll (14.14, Swift 1.4.7: `68396ff`): `ToolHeaderBar::scrolling` is Swift's horizontal `ScrollView` with hidden indicators, as the Move and Type bars already had it: the controls in a frameless `QScrollArea` without bars that takes the row's spare room, their content packed at its own size at the leading edge (a stretch of its own), so a narrow window scrolls them (a sideways wheel or trackpad, or the focus moving in) rather than squeezing them. The Brush, Gradient and selection bars use it (`brushScroll`, `gradientScroll`, `selectionScroll`); the Brush bar's clone and mask notes now scroll after the controls, as Swift's moved them inside; the Gradient's Mask note, Cancel and Apply and the selection's Empty selection and Deselect stay at the bar's end. Tests: `ToolHeaderScrollTests` (each bar narrow and wide, and each note where Swift puts it); `ColorSwatchTests` and `GradientCanvasTests` widen their bars, since a scrolling bar's own size hint no longer holds every control.
- Revealing the chosen layer (14.15, Swift 1.4.8: `d2b8aaf`, `b4e147b`): `selectLayer` and `selectLayers` end with `revealActiveLayer` (declared in `LayerGroups+Session.h`, as Swift's extension), which opens every folder above the active layer and says whether one opened; `selectLayer` then signals again, since its setter signalled before, so the last signal sees the folders open. Swift's `seen` set, a guard against a parent cycle, is left out: a session's document always passes `LayerHierarchy::validate`, so the chain ends at the root. `NativeLayerList::update` remembers the active layer it last scrolled to and scrolls a newly active row into view once its row exists (`ensureWidgetVisible`, no margin), so a later rename or refresh leaves a scroll made by hand: the session's setter signals while a shut folder still hides the row, where SwiftUI sees the selection and the open folders in one update, so the list waits for the row, and first hands the scroll area its posted layout request, since rows made anew have not yet grown the scroll range. Swift's own tests went with `b4e147b`; the twin keeps them. Equivalent mutant: the guard on an empty collapsed set (the walk then removes nothing; Swift's too). Tests: `LayerRevealTests` (Swift's five, the last signal with both and only the inner folder shut, a primary outside every folder opening nothing, a child of a shut folder below the view scrolled to by either call); `ProjectSessionTests` now collapses its folder after choosing a layer inside it.
- Camera Raw's Shadows and Highlights (14.16, Swift 1.4.8: `0b4c56c`; since 14.26 Photoshop's measured tables, below): `kernels/AdjustPixels.c` is 0b4c56c's, unchanged: each follows a bump, u(1 − 2u)², over its half of the tones (Shadows from black, Highlights from white), two and a half times the bump to lift Shadows or pull Highlights down and once the other way, so tones never swap, and black, middle gray and white stay. Tests: `CameraRawToneOrderTests` (Swift's ramp kept in order; since 14.26 its curve test went with the curve).
- The Layers panel's gray (14.17, Swift 1.4.8: `06313a9`): `layersLookEditable` is Swift's: `canEditLayers` without what lasts only while the mouse is down (a stroke, a warp, a drag's transform edit, a pixel move) or a moment (`isProjectBusy`, read as `showsBusy`, which shows after 250 ms). The list's rows and their eyes (an effect's eye keeps `canEditLayers`, as Swift makes each effect row anew with it), the footer's buttons (new layer, folder, adjustments, delete with an active layer), the effects menu (with a layer that has pixels) and the mask button (one layer selected, without a mask) follow it, so nothing flashes gray for a click; every action still asks the session's own gate, which refuses meanwhile. The effects menu's folder term is left out as dead: a folder never holds pixels, so the pixel term refuses it. Tests: `LayersLookEditableTests` (each moment looks editable while the gate stays shut, each lasting state grays, the footer and list mid-stroke and under a pending transform, the effects menu's and mask button's own terms); the busy cases in `BlendModePickerTests`, `LayersPanelTests`, `NativeLayerListTests` and `LayerEffectRowTests` now wait for the indicator, and a click before it changes nothing.
- Export As (14.18, Swift 1.4.8: `9287cf1`, `450af5f`, `b1db93d`): `ProjectController::exportAs(start, done)` replaces `exportJPEG`; `ExportFormat` (png, jpeg, pdf, Swift's raw values through `rawValue`, `allExportFormats`) starts the sheet, else the last format exported this run (`m_lastExportFormat`, PNG at first), and the File menu's Export JPEG… opens it on JPEG; Export As… (Ctrl+Alt+Shift+W, a new shortcut definition) on the last. The flow is the JPEG one: the render in a worker, the sheet, then the format's panel (Export PNG, Export JPEG or Export PDF, suggesting `.png`, `.jpeg` or `.pdf`), the write in a worker, one alert titled "Couldn’t export" for a failure past the render. `UI/ExportAsSheet` (Swift's sheet, renamed from the JPEG one): titled Export As; the format as three checkable buttons; the size as two `PickerField`s keeping the proportions (a number applies on Return or leaving, else both fields show the size again; a scrub or menu replaces their typing) and a scale menu of 25, 50, 75, 100 and 200%, every scale held between a hundredth and the side and surface limits; Quality for JPEG alone (saved only from a JPEG export), the page size in inches at the document's DPI for PDF alone, the background swatch ("Export Background") for JPEG and PDF; "Transparency kept · sRGB" for PNG, else "sRGB"; a change of format or size asks again only when the settings change, as Swift's `task(id:)`. The encoding resizes first (`ImageExporter::resized`: smooth, the same raster at the same size, `tooLarge` past the limits) and encodes by format (`pngData`; `jpeg`; `pdfData` over `flattened` for the preview). `JPEGPreview` takes the export's size beside its image (`setImage(image, pixels, updating)`), so Fit and 100% follow the chosen size, and draws Swift 1.4.8's checkerboard (20-point tiles of grays 0.30 and 0.35, anchored at the image) behind what it shows. Swift's PDF comes from `CGContext`'s PDF writer; `QPdfWriter` rounds a page to whole points, so `ImageExporter::pdfData` writes PDF 1.4 by hand: one page whose MediaBox is the print size in points (72 / resolution a pixel, 72 when none), the background as a filled rectangle when given, the pixels as a Flate RGB image with a gray soft mask, straight colour (adaptation; checked against poppler's `pdfinfo` and `pdftoppm`). A Flate stream that `qCompress` cannot make (it returns nothing when memory runs out) is `ExportError(render)`, never an empty stream. A zoom set before the first image (Ctrl+1 while the sheet still encodes) centres that image when it lands, as Swift's preview, made with its first result, centres on appearing. The page's inches round as Swift's formatter: its shortest digits, halves to even (45 pixels at 72 DPI are 0.62 in). A size field reads Swift's `Int` field (`typedInteger`, checked against Swift 6.0.3): a sign, decimal digits of any script read by code point, a fraction and an exponent (its leading zeros dropped), the number truncated toward zero and exact within Int64 (`1e3` is 1000, `12.7` is 12, `-9.223372036854775808e18` is Int64's least, `1e-400` is 0, `128e` is 128), grouping refused; known difference: ICU's lenient parse also reads a leading number before other text (`7abc` is 7, `1,000` is 1, `--5` is −5), where the twin puts the size back; and ICU rounds some long fractions before Swift truncates them (`199.99999999999999` is 200, `0.99999999999999999` is 1, while `9007199254740993.5` stays …993 and `12.99999999999999` stays 12), a rule the measured cases do not pin down, so the twin truncates the digits as typed. Encoding finds no memory as `ExportError(render)` at `encode`'s door, so Export As's PNG worker never meets a `bad_alloc`. Equivalent mutant: the parser's `point <= 0` as `< 0` (a point at zero leaves no digits, which read as 0). Tests: `ExportPDFTests` (the page, its size, the straight pixels and alpha, rows and channels, the background, a refusal, resizing, flattening), `ExportPDFFailureTests` (a binary of its own: a 360-pixel PDF, small enough that Qt converts it on the calling thread (a pool thread that cannot start hangs Qt 6.4's conversion), under limits from 64 KB to 4 MB throws or inflates whole, both reached, and a PNG likewise throws or matches the unstarved file), `ExportAsSheetTests` (formats and their rows and bytes, the size and its limits, the checkerboard, the page against Swift 6.0.3's formatter), `JPEGPreviewTests::aZoomBeforeTheFirstImageCentresIt`, `ProjectExportTests` (each format through the controller, the menu's Export As on the last format), the entry in `CompositorMenusTests` and `KeyboardShortcutsTests` (its chord, group and place). Equivalent mutants: none found.
- Filter › Scanlines (14.19, Swift 1.4.9: `06c3a22`, `1187736`, `cdde327`): Scanlines leaves Dither for a filter of its own. `kernels/DitherPixels.c/.h` are `cdde327`'s, with 13.17's adaptation carried on: Swift's GCD blocks become static band functions with context structs (`scanlines_tone_band`, `line_scan_band`, `blur_band`, `spread_band`, `column_band`, `split_band`), each failure flag an `atomic_int`, every band through `dither_bands`; the arithmetic is Swift's word for word, and `dither_glow`'s new easing near white came with it. `ScanlinesTests::theKernelDrawsSwiftsOwnBytes` holds that: Swift's own file, built unchanged with clang's blocks and libdispatch in the `swift:6.0.3` image, drew five scenes (37 by 53 and 70 by 300, every setting, both directions of Displace, 32 bands of columns, lines and rows) and a glow, and the twin's bytes hash the same (FNV-1a). `Document/Scanlines.h/.cpp` twin Swift's file: `ScanlinesSettings` with Swift's fields, defaults (glow 0 now, thickness 70, smoothness 50) and clamps, Line Spacing and Color Split rounded, `apply` handing the kernel the normalized settings and Two Colors' bytes (black on white otherwise), then 13.17's `glowing` moved here unchanged. `DitherStyle` loses `scanlines` (`usesPixelSize` is ASCII alone, `drawsMarks` every style without tones) and `DitherSettings` its four fields. `FilterKind::scanlines` ("Scanlines") sits after Dither in Swift's order, so the Filter menu lists it there; it previews whole, as Dither does. Its two colours reuse Dither's picker (`openDitherColorPicker`, `setDitherColor`, Swift's), choosing the edit's own settings, and the titles drop "Dither" (`Color Picker (Light Color)`, `(Dark Color)`). The sheet's `scanlines` (`UI/FilterSheet+Scanlines.cpp`) is Swift's `scanlinesControls`: twelve rows with Swift's ranges, units and help, then the Colors menu and swatches, which `colorChoices` builds for Dither too (the screen's and the lines' colour help for Scanlines). Equivalent mutant: `glowing` run at glow 0 (it adds nothing). Tests: `ScanlinesTests` (Swift's eight, Swift's own bytes, each setting reaching its field, Two Colors rounded, every clamp through `FilterSettings` too, clear pixels, the eased glow against a full-size blur), `ScanlinesSheetTests` (the menu place and a commit, the sheet's rows, ranges and fields, both pickers), the order in `FilterModelTests`, `FinishingFilterTests`, `DitherSheetTests` and `CompositorMenusTests`, the acceptance run's Scanlines row through the menu.
- The Navigator (14.20, Swift 1.4.9: `6490bb3`, `3e09948`; `3c6cf5e`'s conflict resolved toward the minimap, which supersedes the panel): `Rendering/NavigatorGeometry.h/.cpp` twin Swift's file: `NavigatorGeometry` (the document fitted and centred in a box, `thumbnailRect`, `documentPoint` held to the document's edges), and `CanvasViewport::visibleDocumentRect` and `centerView`, declared in `CanvasViewport.h` and defined there, as Swift's extension; `centerView` goes through `translate`, so the view stops following Fit. Swift's `min` and `abs` in `visibleDocumentRect` are dead (the viewport never flips) and left out. `EditorSession::navigatorImage` (`EditorSession+Navigator.cpp`, its fragment `EditorSession+Navigator.h`) twins Swift's: the document drawn at the picture's size, never enlarged, each side rounded, a pixel at least, every layer and placed mask scaled by its axis, blur radius and motion distance by the width's, through a bare session's `drawLiveComposite` so an edit in progress stays out; a context that cannot be allocated, or a painted layer that cannot flatten while it draws, logs on `omaphoto.rendering` and gives no picture, where Swift's `try?` is silent (`NavigatorFailureTests`, a binary of its own: a 4000 by 3000 painted layer under 8 MB of room, the minimap shown without a picture). Swift's medium interpolation on the context is dead here (`LayerRenderer` sets each layer's own) and left out. `UI/NavigatorMinimap` twins Swift's view: a `QWidget` in `ContentView`'s canvas overlay (the mask badge's layout, its margins 12, top right), following the session's `changed()` itself: shown while View › Navigator holds (`navigator.visible` in `QSettings`, Swift's `AppStorage`, off at first), Canvas Only is off, a document is open and the zoom is 300% or more; hidden it drops its picture, key and wait, as SwiftUI drops the view. Its size is `sizeFor` (Swift's `size(for:)`: `QWidget::size` takes the name), fitted inside 126 by 90 and rounded; it fills gray 0.11, draws the 300-pixel picture smoothly over its whole box, the view's box in the accent 2.5 wide inside its rect (three points at least), clipped to a rounded rect of 4, then a 25% white rim. Swift's `task(id:)` is `RenderKey` (the document's id, size and layers and `brushRevision`, so a selection or guide redraws nothing): the first picture renders at once, each later key after a 250 ms single-shot precise `navigatorWait`. A left press or drag centres the view on the point (`centerView`, then `notify`), the arrow its cursor over the canvas's. Known difference: Swift's shadow (radius 6) has no twin, since a widget paints only inside itself. View › Navigator (300% and above) is a checkable entry before Pixel Grid that writes the setting and notifies; the command palette finds it. Tests: `NavigatorTests` (Swift's nine; the geometry's edge cases, both axes and a placed mask scaled, a layer and its mask off the origin, a streak shrunk at its edge, both blurs by the width's factor where the two factors differ, centring at a backing scale of two, the wait restarted by a later edit, a transform in progress left out, the minimap's showing, waiting, clicks, cursor, box, gray and rim), `ViewMenuTests::theNavigatorEntryShowsTheMinimap` (the entry's place and words, the minimap 12 points in at the canvas's top right), the View menu's count in `CompositorMenusTests`. Equivalent mutants: none found.
- Effects, text and shapes kept (14.22, Swift 1.4.9: `94e90bd`): `ImageLayer(record, asset, mask)` loads its live shape and text (`LayerShape::loaded`, `LayerText::loaded`), Swift's `ProjectSnapshot.documentLayers`, so `installProject` and `applyDocumentSize` (Canvas Size, Image Size, Crop, Trim) build the same layers; the canvas resizer carries effects, and Image Size scales them by the root of its two factors through `LayerEffects::scaled` (stroke, glows and blurs to 500, distances to 5,000, Photoshop's Scale Styles) while its resampling still flattens text and shapes, and a size that changes only the resolution keeps all three. Hue/Saturation, Levels, Invert and a floating merge keep the layer's effects. `docs/project-format.md` says so. Tests: `LayerEffectsSurviveTests` (Swift's five sizing cases; a stretch's mean factor and the ranges), `LayerEffectsTests::rebuildsAndResizesKeepThem` (the four rebuilds, both resizers), the shape and text halves in `ShapeLayerTests` and `TypeStoreTests`.
- WebP (14.23, Swift 1.4.9: `5724099`): `ImageImporter` takes Qt's `webp` format beside JPEG, PNG and TIFF (`qt6-image-formats-plugins` carries it, as it carries TIFF; Fedora's `qt6-qtimageformats`, Arch's `qt6-imageformats` and nixpkgs' `qtimageformats` too), through the same reading, sRGB conversion and thumbnail; a dropped picture prefers WebP after TIFF and before Photoshop's, Swift's order; the unsupported and drop messages name WebP; the importer's filter offers `*.webp` and the desktop entry `image/webp`. Tests: `WebPImportTests` (Swift's lossless and lossy pictures: size, sRGB, the clear half, the budget, a drop opening a document; the drop order; its `main` points `TMPDIR` at a folder of its own, since `ImageFileDropTests` counts the dropped copies in the shared one), the words in their files.
- Ctrl or Meta with a middle drag zooms (14.24, Swift 1.4.9: `e03923f`, Blender's gesture): a middle press with Ctrl or Meta (Swift's Command or Control) keeps `m_middleZoom` (where it began, the zoom then) in place of the middle pan; each move with the middle button zooms by 2^((start.y − y) / 100) about the press, as the Zoom tool's drag doubles every 100 points, up in and down out, under the Zoom tool's magnifier, the minus while below the press; the brush circle hides meanwhile; the release, or a move without the middle button (Qt can lose a release), ends it and puts the tool's cursor back, as the pan's does. A left drag beside it goes on, and a right-button tip drag waits, as beside the pan. Tests: `CanvasNavigationTests::ctrlOrMetaWithTheMiddleButtonZooms` (doubling and halving about an off-centre press, from 75% too, both cursors against the Zoom tool's, Meta, a lost release, a plain middle drag still panning, a Hand drag going on and a tip drag waiting beside it).
- Camera Raw's Light and Color as Photoshop's (14.25, Swift 1.4.9: `57ab3bb`): `kernels/AdjustPixels.c/.h` were 57ab3bb's: Exposure, white balance and Contrast ran as one composed table before Highlights and Shadows, Whites, Blacks, Saturation and Vibrance as another after (one table since 14.26). `Document/CameraRawTables.h/.cpp` twin Swift's enum as a namespace: Photoshop's colour tables, 147 of 17³ (`size`), composed over a 33³ `grid`; `tables()` reads them once, `whiteBalance` (four tables round Temperature and Tint, 9 × 9 by 25), `stages` (Swift's order, sliders at zero left out), `compose` (empty without stages, the kernel then handed none; a grid that finds no memory is `ExportError(render)`) and `neutralize` (Swift's search: steps of 10, 2 and 0.5 round the best, the first of equals kept, counted by index where Swift strides, the same values). Swift's bundle resource is a Qt resource compiled into the library (`src/Resources/CameraRawTables.bin`, `qt_add_resources`), so it is always there: Swift's nil-table paths (no tables, no Light and Color, no neutral balance) have no twin, and a header or size that does not match is a `std::logic_error`, a broken build. Its planes were raw DEFLATE until 14.27 (xz since, below). `CameraRawSettings` loses `gains` and its three constants and `neutralizeLinear`; `neutralizeStraight` is the tables' search, and Auto hands it the linear mean encoded again (`encode`), as Swift's. The `Balance` is `CameraRawSettings::Balance`, where Swift's returns a tuple. Tests: Swift's three changed tests in `CameraRawTests` (exposure, translucent too, contrast, whites, blacks, saturation by Photoshop's numbers) and `whiteBalanceAndVibranceMatchPhotoshop`, 181 baked in `CameraRawSessionTests`, `CameraRawTablesTests` (the decoded tables' FNV hash and entries, stages and weights, each slider's tables, past the range, the composed corners, `neutralize`'s exact answers, refinements past −100 on either slider, each channel missing, ties among searched points, three stages composed in order, all from an independent Python decoder and search) (since 14.26 `CameraRawWiringTests::theTableReadsThePicturesBrightness`) and Auto through the encoded mean, a dark pixel on its straight segment.
- Adaptive Contrast, Highlights and Shadows; Dehaze, Texture, Clarity and Sharpening (14.26, Swift 1.4.9: `75147a2`): `kernels/AdjustPixels.c/.h` were 75147a2's and the tables its 285 (`CRT2`, 1.4 MB; 763 since 14.27): Contrast, Highlights and Shadows each blend four tables round their value (−100, −50, −25, 0, 25, 50, 100) and the picture's brightness (53, 80, 104, 130, 160, 190, 224 in sRGB levels, `between` over points, held at the ends, the lower pair taking a point whole), read before any stage by `camera_raw_statistics` (`CameraRawTables::brightness`, `Brightness`: the brightest channel's mean for Contrast, the log-average for Highlights, the luminance for Shadows), only when one of the three is set, else 128 each; every Light and Color slider is now one composed table (`stages` returns one list in Camera Raw's order) and `adjust_camera_raw` takes it alone. Dehaze removes or adds the haze colour of the brightest one percent and leaves one colour be; Texture and Clarity are guided filters at radii in proportion to the picture, so the preview's scale no longer reaches them (Glow's still does); Sharpening sharpens everywhere at Masking 0. These live in Swift's kernel, unchanged. The 14.16 tone curve is gone with it, and its curve test. Equivalent mutant: reading the brightness when none of the three is set (the stages then never read it). Tests: Swift's changed `CameraRawTests` (Highlights and Shadows by Photoshop's numbers, Dehaze on a shadow under a pale sky, one colour left be; pinned beside them: Highlights alone reading the picture, Highlights before Whites, a faint step sharpened at Masking 0) and `CameraRawToneOrderTests` (Highlights −100's white), `CameraRawTablesTests` (the 285 tables' hash and entries, two colours' three measures against Python, each slider's tables, the adaptive blends from an independent Python run, held ends and a measured point, three stages composed, mid gray's brightness), `CameraRawWiringTests::theTableReadsThePicturesBrightness` (the grade against the kernel through the picture's own brightness, not the default), `CameraRawRulesTests` (Glow reads the scale, Clarity no longer).
- Camera Raw's Color Mixer, Calibration, curve, vignettes and Color Grading as Photoshop's (14.27, Swift 1.4.9: `bdfb9c2`, `9dac4f1`, `7b5a592`, taken as their net change): `kernels/AdjustPixels.c/.h` were 7b5a592's (a stage holds up to eight tables, `adjust_camera_raw_curve_color` takes the Mixer's and Grading's composed tables, `adjust_camera_raw_optics` Exposure's tables for the lens vignette, the post-crop vignette darkens by a gain in linear light) and the tables its 763 (`CRT3`, 1.25 MB): a header of ten bytes (size, count, `step`), each entry a running difference in steps of `step` levels read as a signed byte and clamped, the planes an xz stream as Foundation's `.lzma` writes it, unpacked through liblzma (`lzma_stream_buffer_decode`, `LibLZMA::LibLZMA`; `liblzma-dev` in the dev image, `xz-devel` and `xz` in the Fedora and Arch images, `xz` in the PKGBUILD and the flake); the app no longer links zlib. `CameraRawTables` gains Swift's `blend` (up to eight parts; a null table is no change), `exposureTables`, `fifty` (−100 to 100 by 50), `gradingStages` (the four luminance sliders, then the global, midtone, highlight and shadow wheels: twelve hues by 30, saturations 25, 50 and 100 below which the lower is no change, the shadow and highlight wheels between Blending 0, 50 and 100, Balance as Swift's fitted shares of saturation, a wheel with no share left out) and `mixerStages` (luminance, saturation, hue, eight colours each); `stages` opens with Calibration's six primaries and ends with the parametric curve's four regions while `CameraRawCurveSettings::hasMeasuredDividers` holds (dividers 25, 50, 75 and Refine Saturation 0), when `toneTable` draws the point curve alone. `applyCurveColor` hands the kernel zeros for the Mixer's and Grading's formulas and their tables in their place, and their serializers (`mixerFloats`, `gradeFloats`), which Swift keeps without a caller, are gone; `applyCalibration` draws Shadows Tint alone; `apply` runs the Light table for Calibration or a measured curve too. Equivalent mutants: the shares' floor of zero (a negative share fails the `> 0` test as zero does) and `gradingStages`' `fmod` (a normalized hue lies within 0 to 360, where 360 wraps alike). Tests: Swift's `mixerCalibrationCurveAndVignettesMatchPhotoshop` in `CameraRawTests`; `CameraRawTablesTests` against an independent Python decoder of `CRT3` (the hash and entries; every earlier exact value redone on the stepped tables; the Mixer's, Calibration's, the curve's and Grading's tables and weights, a wheel wrapping past 330, Blending 0's own tables, Balance ±50 weighing each wheel's share and +100 leaving the shadows out, every Calibration binding, midtones' and highlights' luminance, Calibration, Light and the measured curve in order, moved dividers); the Highlights pins, Auto's dark pixel and the grading luminance by the same Python; `CameraRawCurveTests` (the tone table as measured and with dividers moved), `CameraRawLensTests` (Shadows Tint by the kernel, primaries through the table), `CameraRawRulesTests` (the clip views keep Calibration's table), `CameraRawWiringTests` (the curve call's tables).
- Camera Raw's preview 5 to 20 times faster (14.28, Swift 1.4.9: `a8630e4`): `kernels/AdjustPixels.c/.h` are a8630e4's, with 13.17's adaptation carried on: Swift threads its passes through GCD blocks (`in_bands` over `dispatch_apply`, and `dispatch_apply` alone for the statistics and the scope), which GCC cannot compile, so each block's body moved word for word into a static band function (`compose_band`, `compose_onto_band`, `camera_raw_band`, `blur_rows_band`, `blur_columns_band`, `effects_luma_band`, `effects_band`, `curve_color_band`, `detail_luma_band`, `sharpen_luma_band`, `sharpen_band`, `optics_band`, `calibration_band`, `statistics_band`, `scope_band`) whose context struct carries what the block captured, handed over as a compound literal, with Swift's split kept (one band under 64 items, else 32; 32 and 16 for the two sums); the bands run through `adjust_bands`, a C hook in `Document/ImageAdjustments.cpp` on `PoolMap::blocking`, as Dither's do. `CameraRawSettings::apply` takes Swift's known brightness (`std::optional<CameraRawTables::Brightness>`, read before Calibration now, measured there when none comes), composes the Light table always, and folds Curve, the Mixer and Grading into it (`foldingColor`: from `CameraRawTables::identity` when Light made none, the curves through `camera_raw_table_curves`, the Mixer's and Grading's stages through `camera_raw_compose_onto`) unless a point colour, Refine Saturation, a point-colour view, a clip view or the sharpen mask needs the curve pass. `CameraRawTables::Brightness` is declared in `CameraRaw.h`, reopening the namespace, since `apply`'s signature needs it and `CameraRawTables.h` holds the kernel's types, which only the core sees; `brightness(QImage)` draws the picture and measures it, and a picture that cannot be drawn logs on `omaphoto.rendering` and reads 128, where Swift's `try?` is silent. `FilterJob` carries `cameraRawBrightness`, which `previewJob` fills for Camera Raw from `FilterEdit::previewBrightness`, measured once and kept by the preview source's `cacheKey` (Swift keys it by `previewSourceVersion`, which the twin dropped in 13.8). `CameraRawScope::make` reads every other pixel each way through `camera_raw_scope`, straight from an `RGBA8888_Premultiplied` image's bytes, else drawn first; each band starts its own rows, so a short picture counts every row. Equivalent mutants: `foldingColor`'s `shape.adjusts()` (an identity curve leaves the table within a float's rounding), the cache's key (Camera Raw's preview source never changes while its panel is open), the band counts of the `in_bands` split (they only spread the work; the two sums' counts are not: one band reads only its own rows). Tests: `CameraRawFoldTests` (the fold against the kernel's own calls, from identity too; a point colour, Refine Saturation and a point-colour view keeping the pass; the brightness read before Shadows Tint and a known one used as handed; the session's job carrying the preview source's brightness and none for other kinds; every band of a forty-row ramp measured and counted; the calibration kernel's blue hue and saturation by their own effects; a job's brightness reaching the grade), `CameraRawFoldFailureTests` (a binary of its own, under `AddressSpaceLimit`: a measured preview source not drawn again, a premultiplied scope read in place), the scope's sampling and both formats in `CameraRawWiringTests`, the panel's ramp two pixels a level in `CameraRawPanelTests`.
- The blank start tab and low Untitled numbers (14.29, Swift 1.4.9: `d0ec2f3`, issue #231): `ProjectWorkspace::canClose` is Swift's: the only tab, with no document and no path, has nothing to close, so `close` (the tab's cross, Close Project) leaves it be; the counter (`m_nextNumber`) is gone, and a new tab takes `freeUntitledName`, "Untitled" when no tab shows it, else the lowest "Untitled N" free, so closing the last project leaves "Untitled" again. `ProjectTabButton::synchronize` hides the cross while the tab cannot close and closes the pill up round its name, 11 a side (Swift's label + 22 against + 40): the title button 3 wider, the layout's right margin gone. Tests: Swift's `untitledNumbersStayLowAndTheBlankTabStays` in `ProjectWorkspaceTests` (a saved but empty tab closes, a project's own "Untitled 4" taken), `ProjectTabStripTests::theBlankStartTabHasNoCloseButton`, the closing flows and titles brought to the new numbers in `ProjectWorkspaceFlowTests`, `ProjectWorkspaceViewTests` and `ProjectTabStripTests`.
- Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to <title>"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is the mean of its edge pixels, as Swift's `edgeTone`. `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
- `CompositorMenus` builds the window's `QMenuBar` from Swift's `.commands`, entry for entry, as far as the session functions exist (`TASKS.md`, "Menu items owed", lists the rest); ⌘ is Ctrl and ⌥ is Alt in every shortcut. Its entries follow the front tab's session, like the toolbar. Swift's Undo and Redo keep a text field's own undo while one has focus, so the twin's do the same: with a `QLineEdit` in focus the entries are the field's, titled bare and always enabled; otherwise they carry the step's name, and only while the session's history is usable. Focus is state the session does not announce, so the menus also sync on `QApplication::focusChanged`. Keyboard navigation (Alt+E) hands focus to the menu bar itself, so the menus keep the field that held it until focus lands anywhere else. The View menu's Pixel Grid entry is checkable and follows `showsPixelGrid`. `Close Project` closes the tab (Swift's `close(window)` hands over to the workspace); `Delete Layer` is Swift's `deleteLayerOrMask`, which takes a chosen effect first since 9.6a; since 9.6d its title names that effect (`Delete Stroke`). The `Hide` entries and `Check for Updates` have no twin (the desktop hides windows; distro packages update).
- The session's gates (`canEditLayers`, `canUseHistory`) and its `selectLayer`, `selectTool`, `undo` and `restore` read only the state that exists so far. `TASKS.md` lists, under "Session hooks owed", the term each later item adds. `reorderLayers` takes a `std::set<int>` for `IndexSet` and reproduces SwiftUI's `move(fromOffsets:toOffset:)`.
- `LayerMask::clipImage` returns nil when the placed grid cannot be rendered, as Swift's `try?` does, and logs a warning on `omaphoto.rendering` so the loss is visible.
- `Rendering/SeparableBlend` twins Swift's file for the modes QPainter lacks since 1.2.2: `needsSurface` names Linear Burn, Linear Dodge (Add), Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract and Divide, and `channel` is Photoshop's formula for each on one channel (in Vivid Light a zero divisor is infinity, clamped to the end; Divide by black is white, zero over zero included, a choice of the twin's, since neither Photoshop nor Core Image publishes it). Swift reaches Core Image's filters, in sRGB as Color Burn and Dodge have been since 1.2.0 (`121a15f`); Color Burn and Dodge themselves stay QPainter's, which follow the PDF formulas, as do its Hard Light and Exclusion (`BlendModeTests::everyBlendModeMatchesThePdfFormulas` holds all twenty-four modes within 2/255 against formulas written apart). Known difference: Core Image's code is not published, so its edges (Hard Mix exactly at one, a divisor of zero) follow Photoshop's definitions, not measured Core Image. Hard Mix is Photoshop's at full fill, white where backdrop and source reach one together.
- `Rendering/HslBlend` is Linux-only: the modes QPainter lacks (Hue, Saturation, Color and Luminosity, and since 1.2.2 `SeparableBlend`'s eight), blended by hand over a copy of the backdrop with the PDF's compositing, so every path that hands a mode to `HslBlend::handles` (the export, the canvas's surface, live masks, effects) takes the new ones.
- `Rendering/SeparableBlend` twins Swift's file for the modes QPainter lacks since 1.2.2: `needsSurface` names Soft Light (since 13.9a), Linear Burn, Linear Dodge (Add), Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract and Divide, and `channel` is Photoshop's formula for each on one channel (in Vivid Light a zero divisor is infinity, clamped to the end; Divide by black is white, zero over zero included, a choice of the twin's, since neither Photoshop nor Core Image publishes it). Swift reaches Core Image's filters, in sRGB as Color Burn and Dodge have been since 1.2.0 (`121a15f`); Color Burn and Dodge themselves stay QPainter's, which follow the PDF formulas, as do its Hard Light and Exclusion (`BlendModeTests::everyBlendModeMatchesThePdfFormulas` holds all twenty-four modes within 2/255 against formulas written apart). Known difference: Core Image's code is not published, so its edges (Hard Mix exactly at one, a divisor of zero) follow Photoshop's definitions, not measured Core Image. Hard Mix is Photoshop's at full fill, white where backdrop and source reach one together.
- `Rendering/HslBlend` is Linux-only: the modes QPainter lacks (Hue, Saturation, Color and Luminosity, and `SeparableBlend`'s nine: eight since 1.2.2, Soft Light since 13.9a), blended by hand over a copy of the backdrop with the PDF's compositing, so every path that hands a mode to `HslBlend::handles` (the export, the canvas's surface, live masks, effects) takes the new ones.
- Known difference: CoreGraphics enlarges "High quality" layers with a sharper kernel; QPainter offers bilinear only, so `InterpolationQuality::high` and `low` draw alike when enlarging. Reductions are sharp on both (halvings).
- Where an Apple framework threads internally (vImage, Core Image, Metal), the twin threads with QtConcurrent.
- A Swift error enum becomes an exception class with a `Kind` enum; `what()` carries the Swift `errorDescription`.
@@ -145,10 +225,11 @@ Deliberate name breaks: `MetalBrushCoverage` → `BrushCoverage`, `MetalLayerEff
## Linux-only behaviour
- Tool icons: SF Symbols have no Linux twin, and icon themes differ by distro, so `src/UI/ToolIcons` draws each tool's icon as paths in an 18-point square, in one colour that the caller takes from the palette (the Omarchy theme retints them for free). Swift's two hand-drawn icons keep their geometry there (`GradientToolIcon`'s Floyd–Steinberg fade, `CloneStampToolIcon`'s four parts); their Swift homes (`GradientControls`, `BrushControls`) arrive later. The eyedropper's glyph lives in `Rendering/EyedropperIcon`, since the canvas's cursor draws it too. Icons that follow tool state (ellipse marquee, polygonal lasso, eraser) arrive with that state. `tests/fixtures/ToolIcons.png` is the reference sheet: `ToolIconTests` redraws it, allows a font a few pixels and a stroke none, and writes the fresh sheet beside the test binary; after a wanted change, look at it and commit it.
- Tool icons: SF Symbols have no Linux twin, and icon themes differ by distro, so `src/UI/ToolIcons` draws each tool's icon as paths in an 18-point square, in one colour that the caller takes from the palette (the Omarchy theme retints them for free). Swift's two hand-drawn icons keep their geometry there (`GradientToolIcon`'s Floyd–Steinberg fade, `CloneStampToolIcon`'s four parts); Swift keeps them in `GradientControls` and `BrushControls`, and the twin keeps them with the rail's other icons. The eyedropper's glyph lives in `Rendering/EyedropperIcon`, since the canvas's cursor draws it too. Icons that follow tool state (the ellipse marquee, the polygonal lasso, the Magic tool's object mode, the eraser) take a `ToolIconKind`. `tests/fixtures/ToolIcons.png` is the reference sheet: `ToolIconTests` redraws it, allows a font a few pixels and a stroke none, and writes the fresh sheet beside the test binary; after a wanted change, look at it and commit it.
- Omarchy theme: `src/UI/OmarchyTheme` reads `~/.local/state/omarchy/current/theme/colors.toml` (Omarchy 4.x: flat `key = "value"` lines, `mode` dark or light, the twenty-two stock themes' keys required, `orange` optional and `yellow` its stand-in) into `OmarchyColors`, applies them as the application palette (Window and Text from `background` and `foreground`, Base from `darker_background`, Button from `lighter_background`, Highlight and Link from `accent` with the background as highlighted text, PlaceholderText from `dark_foreground`, BrightText from `orange` or `yellow`, Mid from `muted`, the disabled roles from `muted`) and sets the Fusion style, which follows a palette on every desktop where a platform style does not; no stylesheet is needed. Keys and values read in double or single quotes. A missing key, or a recognised key whose value is bare or half-quoted or no `#rrggbb`, refuses the file with the key's name; a file that exists but cannot be opened, or sits behind a folder that cannot be searched (`stat` fails other than `ENOENT`), is refused too; either way the app keeps the palette it has and logs a warning, where an absent file means the built-in dark, Swift's grays, installed before the first load. `omarchy-theme-set` removes `current/theme`, moves `next-theme` in and writes `theme.name`, so the watcher holds `current/`, `current/theme` and the file, adds them back after each load (a removed path leaves a `QFileSystemWatcher`) and renews the file's watch every load (it follows the inode, which an atomic replacement swaps), watches the nearest ancestor the watcher accepts while `current/` is missing or barred, letting it go once the directory is watched, and loads once 100 ms after the last event. A read that stops short (an error, or fewer bytes than the file holds) is refused like a malformed file. Each load logs the theme's name and mode on `omaphoto.app`. `main` makes the theme before the window. Widgets take colours from the palette, never from literals; the canvas keeps Swift's document grays. The UI font stays Qt's default: `omarchy font current` is not followed.
- No updater. Distro packages own updates.
- Distribution (11.4b): releases live on GitHub under fixed asset names (`omaphoto-noble_amd64.deb`, `omaphoto-resolute_amd64.deb`, `omaphoto.x86_64.rpm`, `omaphoto-x86_64.pkg.tar.zst`; 1.0.0 had one DEB, `omaphoto_amd64.deb`) with `SHA256SUMS`, so `releases/latest/download/<name>` always reaches the newest. `scripts/install/fedora.sh` and `arch.sh` read `/etc/os-release` (`ID` or `ID_LIKE`; Omarchy says `ID_LIKE=arch`) and `ubuntu.sh` its `UBUNTU_CODENAME`, which Ubuntu's derivatives set to their base: each DEB names its release's Qt libraries (24.04's carry a `t64` suffix that 26.04's drop, and Debian 13 has neither), so the release carries one DEB per LTS, built on it (`Dockerfile.dev` takes the base as `BASE`), and the script takes the one whose codename matches, `noble` or `resolute`, refusing any other. Each refuses another system or another machine than x86_64, verify the package against the sums and install it with apt, dnf or pacman under sudo, so the package manager resolves dependencies; apt reads the file as its own user, so the download folder is opened to it. Fedora's script names RPM Fusion's `libheif-freeworld` when it is missing, since Fedora's libheif decodes no HEVC, and adds no repository itself. `nixos.sh` installs the flake at the newest release's tag, which GitHub's `releases/latest` redirect names, into the user's profile (`nix profile install`, the name every Nix still takes), so no user builds unreleased work from `main`. The README runs each script after downloading it, so the package manager's questions read the terminal and fish runs it too. `release.yml` builds each package with the command the distro checks use, the DEBs on the dev image over each base, the RPM on Fedora 43, the oldest Fedora still supported, whose Qt symbols a later Fedora keeps, the Arch package through `makepkg` as a user; the version is CMake's `project` version, the PKGBUILD's and the flake's, kept equal by hand, and the workflow's first job refuses a tag that names another.
- Custom cursors (`QCursor` pixmaps) come out garbled on the hardware cursor path with NVIDIA and Hyprland at a fractional scale; theme cursors render correctly (issue #9, hyprwm/Hyprland#16413). README gives the software-cursor setting; the app keeps its cursors and does not guess at drivers (the user's decision, 13.1).
- Distribution (11.4b): releases live on Gitea (git.zacharyzhang.com) under fixed asset names (`omaphoto-noble_amd64.deb`, `omaphoto-resolute_amd64.deb`, `omaphoto-fc43.x86_64.rpm`, `omaphoto-fc44.x86_64.rpm`, `omaphoto-x86_64.pkg.tar.zst`, `omaphoto-x86_64.AppImage` since 1.4.5; 1.0.0 had one DEB, `omaphoto_amd64.deb`, and 1.0.0 to 1.2.3 one RPM, `omaphoto.x86_64.rpm`) with `SHA256SUMS`. Gitea has no `releases/latest/download`, so each script asks the API for the newest release's tag (`/api/v1/repos/ZacharyZhang-NY/OmaPhoto/releases/latest`) and downloads from `releases/download/<tag>/<name>`. `scripts/install/arch.sh` reads `/etc/os-release` (`ID` or `ID_LIKE`; Omarchy says `ID_LIKE=arch`), `fedora.sh` its `ID` or `ID_LIKE` and `VERSION_ID`, taking the RPM of release 43 or 44 and refusing any other, since each names its release's LibRaw (`libraw_r.so.23` on 43, `.25` on 44; found in 12.16, when 1.2.3's single RPM from Fedora 43 would not install on 44), and `ubuntu.sh` its `UBUNTU_CODENAME`, which Ubuntu's derivatives set to their base: each DEB names its release's Qt libraries (24.04's carry a `t64` suffix that 26.04's drop, and Debian 13 has neither), so the release carries one DEB per LTS, built on it (`Dockerfile.dev` takes the base as `BASE`), and the script takes the one whose codename matches, `noble` or `resolute`, refusing any other. Each refuses another system or another machine than x86_64, verify the package against the sums and install it with apt, dnf or pacman under sudo, so the package manager resolves dependencies; apt reads the file as its own user, so the download folder is opened to it. Fedora's script names RPM Fusion's `libheif-freeworld` when it is missing, since Fedora's libheif decodes no HEVC, and adds no repository itself. `nixos.sh` installs the flake at the newest release's tag (`git+https://…?ref=refs/tags/<tag>`) into the user's profile (`nix profile install`, the name every Nix still takes), so no user builds unreleased work from `main`. The README runs each script after downloading it, so the package manager's questions read the terminal and fish runs it too. `scripts/release.sh vX.Y.Z` (with `GITEA_TOKEN` set, the tag already pushed) builds each package from `git archive` of the tag with the command the distro checks use, the DEBs on the dev image over each base, the RPMs on Fedora 43 and 44 (`scripts/distro/fedora/Dockerfile` takes the release as `RELEASE`, 43 by default), the Arch package through `makepkg` as a user, the AppImage on the noble dev image (`scripts/distro/appimage`, linuxdeploy and its Qt plugin pinned by checksum; `scripts/appimage.sh` adds Qt's Wayland shell, decoration and graphics plugins and libheif's HEVC plugin, and `packaging/appimage/omaphoto.sh`, an AppRun hook, puts the AppImage's `usr/share` first on `XDG_DATA_DIRS` so the model is found, and sets `LIBHEIF_PLUGIN_PATH`), then writes `SHA256SUMS`, creates the release and uploads every file; the version is CMake's `project` version, the PKGBUILD's and the flake's, kept equal by hand, and the script refuses a tag that names another.
- Remove Background runs an open-licensed model on ONNX Runtime.
- A `.comp` project is a directory on Linux; its layout matches the macOS package.
- Shortcuts map ⌘ to Ctrl and ⌥ to Alt.
+53 -7
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.22)
project(omaphoto VERSION 1.2.3 LANGUAGES C CXX)
project(omaphoto VERSION 1.4.9 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -11,7 +11,7 @@ endif()
option(OMAPHOTO_WERROR "Treat warnings as errors" OFF)
find_package(Qt6 6.4 REQUIRED COMPONENTS Widgets Concurrent Test)
find_package(Qt6 6.4 REQUIRED COMPONENTS Widgets Concurrent Svg Test)
find_package(libheif REQUIRED)
find_package(Fontconfig REQUIRED)
find_package(onnxruntime REQUIRED)
@@ -31,6 +31,7 @@ add_library(compositor_core STATIC
src/Document/Distort.cpp
src/Document/DocumentHistory.cpp
src/Document/EditorSession.cpp
src/Document/EditorSession+Navigator.cpp
src/Document/EditorSession+Brush.cpp
src/Document/EditorSession+Imports.cpp
src/Document/EditorSession+Layers.cpp
@@ -44,18 +45,25 @@ add_library(compositor_core STATIC
src/Document/AdjustmentEditing.cpp
src/Document/LayerAppearance.cpp
src/Document/LayerGroups.cpp
src/Document/LayerGroups+Order.cpp
src/Document/LayerMask.cpp
src/Document/LayerMask+Alone.cpp
src/Document/LayerTransform.cpp
src/Document/LiveLayerMask.cpp
src/Document/PixelAdjust.cpp
src/Document/ProjectWorkspace.cpp
src/Document/ProjectWorkspace+Copy.cpp
src/Document/SelectionClipboard.cpp
src/Document/Crop.cpp
src/Document/DocumentLimits.cpp
src/Document/Guides.cpp
src/Document/ToolDefaults.cpp
src/Document/ObjectSelection.cpp
src/Document/Dither.cpp
src/Document/Scanlines.cpp
src/Document/Filters.cpp
src/Document/CameraRaw.cpp
src/Document/CameraRawTables.cpp
src/Document/CameraRaw+Session.cpp
src/Document/CameraRawColor.cpp
src/Document/CameraRawDetailOptics.cpp
@@ -77,6 +85,7 @@ add_library(compositor_core STATIC
src/Document/CloneStamp.cpp
src/Document/ColorPalette.cpp
src/Document/ContentFill.cpp
src/Document/CanvasRotation.cpp
src/Document/LayerFlip.cpp
src/Document/LayerMerge.cpp
src/Document/MagicWand.cpp
@@ -87,15 +96,19 @@ add_library(compositor_core STATIC
src/Document/SelectionEdits.cpp
src/Document/ShapeTool.cpp
src/Document/TypeTool.cpp
src/Document/TypeTool+Runs.cpp
src/Document/ColorRangeSelection.cpp
src/Document/SmudgeLiquify.cpp
src/UI/BlendModePicker.cpp
src/UI/BrushControls.cpp
src/UI/CropControls.cpp
src/UI/CanvasRulers.cpp
src/UI/CanvasSizeSheet.cpp
src/UI/GridSettingsSheet.cpp
src/UI/ImageSizeSheet.cpp
src/UI/TrimSheet.cpp
src/UI/JPEGExportSheet.cpp
src/UI/ExportAsSheet.cpp
src/UI/ExportAsSheet+Preview.cpp
src/UI/ByteCounts.cpp
src/UI/SliderSnap.cpp
src/UI/GradientControls.cpp
@@ -104,24 +117,35 @@ add_library(compositor_core STATIC
src/UI/CanvasThumbnail.cpp
src/UI/ColorPaletteControls.cpp
src/UI/ColorPickerSheet.cpp
src/UI/ColorPickerSheet+Dialog.cpp
src/UI/CommandPalette.cpp
src/UI/CommandPaletteView.cpp
src/UI/CompositorMenus.cpp
src/UI/LayerAppearanceControls.cpp
src/UI/LayerIcons.cpp
src/UI/LassoControls.cpp
src/UI/LayerMaskMenu.cpp
src/UI/NavigatorMinimap.cpp
src/UI/LayersPanel.cpp
src/UI/LevelsSheet.cpp
src/UI/HueSaturationSheet.cpp
src/UI/SampleButton.cpp
src/UI/ColorRangeSheet.cpp
src/UI/HueSaturationSheet+Spectrum.cpp
src/UI/CurvesControls.cpp
src/UI/NativeLayerList.cpp
src/UI/NativeLayerList+Buttons.cpp
src/UI/NativeLayerList+Cell.cpp
src/UI/NativeLayerList+Drag.cpp
src/UI/NativeLayerList+Effects.cpp
src/UI/NativeLayerList+Menu.cpp
src/UI/NavigationToolHeader.cpp
src/UI/NumericScrub.cpp
src/UI/HeldModifiers.cpp
src/UI/TransformInspector.cpp
src/UI/EffectsSheet.cpp
src/UI/CameraRawColorControls.cpp
src/UI/CameraRawColorControls+Curve.cpp
src/UI/CameraRawColorControls+Grading.cpp
src/UI/CameraRawColorControls+Mixer.cpp
src/UI/CameraRawControls.cpp
@@ -132,6 +156,10 @@ add_library(compositor_core STATIC
src/UI/CameraRawSlider.cpp
src/UI/FilterSheet.cpp
src/UI/FilterSheet+Adjustments.cpp
src/UI/FilterSheet+Controls.cpp
src/UI/FilterSheet+Dither.cpp
src/UI/FilterSheet+Scanlines.cpp
src/UI/FilterSheet+Finishing.cpp
src/UI/FloatingPanel.cpp
src/UI/KeyboardShortcuts.cpp
src/UI/KeyboardShortcuts+Sheet.cpp
@@ -140,6 +168,8 @@ add_library(compositor_core STATIC
src/UI/RawDevelopSheet.cpp
src/UI/OmarchyTheme.cpp
src/UI/ProjectTabs.cpp
src/UI/ProjectTabs+Strip.cpp
src/UI/ProjectTabLayout.cpp
src/UI/ProjectWorkspaceView.cpp
src/UI/ToolHeaderStyle.cpp
src/UI/ToolIcons.cpp
@@ -151,14 +181,22 @@ add_library(compositor_core STATIC
src/IO/ProjectController.cpp
src/IO/ProjectController+Resize.cpp
src/IO/ProjectController+Export.cpp
src/IO/ProjectController+ExternalChanges.cpp
src/IO/ImageFileDrop.cpp
src/IO/PSD/PSDChannelCoder.cpp
src/IO/PSD/PSDDocumentBuilder.cpp
src/IO/PSD/PSDReader.cpp
src/IO/PSD/PSDReader+Records.cpp
src/IO/PSD/PSDReader+Adjustments.cpp
src/IO/PSD/PSDText.cpp
src/IO/PSD/PSDText+Descriptor.cpp
src/IO/PSD/PSDText+Engine.cpp
src/IO/PSD/PSDTypes.cpp
src/IO/PSD/PSDVector.cpp
src/IO/ProjectDigest.cpp
src/IO/ProjectStore.cpp
src/IO/ProjectWatcher.cpp
src/IO/RecentProjects.cpp
src/IO/ProjectStore+Adjustment.cpp
src/IO/ProjectStore+Effects.cpp
src/IO/ProjectStore+Manifest.cpp
@@ -170,6 +208,7 @@ add_library(compositor_core STATIC
src/Rendering/EditorCanvas+CameraRaw.cpp
src/Rendering/EditorCanvas+Brush.cpp
src/Rendering/EditorCanvas+Crop.cpp
src/Rendering/EditorCanvas+DraftText.cpp
src/Rendering/EditorCanvas+Effects.cpp
src/Rendering/EditorCanvas+Grid.cpp
src/Rendering/EditorCanvas+Guides.cpp
@@ -177,6 +216,7 @@ add_library(compositor_core STATIC
src/Rendering/EditorCanvas+Gradient.cpp
src/Rendering/EditorCanvas+Cursors.cpp
src/Rendering/BrushCursorOverlay.cpp
src/Rendering/EditorCanvas+Masks.cpp
src/Rendering/EditorCanvas+Navigation.cpp
src/Rendering/EditorCanvas+Picking.cpp
src/Rendering/EditorCanvas+Selection.cpp
@@ -191,11 +231,13 @@ add_library(compositor_core STATIC
src/Rendering/EyedropperIcon.cpp
src/Rendering/SampleRingOverlay.cpp
src/Rendering/TransformOverlay.cpp
src/Rendering/TransformOverlay+Ants.cpp
src/Rendering/HslBlend.cpp
src/Rendering/SeparableBlend.cpp
src/Rendering/LayerEffectsKernel.cpp
src/Rendering/LayerEffectsSurface.cpp
src/Rendering/LayerRenderer.cpp
src/Rendering/NavigatorGeometry.cpp
src/Rendering/LiveMaskRenderer.cpp
src/Rendering/BrushCoverage.cpp
src/Rendering/RasterSnapshot.cpp
@@ -204,16 +246,20 @@ add_library(compositor_core STATIC
# Shared macOS pixel kernels, always optimized.
set(OMAPHOTO_KERNELS ${CMAKE_CURRENT_SOURCE_DIR}/kernels)
target_sources(compositor_core PRIVATE ${OMAPHOTO_KERNELS}/AdjustPixels.c ${OMAPHOTO_KERNELS}/BrushPixels.c ${OMAPHOTO_KERNELS}/WandPixels.c ${OMAPHOTO_KERNELS}/ContentFill.c
${OMAPHOTO_KERNELS}/HealPixels.c ${OMAPHOTO_KERNELS}/LevelsPixels.c ${OMAPHOTO_KERNELS}/NoisePixels.c ${OMAPHOTO_KERNELS}/LensPixels.c)
set_source_files_properties(${OMAPHOTO_KERNELS}/AdjustPixels.c ${OMAPHOTO_KERNELS}/BrushPixels.c ${OMAPHOTO_KERNELS}/WandPixels.c ${OMAPHOTO_KERNELS}/ContentFill.c
${OMAPHOTO_KERNELS}/HealPixels.c ${OMAPHOTO_KERNELS}/LevelsPixels.c ${OMAPHOTO_KERNELS}/NoisePixels.c ${OMAPHOTO_KERNELS}/LensPixels.c
${OMAPHOTO_KERNELS}/DitherPixels.c)
set_source_files_properties(${OMAPHOTO_KERNELS}/AdjustPixels.c ${OMAPHOTO_KERNELS}/BrushPixels.c ${OMAPHOTO_KERNELS}/WandPixels.c ${OMAPHOTO_KERNELS}/ContentFill.c
${OMAPHOTO_KERNELS}/HealPixels.c ${OMAPHOTO_KERNELS}/LevelsPixels.c ${OMAPHOTO_KERNELS}/NoisePixels.c ${OMAPHOTO_KERNELS}/LensPixels.c ${OMAPHOTO_KERNELS}/DitherPixels.c
PROPERTIES COMPILE_OPTIONS -O2)
# Swift's kernel warns so through 1.3.3, unchanged.
set_property(SOURCE ${OMAPHOTO_KERNELS}/AdjustPixels.c APPEND PROPERTY COMPILE_OPTIONS -Wno-unused-parameter -Wno-misleading-indentation)
# The per-pixel loops vectorise only at -O3.
set_source_files_properties(src/Rendering/BrushCoverage.cpp src/Rendering/DownsampleCache.cpp PROPERTIES COMPILE_OPTIONS -O3)
target_include_directories(compositor_core PUBLIC src PRIVATE ${OMAPHOTO_KERNELS})
target_link_libraries(compositor_core PUBLIC Qt6::Widgets Qt6::Concurrent PRIVATE heif Fontconfig::Fontconfig onnxruntime::onnxruntime PkgConfig::LIBRAW)
find_package(LibLZMA REQUIRED)
# Swift's bundled resource, inside the binary.
qt_add_resources(compositor_core camera_raw_tables PREFIX "/" BASE src/Resources FILES src/Resources/CameraRawTables.bin)
target_link_libraries(compositor_core PUBLIC Qt6::Widgets Qt6::Concurrent PRIVATE LibLZMA::LibLZMA Qt6::Svg heif Fontconfig::Fontconfig onnxruntime::onnxruntime PkgConfig::LIBRAW)
target_compile_options(compositor_core PUBLIC -Wall -Wextra
$<$<BOOL:${OMAPHOTO_WERROR}>:-Werror>)
# Empty optionals and bad indexes abort, not pass by luck.
@@ -259,7 +305,7 @@ set(CPACK_PACKAGE_NAME omaphoto)
set(CPACK_PACKAGE_VENDOR "ZacharyZhang-NY")
set(CPACK_PACKAGE_CONTACT "ZacharyZhang-NY")
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Layered image editing for Linux, a port of Compositor")
set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/ZacharyZhang-NY/OmaPhoto")
set(CPACK_PACKAGE_HOMEPAGE_URL "https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto")
set(CPACK_PACKAGING_INSTALL_PREFIX /usr)
set(CPACK_DEBIAN_FILE_NAME DEB-DEFAULT)
set(CPACK_DEBIAN_PACKAGE_SECTION graphics)
+2 -2
View File
@@ -2,8 +2,8 @@ ARG BASE=ubuntu:24.04
FROM $BASE
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
g++ cmake ninja-build qt6-base-dev libgl1-mesa-dev qt6-wayland fonts-dejavu-core \
qt6-image-formats-plugins libheif-dev libheif-plugin-libde265 libfontconfig-dev libraw-dev \
g++ cmake ninja-build qt6-base-dev qt6-svg-dev libgl1-mesa-dev qt6-wayland fonts-dejavu-core \
qt6-image-formats-plugins libheif-dev libheif-plugin-libde265 libfontconfig-dev libraw-dev liblzma-dev \
ca-certificates curl dpkg-dev file fakeroot \
&& rm -rf /var/lib/apt/lists/*
# The ONNX Runtime the packages carry: Microsoft's own build.
+55 -17
View File
@@ -6,15 +6,31 @@ It is a Linux port of [Compositor](https://github.com/robbietilton/Compositor) b
## What it does
- Layers, folders, masks, clipping masks, blend modes and opacity
- Selections: marquee, lasso, magic wand, load from a layer, expand and contract
- Brush, eraser, spot healing, clone stamp, smudge, blur and liquify, gradient, shapes, type
- Move, transform and distort layers and selections
- Levels, Curves, Hue/Saturation, Exposure, Gradient Map and Grain, as edits or as adjustment layers
- Layer effects: stroke, drop shadow, color overlay, inner shadow
- Filters: Gaussian and motion blur, noise, lens correction, Content-Aware Fill and Remove Background (U²-Net on ONNX Runtime, offline)
- Crop, canvas size, image size; PNG and JPEG export
- Imports JPEG, PNG, TIFF, HEIC, Photoshop files and camera RAW
- Layers, folders, masks, clipping masks, blend modes and opacity; whole layers copy between projects; Ungroup Layers
- Selections: marquee, lasso, magic wand and its object mode, Select › Subject, Select › Color Range, load from a layer, feather, expand and contract
- Brush, eraser, spot healing, clone stamp, smudge, blur and liquify, gradient, shapes, type with colour and fonts on selected letters
- Move, transform and distort layers and selections; rulers, guides, a grid with its own settings, and snapping for layers, selections and shapes
- Levels, Curves, Hue/Saturation, Exposure, Black & White, Color Balance, Gradient Map, Grain, Invert, blur and noise, as edits or as adjustment layers
- Camera Raw Filter, docked beside the canvas, its sliders, curves, Color Mixer, Calibration and Color Grading measured against Photoshop's
- Layer effects: stroke, drop shadow, outer and inner glow, color overlay, inner shadow
- Filters: Gaussian and motion blur, noise, lens correction, Vignette, Bloom / Glow, Tonal Contrast, Dither, Scanlines, Content-Aware Fill and Remove Background (U²-Net on ONNX Runtime, offline)
- Last Filter (Ctrl+Meta+F) runs the last filter again with the same settings
- Crop, trim, canvas size, image size, rotate the canvas 90°; PNG, JPEG and one-page PDF export, scaled if you like, with a zoomable preview (File › Export As…); New Canvas in pixels, inches, centimetres or millimetres at 72 or 300 DPI, on a clear, white or black background, with presets for screens and social formats
- Imports JPEG, PNG, TIFF, HEIC, WebP, SVG, Photoshop files (PSD and PSB) and camera RAW
- A Navigator minimap in the canvas's corner from 300% zoom: the whole document in small with a box around the view; click or drag to move there (View › Navigator)
- Open Recent, and a Keyboard Shortcuts window
- Search Commands (Ctrl+F): find every menu command and tool by name and run it with Return
- Toggle Fullscreen (F): the canvas alone on black over the whole screen, every panel put away; F or Esc brings them back
- Drag a number's label to scrub its value; Ctrl with the middle button drags to zoom, as in Blender
- Keep working while a project saves
- Project tabs that drag to reorder; those that do not fit gather in a menu
- A project another app changes on disk reloads in place; unsaved work is never replaced without asking
The [acceptance run](docs/acceptance.md) checks each of these in the app's own window, with screenshots.
### Works with AI agents
AI agents and scripts can build and edit projects directly: a `.comp` is a folder of PNG layers and a manifest, and an open project updates live as it's written. See [Writing OmaPhoto projects](docs/writing-comp-files.md) and the [project format](docs/project-format.md).
## Install
@@ -23,21 +39,21 @@ Each script installs the newest release. The Arch, Ubuntu and Fedora scripts dow
Arch and Omarchy:
```sh
curl -fsSLO https://raw.githubusercontent.com/ZacharyZhang-NY/OmaPhoto/main/scripts/install/arch.sh && bash arch.sh
curl -fsSLO https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto/raw/branch/main/scripts/install/arch.sh && bash arch.sh
```
Ubuntu 24.04 or 26.04 LTS and systems built on them (Linux Mint 22, Pop!_OS 24.04):
```sh
curl -fsSLO https://raw.githubusercontent.com/ZacharyZhang-NY/OmaPhoto/main/scripts/install/ubuntu.sh && bash ubuntu.sh
curl -fsSLO https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto/raw/branch/main/scripts/install/ubuntu.sh && bash ubuntu.sh
```
Each DEB names its Ubuntu release's libraries, so other Ubuntu releases and Debian build from source (below).
Fedora:
Fedora 43 or 44:
```sh
curl -fsSLO https://raw.githubusercontent.com/ZacharyZhang-NY/OmaPhoto/main/scripts/install/fedora.sh && bash fedora.sh
curl -fsSLO https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto/raw/branch/main/scripts/install/fedora.sh && bash fedora.sh
```
Fedora's own libheif decodes no HEVC. For HEIC import, add [RPM Fusion](https://rpmfusion.org/Configuration) and install `libheif-freeworld`; the script reminds you.
@@ -45,12 +61,30 @@ Fedora's own libheif decodes no HEVC. For HEIC import, add [RPM Fusion](https://
NixOS installs the release's flake into your profile:
```sh
curl -fsSLO https://raw.githubusercontent.com/ZacharyZhang-NY/OmaPhoto/main/scripts/install/nixos.sh && bash nixos.sh
curl -fsSLO https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto/raw/branch/main/scripts/install/nixos.sh && bash nixos.sh
```
Or add `github:ZacharyZhang-NY/OmaPhoto/<tag>`, a release tag, as a flake input and put its `packages.x86_64-linux.default` in your configuration.
Or add `git+https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto?ref=refs/tags/<tag>`, a release tag, as a flake input and put its `packages.x86_64-linux.default` in your configuration.
The packages themselves sit on the [releases page](https://github.com/ZacharyZhang-NY/OmaPhoto/releases).
The packages themselves sit on the [releases page](https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto/releases). Any other x86_64 system can run the AppImage there, `omaphoto-x86_64.AppImage`: download it, `chmod +x` it and start it.
### Garbled cursors on NVIDIA with Hyprland
On NVIDIA with Hyprland at a fractional scale, the cursors OmaPhoto draws itself, such as those of the selection tools, the wand, the eyedropper, zoom and moving layers, can show garbled or cut off. Theme cursors render correctly. The fault lies in the hardware cursor path (reported upstream as [Hyprland #16413](https://github.com/hyprwm/Hyprland/issues/16413)). Software cursors avoid it.
Current Hyprland and Omarchy read `~/.config/hypr/hyprland.lua`; Omarchy users can put the line in `~/.config/hypr/looknfeel.lua`:
```lua
hl.config({ cursor = { no_hardware_cursors = 1 } })
```
An older setup that reads `~/.config/hypr/hyprland.conf` takes:
```
cursor {
no_hardware_cursors = 1
}
```
## Build from source
@@ -64,7 +98,11 @@ scripts/dev.sh run # build, then the app on your Wayland display
`run` gives the container your home folder at its own path, so the app opens and saves your files and reads your Omarchy theme, and the GPU's render nodes (`/dev/dri/renderD*`), which Mesa's EGL wants. Files outside your home folder stay out of reach.
By hand: C++20, Qt 6.4 or later (Widgets, Concurrent, image formats), CMake, Ninja, libheif, LibRaw, fontconfig and ONNX Runtime. `-DOMAPHOTO_MODEL=path/to/u2net.onnx` installs Remove Background's model. `scripts/distro-check.sh arch|fedora|nixos|resolute` builds and tests on those systems (`resolute` is Ubuntu 26.04).
By hand: C++20, Qt 6.4 or later (Widgets, Concurrent, image formats), CMake, Ninja, libheif, LibRaw, fontconfig, liblzma and ONNX Runtime. `-DOMAPHOTO_MODEL=path/to/u2net.onnx` installs Remove Background's model. `scripts/distro-check.sh arch|fedora|nixos|resolute` builds and tests on those systems (`resolute` is Ubuntu 26.04).
## Translations
OmaPhoto is English only, as Compositor is for now, and takes no translations. It follows Compositor's own words, which are not being translated; every new string would need translating from then on. This will be looked at again if Compositor's changes.
## Licence
+221 -9
View File
@@ -369,15 +369,227 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- 12.8r release run: failed on every package: Qt 6.9 and later refuse `findChild` on a class without `Q_OBJECT`, and 12.8b's `ClipButton` and mixer `Swatch` were found that way (Qt 6.4 in the dev image allows it). Both are now found by their Qt base and name; `scripts/distro-check.sh arch` (Qt 6.11) built and passed 248 of 248. The tag moved to the fix, since nothing was published; the arch check now runs before every release tag.
- [x] 12.8d A `std::bad_alloc` aborted `ProjectExportFailureTests` under a parallel run (seen in 12.7b and 12.8a; alone it passed ten of ten): the panel tests held a memory limit while dialogs painted, and the panel limits sat at the edge of what the panel and its snapshot need.
- 12.8d round 1: PASS. Its notes taken: the snapshot's measured peak named in AGENTS.md, `malloc_trim` in `limited` named equivalent.
- [ ] 12.9 Version 1.2.4: the finishing filters, stepped keyboard zoom, a steady tool rail and tab strip, the project format's version in one place.
- [ ] 12.10 Versions 1.2.5–1.2.7: the layer context menu without its dropped Layer Effects entry (`5d7adba`), copying and pasting whole layers within and between projects, duplicating every selected layer, crop ratios 3:4 and 9:16 and a crop that starts at the selection, Vignette's defaults, text colour previews.
- [ ] 12.11 Versions 1.2.8–1.2.10: the document's own pixel budget, PSB, simple Photoshop text as editable text, background-only PSDs, oversized Photoshop layers cropped, clicked text starting on its first baseline, live text opened by a double click with the Move tool (`a5721b2`), marching ants at screen resolution, SVG import (Qt SVG).
- [ ] 12.12 Version 1.2.11: masks painted anywhere on the canvas, moved unlinked masks under effects, effects kept on screen while text is edited, masks painted and steps undone (`b683acb`), mask thumbnails' ground, text shown as its committed pixels, numeric label scrubbing.
- [ ] 12.13 Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), editing while a project saves, images decoded from memory, the guide for AI agents writing projects.
- [ ] 12.14 Versions 1.3.1–1.3.2: quitting and closing while text is edited, the I-beam over the canvas, File > Open Recent, Image Size keeping print sizes, Camera Raw's Color Grading layout, coloured slider tracks with double-click reset, colouring only the selected letters, sampling keeping a panel's focus, filters waiting for text editing to finish.
- [ ] 12.15 Version 1.3.3: the Dither filter.
- [ ] 12.16 Release OmaPhoto 1.3.3, the version Compositor carries (the user's decision, 2026-09-26: OmaPhoto's version follows Compositor's): README's features, AGENTS.md, the distro checks, packages, the install scripts rerun.
- [ ] 11.5 End-to-end acceptance run with screenshots, README Linux section, `AGENTS.md` sync, MVP acceptance table.
- [x] 12.9a Version 1.2.4, part one: the finishing filters (Vignette with its colour picker, Bloom / Glow, Tonal Contrast), a preview kept to the grid it was rendered for, effects kept by a filter's commit (`e08dd8c`).
- 12.9a round 1 (codex): FAIL, two findings: a job waiting behind another edit's worker kept its old grid's image while Preview off grew the grid, so its result landed stretched into the new placement (reproduced); the sheet's test could not tell which setting a control wrote. Fixed: a growth rebuilds a waiting job on the new grid; each row names its member and only that member may move. Each fix's mutant caught.
- 12.9a round 2: PASS, no findings.
- [x] 12.9b Version 1.2.4, part two: stepped keyboard zoom (`383548c`, `8053fb6`), a steady tool rail and tab strip (`75c4219`). The project format's version in one place is 1.2.5's (`4cc9951`), moved to 12.10.
- 12.9b round 1 (codex): FAIL, a spec mismatch and test gaps: tab titles centred where Swift's are leading; the zoom tolerance, the Meta exclusion, an unsaved long title's elision and the scroll test's unsaved edit were unpinned; and the menu already takes Ctrl with keypad minus, so the filter's zoom-out branch was unreachable in the app. Fixed: `TabTitle` paints from the left, the filter takes Zoom In's plus alone, the key test runs in the app's window; each mutant caught.
- 12.9b round 2: PASS, no findings.
- [x] 12.10a Versions 1.2.5–1.2.7, part one: the project format's version in one place (`4cc9951`); the layer context menu, routed by the list, with its layer actions and validation (`6fa115e`, `2f1e796`, `5c5f05b`, `1366f68`), without the Layer Effects entry (`5d7adba`). Codex review: 3 rounds, PASS.
- [x] 12.10b Part two: copying and pasting whole layers within and between projects (`5ef5b0a`, `c873453`); duplicating every selected layer, stacked above the topmost, folders with Ctrl+J (`245db93`, `2f1537f`). Codex review: 3 rounds, the third's finding (a history manager re-setting the current entry) fixed and closed.
- [x] 12.10c Part three: crop ratios 3:4 and 9:16 (`8b5a6af`) and a crop that starts at the selection (`5ece82b`); Vignette round by default, an empty layer painted across the canvas (`0488cab`); text colour previews from the pickers (`3317d02`, `4e5f4ec`); the tab strip's steady edge fade (`1fb6a3f`) and the title bar's free space dragging the window (`e8ed83b`), each where the twin has its subject. `c4244c4` is reverted in `67301d2`; the format documents (`156c38c`, `4455a72`) are 12.13's. Codex review: 2 rounds, PASS. The tab strip's two commits have no subject here (no fade; the desktop's title bar drags).
- [x] 12.11a Versions 1.2.8–1.2.10, part one: the document's own pixel budget, scaled to the machine's memory, one surface capped (`7dadc88`, `bc865fb`); marching ants from a screen-resolution outline when zoomed out (`8eba077`); Camera Raw's grading wheels drawn in the direction their hue runs (`96edd5f`). Codex review: 3 rounds, PASS.
- [x] 12.11b1 Part two: Photoshop Large Documents (`b8e3cd1`), background-only PSD and PSB from the merged image (`e6bcfa1`), oversized layers cropped to the canvas (`1fcf854`). Codex review: 3 rounds; the third stopped before its report after one finding (a layered read copied the ICC resource), fixed and closed.
- [x] 12.11b2 Part three: simple Photoshop text as editable text (`b8cce6e`, `fa2b342`). Codex review: 3 rounds; the third's findings (test gaps: rounding, scaled leading, the `Txt` key, negative exponents, versions, the limits' edges) fixed and closed.
- [x] 12.11c Part four: clicked text starting on its first baseline, the pointer kept while typing (`292cb6b`, `072fa08`); live text opened by a double click with the Move tool (`a5721b2`); SVG import through Qt SVG (`0534e00`). Codex review: 3 rounds; the third's one finding (a test gap: a stale caret step meeting a replacing draft) fixed and closed.
- [x] 12.12a Version 1.2.11, part one: text being typed shown as the pixels it will commit to (`fe6cb2b`); layer effects kept on screen while text is edited, masks are painted and steps undone (`b683acb`). Codex review: 3 rounds, PASS.
- [x] 12.12b Part two: masks painted anywhere on the canvas (`e753f26`, `2ffb092`), a moved unlinked mask kept in place under effects (`8d2eade`), mask thumbnails' ground white or black as the canvas treats it (`a247676`). Codex review: 1 round; its one finding (two long test comments) fixed.
- [x] 12.12c1 Part three: numeric label scrubbing (`2d36d6e`, `680a522`) in the tool bars and the layer opacity; the build-warning fixes (`075a71b`, `725b934`) where they change behaviour. Codex review: 3 rounds. Round 1: focused fields left stale, double-click second presses, Feather's amount wrongly resting, test gaps. Round 2: an opacity scrub outliving its layer, boundary gaps. Round 3: the same for the Move bar. All fixed; closed after the third.
- [x] 12.12c2 Numeric label scrubbing in the sheets and panels: Canvas Size, Image Size, the colour picker, effects, filters, Hue/Saturation, Levels, Camera Raw. Codex review: 3 rounds. Round 1: every Mixer row scrubbed where Swift scrubs the HSL families alone, and test gaps (Image Size limits, Camera Raw bindings, filter decimals, centimetres). Round 2: test gaps (a held drag across `reshape`, titles enabled again, Alt-held Camera Raw scrubs). Round 3 passed.
- [x] 12.13a Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), images decoded from memory (`cba6efc`). Codex review: 3 rounds. Round 1: a package exchanged and kept aside unseen (Qt watches no IN_MOVE_SELF), a failed manifest read still digested, coarse timers, a failed save's guard released early, and test gaps. Round 2: test gaps (the watch moving, a second change while asking, quiet-time and re-arm counts, an ordered test for a vanished image). Round 3 passed.
- [x] 12.13b Keep editing while a project saves (`940e188`). Codex review: 3 rounds. Round 1: overlapping writes and a quit before a write finished, a queued save crashing after Undo took the document, a reopen reading a package mid-save, and test gaps. Round 2: an external reload reading mid-save, and test gaps. Round 3: a failed save stranding an external change already seen, and test gaps. All fixed; closed after the third.
- [x] 12.13c The guide for AI agents writing projects (`c317306`), README's lines. Codex review: 3 rounds. Round 1: thirteen claims Swift's pages make that the code does not keep (the same-size recipe, replacing a folder, required adjustment fields, folder pass-through, budgets, the save's fallback, text after resizes, fonts, masks, Alt-click) and weak example tests. Round 2: Hue/Saturation's `hsvSettings` precedence, the sampling list, PNG depths, a checked read. Round 3 passed.
- [x] 12.14a Version 1.3.1: quitting and closing while text is edited (`a18d86c`, `afd622e`), the Type tool's I-beam over the canvas (`4a4cf85`, `221d6cc`). Codex review: 3 rounds, all test gaps (quit order and refusal across tabs, the real window's close, content and history, existing and blank drafts, the cursor outside the canvas before and after leaving) and one false equivalence claim; fixed and closed after the third.
- [x] 12.14b Version 1.3.2: File > Open Recent (`73dc0f4`), Image Size keeping print sizes through an invalid resolution (`555417e`), Camera Raw's Color Grading under Color (`7e9afbe`); `d62a132` (always dark) judged against the Omarchy theme (holds: alerts follow the app's palette, which follows Omarchy by design). Codex review: 3 rounds; two display defects (a name's ampersand in the menu, a filename read as markup in the alert), two stale test expectations and one false equivalence claim in round 1, the rest test gaps (eye visibility and toggles by group, note boundaries, remembered resolution across units, ranges and resampling, the submenu's place and every gate, the list across processes); fixed and closed after the third.
- [x] 12.14c Coloured slider tracks with double-click reset in Black & White, Color Balance and Hue/Saturation, filter sliders lined up (`6b709b4`, `c8ed9ba`, `0c969af`). A fix beyond Swift found by its tests: a sheet or Camera Raw group destroyed while an entry held the keys committed into freed members (`releaseFocus`). Codex review: passed in round 3; round 1 found Camera Raw groups still unsafe to destroy, Hue/Saturation's arrow keys stuck on rounding and right double clicks resetting, with test gaps; round 2 an untested repaint and two stale notes.
- [x] 12.14d1 Colouring only the selected letters (`2bfc4ea`, `4503998`, `b5fe653`, `a1d56ac`): colour runs in the text style, the manifest's version 10, the runs drawn and kept through edits, the palette, picker and Type bar paint the selection, the swatch follows the caret, the selection stays see-through under the picker. Codex review: 3 rounds; round 1 found colours guessed onto the wrong twin letter (now exact steps through every path, undo included) and run glyphs drawn in the clear overlay, with test gaps; round 2 a coloured tail lost across multi-step edits and a preedit over a selection inheriting wrongly; round 3 a leading preedit losing its colour on update or commit and an untested start position; fixed and closed after the third.
- [x] 12.14d2 Sampling keeping a panel's focus and the keys going back to open text (`810c88a`), filters, adjustments and Invert waiting for text editing to finish (`d448625`). Sampling's panel focus is a known difference: the window manager activates the clicked window, and refocusing at the press would end the sampling, so the keys go back at the release. Codex review: 3 rounds, test gaps only (reopened text, empty-layer Vignette, both pickers and the background's, the modal guard, a tool panel keeping its keys, no request without text); fixed and closed after the third.
- [x] 12.15 Version 1.3.3: the Dither filter (`f3cd4e9`, `71635e2`, `d0aae29`, `09ab78a`): `DitherPixels.c`, `DitherSettings` with its styles, chunky pixels and their shape, colours and ASCII glyphs, the filter kind in the Filter menu under Bloom / Glow, full-size previews, the picker's two Dither colours, the sheet's controls and swatches, the busy line shown only for the slow filters; the guide's reload note (`29f0bea`); `e70da06` (a launch to open a file shows its window) judged. Codex review: 3 rounds; round 1 found the Characters field stale after normalization, canonically equal characters drawn twice and a stale test, with test gaps; round 3 found glyph paths filled odd-even (overlapping outlines lost their ink); the rest test gaps; fixed and closed after the third.
- [x] 12.16 Release OmaPhoto 1.3.3, the version Compositor carries (the user's decision, 2026-09-26: OmaPhoto's version follows Compositor's): README's features, AGENTS.md, the distro checks, packages, the install scripts rerun.
- 12.16 work: version 1.3.3 in CMakeLists.txt, the PKGBUILD and the flake; `versionupdate.md` says what changed since 1.2.3; README's feature list brought up to date. The full build (warnings as errors) and suite, their first since 12.8r, found two faults: `ProjectExportFailureTests` failed one run in three (the snapshot grew its record list, holding two at its peak; now reserved, with 2,048 layers and 5,120 KB limits, and `aSnapshotHoldsOneRecordList` pins the reserve), and `HueSaturationCanvasTests` read an edit that 12.14d2 now refuses over open text (the test checks the refusal, then the Type tool's double click without a draft). The NixOS check found the flake's test shell without shared-mime-info (no camera RAW type), added. Then 291 of 291 in the dev image, Arch, Fedora, NixOS and Ubuntu 26.04.
- 12.16 codex review: round 1 PASS (version guards, change log, README, both test fixes and their mutants); round 2 PASS (the flake's MIME path, and the installed package finding the database on NixOS and through `nix profile`).
- 12.16 release run: every job passed and published. The live installs (each script from GitHub in fresh containers) installed and started 1.3.3 on Ubuntu 24.04 and 26.04, Fedora 43 and Arch and refused Debian 13, but Fedora 44 refused the RPM: built on 43, it needs LibRaw's `libraw_r.so.23`, where 44 has `.25` (so 1.2.3's RPM had the same fault). Now one RPM per Fedora release, as one DEB per Ubuntu LTS: the Fedora Dockerfile takes `RELEASE`, the workflow builds `omaphoto-fc43` and `omaphoto-fc44`, `fedora.sh` takes the one for `VERSION_ID`, `distro-check.sh fedora44` passed 291 of 291. The published tag stays: the release gained `omaphoto-fc43.x86_64.rpm` (the CI build, renamed), `omaphoto-fc44.x86_64.rpm` (built from `git archive v1.3.3` with the workflow's command) and new sums, and keeps `omaphoto.x86_64.rpm` for the old script.
- 12.16 round 3: FAIL, one finding: `fedora.sh`'s combined pattern refused a derivative whose `ID_LIKE` ends in `fedora`. Fixed (identity and version checked apart); the reviewer's own probe of eleven identities now passes each. Closed after the third round.
- 12.16 live installs after the fix (`live-installs-1.3.3.log`, `live-nixos-1.3.3.log`): each script from GitHub in a fresh container verified its package, installed it and started 1.3.3 with the bundled runtime on Ubuntu 24.04 (Qt 6.4.2) and 26.04 (6.10.2), Fedora 43 (6.10.3) and 44 (6.11.2) and Arch (6.11.2); Debian 13 was refused; `nixos.sh` resolved `v1.3.3`, built its flake and started 1.3.3 on Qt 6.11.2.
- [x] 11.5 End-to-end acceptance run with screenshots, README Linux section, `AGENTS.md` sync, MVP acceptance table.
- 11.5 work: `AcceptanceTests`, `AcceptanceColorTests` and `AcceptanceProjectTests` drive the app's own window through one scenario per README feature row (menus, canvas gestures, sheets, the real panels) and check pixels, layers, sizes and files; `docs/acceptance/` holds sixteen screenshots and `docs/acceptance.md` the MVP acceptance table, with the distro checks and live installs of 12.16. The README links the table (the README is the Linux app's own since 11.4b, so its Install and Build sections are the Linux section). The run found a crash: destroying the window while the Keyboard Shortcuts panel held the keys (so quitting with it active) reached the half-destroyed menus; `~CompositorMenus` now stops listening first. 295 of 295 in the dev image.
- 11.5 round 1 (codex): FAIL. A sibling crash: a floating panel's typed field commits as the window goes, and the session's signal reached the window's toolbar and tab strip, already destroyed (colour picker, Levels, Hue/Saturation, a filter); `~ProjectWorkspaceView` now disconnects its session watch first, `WindowTeardownTests` holds the four cases. Also: the reload scenario did not check that the app asked (a bypass mutant passed), the documented command hid an early failure, unchecked lookups and `->` on optionals, the scrub row's screenshot taken after the reload, a stale Tool icons bullet and two table wordings. All fixed; each fix's mutant caught.
- 11.5 round 2: PASS, no findings.
## 13 Compositor 1.4.5 (the user's order, 2026-09-30: port through v1.4.5, release OmaPhoto 1.4.5; issue #9 gets its workaround documented)
Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`) through 13.2's second round; from 13.2's third they run on Claude Code, Fable 5.1 at high effort (agent `fable-reviewer`, a split pane to the right; the user's order, 2026-09-30: codex was too slow). Each item names its upstream commits; release, feed and Xcode commits (`ab99b60`, `c28f827`, `4f11bb5`, `0c56c86`, `5809fe9`, `8294fc4`, `9e2894e`, `e2440b4`, `2fbf14f`, `de442f2`, `0d9986f`, `3b50e5d`, `ed9c280`, `0787b60`, `557d2f0`, `3a2798b`, `ec02559`, `2187cbf`, `086f163`, `250578e`, `38ec6f7`), review notes (`9593bf5`, `bc29006`'s note), the 1.3.3 feed (`aba3077`), README's line about the author's own history (`3b3f6ca`) and merges, trials included (`7b22492`, `9d3d1b7`, `359956b`, `5adf0b1`, `0a74187`, `4211695`, `e359885`, `7dec592`, `c8295cc`: none resolves a conflict, `git show --cc` is empty) carry nothing to port.
- [x] 13.0 Survey v1.3.3..v1.4.5 (94 commits) and split them into the items below; the two window-teardown crashes found in 11.5 are fixed on `main` (e94fd73) and ship with 1.4.5.
- 13.0 codex review (gpt-6.1-sol, max): round 1 FAIL (the Metal commits' shared CPU contracts unnamed, ordinary paint at the layer's resolution missing from 13.11, Camera Raw's graph gestures missing from 13.18); round 2 FAIL (more contracts from its inventory, the empty layer's document resolution); round 3 PASS.
- [x] 13.1 Issue #9: custom cursors garbled on NVIDIA + Hyprland at a fractional scale (the driver's hardware cursor plane, hyprwm/Hyprland#16413). README's Install section names the workaround, `cursor { no_hardware_cursors = true }`; the issue gets the answer. No app-side fallback (the user's decision).
- 13.1 review (codex-sol): round 1 FAIL (the config path: Hyprland 0.56 and Omarchy load `hyprland.lua`, the `.conf` block is the older form; an unproven reason for theme cursors; the cursor list not marked as examples); round 2 PASS. The reply posted on the issue.
- [x] 13.2 Version 1.3.4, part one: a font change applies to the selected letters, a mixed selection takes the face the menu shows, the menu says (Multiple) (`0eaeb98`, `1ffc8f4`, `916d1ab`, `bc29006`'s test), with the manifest and both format documents.
- 13.2 work: `LayerTextFontRun` and `fontRuns` with Swift's model (`fontNameAt` for `fontName(at:)`), the run members moved to `TypeTool+Runs.cpp`, each run's face as a layout format, format version 11, (Multiple) in the Type bar's menu; a fix beyond Swift keeps a folded base face. Tests: `TextFontRunsTests`, `TextFontControlsTests`.
- 13.2 review: round 1 (codex) FAIL: the folded face's parity unstated, and test gaps (overflow, start-of-text inheritance, newlines, an independent bold reference, editor geometry); round 2 (codex) FAIL: test gaps (neighbouring faces, size and tracking in layout, colour with faces, a missing location); round 3 (Fable 5.1) FAIL: test gaps (a negative location, a run starting at the span's end, fractional numbers, an unlisted face's own name). All fixed, each mutant caught; closed after the third round.
- [x] 13.3a Part two: Dither's ASCII as readable text (`2309a85`, the kernel and the sheet).
- 13.3a review (Fable 5.1): round 1 FAIL (test gaps: the face's divisor and the width's rounding at larger sizes, the coverage's mean, the Text Size row's ends, decimals and unit, the picked table over non-square cells); round 2 FAIL (the kernel's averaging window, height and width); round 3 PASS.
- [x] 13.3b Part three: Select › Color Range (`c3e360a`: `ColorRangeSelection`, `WandPixels.c`, the sheet, the canvas's sampling).
- 13.3b review (Fable 5.1): round 1 FAIL (the sheet's native keys unbound; test gaps: the click's rows and floor, stale and foreign results, the selection kept on error, the preview's truncation); round 2 FAIL (OK after an error, the antialiased flag; two dead gate terms noted); round 3 PASS.
- [x] 13.4a Version 1.3.5, part one: Export JPEG and Canvas Size take the app's colour picker (`c580a38`); `908bed9` and `158cd53` judged: Qt signals arrive on the GUI thread, and the digest already hashes a local's bytes.
- 13.4a review (Fable 5.1): round 1 FAIL (the swatch's minimize case, its white ring and radius untested); round 2 PASS.
- [x] 13.4b Part two: Export JPEG's zoomable preview and tidier layout, the View menu's zoom on it, one OK on the import error (`ac6f309`).
- 13.4b review (Fable 5.1): round 1 FAIL (the full-size preview passed Qt's 128 MB decode limit on large canvases, so they could not export; the kept middle's `min` untested); round 2 PASS.
- [x] 13.5a Part three: the marquee, shapes and a moved selection snap to View › Snap To (`d6e3e93`, `8b1369a`).
- 13.5a review (Fable 5.1): round 1 FAIL (test gaps: rounding, the targets without centres, a path's own box, the drag's reach when zoomed); round 2 FAIL (the nearer of two lines); round 3 FAIL (the nearer line neither first nor lowest). All fixed, each mutant caught; closed after the third round.
- [x] 13.5b Part four: View › Grid Settings… with Restore Defaults (`1c819d0`, `db5eafa`).
- 13.5b review (Fable 5.1): round 1 FAIL (the picker's Cancel turned Light Gray, Light Blue and Green into Custom, since only one side was compared in 8 bits; the drawing's dots, dashes, caps and 4-point threshold loosely tested); round 2 PASS. The CPU cap was lifted to all 32 cores during this item (scripts/mutate.py follows).
- [x] 13.6 Part five: Ctrl+Z while typing takes back what was typed (`ee68ba9`); font previews in the Type bar (`1bd1df4`).
- 13.6 review (Fable 5.1): round 1 FAIL (four survivors unrecorded; the code judged right against Swift); round 2 PASS.
- [x] 13.7 Versions 1.3.6–1.3.7: New Canvas presets in a More menu (`aaf3dc0`); the project's Quick Look preview (`14aad18`, `4868465`) judged: the package's preview file is format, Finder's thumbnail extension is macOS alone.
- 13.7 port (agent): tests in `NewCanvasSheetTests` (presets, the check, the dots), `QuickLookPreviewTests` (the store's folder, load ignoring it, the JPEG byte for byte, sizes, the 50-megapixel bound, a failed render, a controller save) and the sync order in `ProjectReplaceTests`. Survivors recorded: the heading's stretch (equivalent); the scale and allocation null checks and the staged folder's `mkdir` check untested. No review yet.
- [x] 13.8 Version 1.4, part one: the changes behind the GPU canvas that the CPU canvas and export share: a sharper canvas with adjustment layers (adjustment surfaces at device resolution, adjustment operations scaled with the surface, direct sampling for an upright layer at 1:1, tiled layers included) and the PSD adjustment and mask fixes (`8c0417b`); Photoshop's positive Saturation (`fe7a83d`); Levels and Hue/Saturation on all cores (`af3b568`); a blur's preview kept while the layer grows (`3ccc125`); the hierarchy kept until what shapes it changes (`d0135c8`, `dcf1c66`).
- 13.8 port (agent): tests in `SharpCanvasTests`, `PSDAdjustmentTests`, `AdjustmentBandsTests`, `LayerOrderTests`, `PSDReaderTests`, `HueSaturationTests`, `FilterTests`, `FilterSessionTests`, `FinishingFilterTests`, `BlurNoiseAdjustmentTests`, `AdjustmentSurfaceTests`, `HueSaturationFailureTests` (it now fails at the copy). Survivors recorded: Saturation's clamps, the cube cache, the copy's bytewise branch, the bands' guard (equivalent); the mask grid out of memory untested. No review yet.
- [x] 13.9a Part two: blending. Clipping stacks and live-mask coverage at device resolution; an adjustment in any mode over the document and inside a stack, blended at full coverage, its alpha restored, then mixed through mask and opacity (`6dc2219`); a clipping stack composited in its base's mode in every mode, on the canvas, in export and in Copy Merged (`8ab32c9`; Qt keeps the stack's mode: a held judgment to prove); Soft Light by Photoshop's formula as Core Image computes it, on the canvas, in export and in Copy Merged (`5a8f6ce`: a 0.9 source over a 0.5 backdrop gives about 170; compare Qt's formula first); soft, partly clear painted layers blending alike in Soft Light, Overlay, Multiply and Color Dodge (`e6c964b`, `softEdgedLayersBlendTheSame`).
- 13.9a port (agent): Soft Light by Photoshop's formula (Qt's is the PDF's, up to 17 levels darker on dark backdrops); stacks, coverage and adjustments in every mode held and proven. Tests in `BlendingTests`, `BlendModeTests`, `ClippingStackTests`. Survivor recorded: `<= 0.5` for `< 0.5` (equivalent). No review yet.
- [x] 13.9b Part three: the Metal canvas (`cdf8674`, `4b04323`, `5708ed3`, `93de9e7`, `54dd707`, `91cb0b1`, `f773f7b`, `4c5e1a6`, `d04f159`, `b1b7490`) stays CPU (the port's decision); each contract its tests hold for every canvas is judged and, where the twin lacks it, ported: Grain and Add Noise anchored to the document while panning and zooming, the canvas mapping its surface back to document coordinates (`54dd707`; the twin's adjustments take view-space regions today); every adjustment kind with alpha, own and folder masks and opacity, blurs at the drawn scale and direction (`54dd707`); a moved or duplicated selection's preview and commit (the hole cut only for a move, the rounded offset, opacity and coverage once) and the lazy tiles realised before display and commit, a failure cancelling the move with `brushError` (`4b04323`); live paint, erase and mask paint replacing patch interiors, revealing paint past the old bounds under the old mask, gradients' selection clip and opacity matching the commit, the lazy gradient fill realised before display and commit, Smudge and Liquify previews keeping mask, opacity and placement (`5708ed3`, `91cb0b1`); a new one-pixel white mask revealing its whole layer, rotated or scaled without a faded edge, and the live paint grid's extent kept (`93de9e7`); rotation, flips and scale, sharp successive reductions, premultiplied blending in every mode with layer and ancestor opacity, the checkerboard anchored to the document, the crisp composite at high zoom (`cdf8674`); non-adjacent live mask sources, distortion previews (perspective, folded, flipped, placed masks, effects), text previews keeping effects and handing them on without a flash, new drafts above the active layer inside a clipping stack, lines above the image, effects and placed masks kept while brushing, painting masks, dragging gradients and moving pixels (`f773f7b`); sharp reductions and fresh paint pixels (`4c5e1a6`, `d04f159`); `b1b7490` only words a test's diagnostics. Also named, each to judge: sampling modes and rounded-up reduction extents, premultiplied blending in sRGB, the surround, shadow and border, placed own masks and multiplied folder masks on ordinary pixel layers, and filter, Levels and Hue/Saturation previews replacing the layer's pixels with its alpha, mask, opacity and placement (`cdf8674`); image-edge sampling that carries edge values outward for filtering and keeps the image's own boundary, for ordinary images and live grids as for the white mask (`93de9e7`); adjustment results equal to the CPU reference, deterministic Noise and Grain seeds, Add Noise counted in frame pixels from the document's corner and Grain's size in document pixels (`54dd707`); a lazy gradient fill that fails cancelling the gradient with `brushError`, and a failed commit publishing it (`5708ed3`); Smudge and Liquify strokes and commits equal to the CPU algorithms (`91cb0b1`, `matchesTheCPU`: the twin's CPU path is the reference, a held judgment); new text shown when the active layer cannot draw, the pixel grid and the new text frame drawn above the image and passing input through, and edits keeping mask gray values and effects and placed masks as paint bounds grow (`f773f7b`). Texture retention, eviction and blits are GPU mechanisms with no CPU twin.
- 13.9b port (agent): Grain and Add Noise anchored to the document on the canvas (`adjustmentRegion`); every other GPU contract judged held, gaps pinned in `AdjustmentCanvasTests` and `CanvasEditContractTests`; the grid over the border hairline left as a known difference. No review yet.
- [x] 13.10 Part three: Shift keeps moved pixels on a line (`60bde4f`); Ctrl+A selects the canvas from the Layers panel (`451281e`).
- 13.10 port (agent): Shift keeps a Ctrl-dragged selection's pixels on the longer axis; Ctrl+A from the Layers panel selects the canvas (`selectAllRows` gone). No review yet.
- [x] 13.11 Version 1.4.1, part one: why a brush can't paint, Select All then Inverse deselects (`133c34a`); painting at the layer's own resolution (`d3170ab`): Clone Stamp and Blur sample there, the Brush and eraser on a scaled-down, rotated or unequally scaled layer that holds pixels lay a tip of the layer's own pixels and show a smooth edge scaled back up, while an empty layer, however small its transform, paints at document resolution (`paintingABlankLayerThatWasScaledDownPaintsAtDocumentResolution`); the twin's coverage in the stroke's grid (`BrushStroke::grid`, `BrushCoverage::render`) may already hold both, a held judgment to prove with `NativeResolutionPaintTests`' cases; Cancel and Apply only when an edit waits, scaled layers' scale (`67cc31e`).
- 13.11 port (agent): `paintRefusal` says why a brush, gradient (a fix beyond Swift) or the Smear refuses; Inverse of everything deselects; Clone Stamp and Blur sample in the stroke's own grid (`Clone` as Swift's image, placed, inGrid; `gridRect`; `blurSample`'s budget); the held judgment holds (`NativeResolutionPaintTests`, no change to the grid or coverage); Cancel and Apply fade in only for a waiting edit; `sizeLabel`. No review yet.
- [x] 13.12 Part two: masks as Photoshop's: the button reveals the selection, Alt-click shows a mask alone (`b3419ab`).
- 13.12 port (agent): the mask button reveals the selection, Alt-click hides it; Alt-click on a mask thumbnail shows the mask alone (`viewsMaskAlone`, `toggleMaskAlone`, `drawMaskAlone`, a white ring, `MaskAloneBadge`); `liveFolderMaskClip` moved to `EditorCanvas+Masks.cpp`. No review yet.
- [x] 13.13 Part three: Ctrl flips Auto Select both ways and Shift the aspect lock, live in the bar (`bca8f13`, `03d692c`); Move bar values apply without Apply as one step (`686d8c7`); resize handles snap (`c459f88`).
- 13.13 port (agent): held Ctrl flips Auto Select both ways and Shift the lock, shown in the bar (`HeldModifiers`); Ctrl+Shift adds either way; the bar's typed, stepped and scrubbed values open a `fromFields` edit applied as one step when the field is left or the scrub let go, Escape throwing it away, a canvas drag applying it first; resize handles snap (`snappedResizePoint`), Ctrl free, a turned layer not; tests in `TransformPressTests`, `TransformInspectorTests`, `ScrubbableTransformTests`, `ResizeSnapTests`, `ResizeSnapCanvasTests`. Survivors recorded: `before`'s read. No review yet.
- [x] 13.14 Part four: tabs drag to reorder, the ones that don't fit gather in a menu (`1faf7a0`).
- 13.14 port (agent): tests in `ProjectTabLayoutTests` (Swift's six and the bounds), `ProjectTabDragTests` (drag, slide, stacking, landing, threshold, busy, lost release, a close mid-drag, the overflow pill and its menu), `ProjectWorkspaceTests` (`moveTab`) and `ProjectWorkspaceViewTests`. Survivor recorded: reporting `ended` after a click that never dragged (equivalent but after a lost release); `span`'s empty guard and `contentWidth`'s pill fallback removed as dead. No review yet.
- [x] 13.15 Part five: Layer › Ungroup Layers (Ctrl+Shift+G) and in a folder's context menu (`1318f1e`, `6c3b9a5`); Alt over a mask thumbnail shows the duplicate pointer with an eye (`6955a6f`).
- 13.15 port (agent): tests in `GroupSessionTests` (Swift's three and more), `LayerContextMenuTests`, `CompositorMenusTests`, `KeyboardShortcutsTests` and `LayerDragGestureTests` (the eye pointer). Survivor recorded: the context entry gated by `canEditLayers` (equivalent: the right-click makes the folder active); Swift's validation guard left out as dead. No review yet.
- [x] 13.16 Versions 1.4.2–1.4.3: Liquify keeps pixels sharp (`7164dd4`), Smudge without ghost copies (`ad9ad7c`), the Blur brush's own Radius (`504129d`), Blur, Smudge and Liquify on big canvases (`095da2f`).
- 13.16 port (agent): Liquify on per-pixel offsets from the untouched layer (MetalWarp's `warp_push` on the CPU), Smudge carrying only the last dab's pixels at the strength, dabs a pixel apart; the Blur's own Radius (`blurRadius`, the bar's row), its sigma Swift's `min(min(50, max(0.5, r)) / perPixel, sidePixels / 2)` in 13.11's grid; `blurSample` returns the sharp sample with `render`, blurring only the pieces the brush reaches, each tile's clone piece cut once; `maxDiameter` 2100 and the warp commit's thinned points; tests in `SmudgeLiquifyTests`, `WarpStrokeTests`, `LargeCanvasBrushTests` (Swift's `largestBrushCommits`, `blurPiecesMatchTheWhole`), `BlurBrushTests`, `BlurToolTests`, `BrushControlsTests`, `BrushTests`. Survivors recorded: buffer allocation for every mode. No review yet.
- [x] 13.17 Version 1.4.4: quit works mid-gradient or with a dialog open (`1aad384`); Dither › Scanlines (CRT) on all cores (`9c99853`, `cd2f998`).
- 13.17 port (agent): `settlePendingEdits` applies a pending gradient and pixel move and cancels open dialogs before Quit asks, `confirmQuit` refuses while managing; Scanlines (CRT) with Glow, Dots and Wobble, the kernel cd2f998's with GCD swapped for `dither_bands` on PoolMap (forced difference); tests in `QuitPendingEditsTests` and `ScanlinesTests`. Survivors recorded: the glow pad and edge mode, the band split. No review yet.
- [x] 13.18 Version 1.4.5: Camera Raw curves as Photoshop's (`60d9117`): the smooth parametric curve and the per-channel point curves, Refine Saturation's new meaning (0 the RGB curve, −100 brightness alone, above 0 stronger colour) and its words; the graph: a press picks a point, divider or tonal region once and keeps it through the drag, a region drag lifts or lowers that region, dividers move in the bottom strip and stay ordered, point mode picks by distance, adds and drags a new point at once, keeps inner points ordered and moves the end points' outputs alone, the chosen point lit and the readout following it, a new channel or preset clearing the choice.
- 13.18 port (agent): the fitted parametric curve, the tone curve per channel, Refine Saturation's new meaning and words; the graph's one drag (`CameraRawColorControls+Curve.cpp`); fixes beyond Swift: a preset drops the chosen point, a point dragged onto its neighbour stays and stays dragged; tests in `CameraRawCurveTests` and `CameraRawCurveGraphTests`. Survivor recorded: the all-zero shortcut. No review yet.
- [x] 13.19 Release OmaPhoto 1.4.5: version, change log, README, AGENTS.md, the acceptance run, distro checks, packages, live installs, the acceptance checklist.
- Status 2026-10-02, stopped for a system reinstall. Main is 19d8737 plus this note, pushed; there are no other branches or worktrees. Items 13.2–13.18 are ported and merged; 13.19 is open. Nothing outside the repository survives the reinstall (`~/.cache/omaphoto-review`, `~/.cache/omaphoto-port`, the `omaphoto-dev` image, `../Compositor-spec`), so this note carries everything.
- Setup after the reinstall: clone https://github.com/robbietilton/Compositor into `Compositor-Ref` (ignored) and check out tag `v1.4.5`; `scripts/dev.sh build` rebuilds the `omaphoto-dev` image from `Dockerfile.dev`.
- Reviews: two of the ten the user allowed are used. Review 1 (13.7, 13.8, 13.9a, 13.9b, 13.14, 13.15) passed 13.8 and failed the rest on test gaps and stale AGENTS lines; review 2 (13.10–13.13, 13.16–13.18) failed on the same kinds. No crash or wrong output was found. Fixed and tested: all of review 1's, and review 2's for 13.17 and 13.18.
- Committed but neither built nor tested (stopped mid-work; two leftover mutants were reverted from it):
- 13.8: `SharpCanvasTests` pins `LayerRenderer::draw`'s `upright` term (a turned layer near 1:1 must filter) (`63e42ac`).
- 13.10–13.12 (`2190a1b`): tests for Shift's upward-dominant step (`SelectionCanvasTests`), `paintRefusal`'s untested seams (folder/hidden, hidden/mask off, mask off/adjustment, adjustment/empty selection; `PaintRefusalTests`), the mask-alone view across undo and redo and through `setIsMaskSelected` writes (`MaskAloneTests`), and AGENTS lines. Still open: the 12.5b bullet's Select All sentence, the 13.11 bullet's `blurSample` sentence and test names at HEAD, its "row in `NativeLayerListTests`" (the scaled row is in `NativeResolutionPaintTests`), the 8.6 bullet's `blurSample` term, `sizeLabel`'s unreachable "ties to even" claim, and 13.12's pending pixel-gradient case (a pixel gradient must not draw into the mask-alone view).
- 13.13 and 13.16 (`70318de`): a typed Move-bar value then a real canvas press (the field finishes from the event loop on `MouseFocusReason`; `MoveBarPressTests`), the folder case, Alt-centre and long-drag resize snapping and the edge tie (`ResizeSnapTests`, `ResizeSnapCanvasTests`, fixture `MovePressFixtures.h`), the dead one-pixel Liquify "fix" removed, a Liquify dab's scratch allocation caught (skips the dab with a warning), the Radius field's `inf`, `WarpStroke::committedPoints` with its test, `softened`'s mask flag removed, and AGENTS and TASKS lines.
- Known failures: at 81894f7 the full suite under `ctest -j16` failed `ColorRangeTests`, `GuideGridTests`, `GridSettingsSheetTests`, `TypeFontPreviewTests` and `GuideCanvasTests`, each of which passes alone: some state is shared between test processes (suspect `QSettings` or `ToolDefaults` keys). Find and isolate it; never loosen a check.
- Next, in order: build; run the commits above and fix them; run the full suite and fix the parallel failures; review 3 over every fix since 81894f7 (13.8 included); then this item: acceptance run, distro checks (arch, fedora, fedora44, nixos, resolute), the `v1.4.5` tag and release, live installs, and the acceptance checklist. The 1.4.5 version, change log, README and acceptance table (results pending) are already in.
- Resumed 2026-10-03 after the reinstall. The user's rule of that day replaces the ten-review allowance: every step below is reviewed by codex (GPT-6.1-Sol, max effort until 13.19g, high from then at the user's word) in a herdr pane before the next, at most three rounds a step. Steps, in order:
- [x] 13.19a The spec checkout is `Compositor-Ref` inside the repository (ignored by git, left out of the flake's source), at `v1.4.5`; AGENTS.md and TASKS.md say so.
- 13.19a review: round 1 FAIL (the flake copied the spec; no step for review 3's scope; the budget unstated; the status said done), fixed; the Docker context finding answered (no Dockerfile copies from it). Round 2 PASS.
- [x] 13.19b Build main in a fresh `omaphoto-dev` image.
- 13.19b review: round 1 PASS.
- [x] 13.19c `63e42ac` (13.8's `upright` pin) built, run, its mutant caught.
- 13.19c work: `SharpCanvasTests` passes; `transform.radians() == 0` set to `true` in `LayerRenderer::draw` and in `TiledLayerRenderer`, each caught by the turned rows (dropping `upright` inside `interpolation` leaves the parameter unused, which `-Werror` refuses).
- 13.19c review: round 1 PASS.
- [x] 13.19d `2190a1b` (13.10–13.12 tests) built and run; its open items closed: the 12.5b, 13.11 and 8.6 bullets, `sizeLabel`'s tie claim, 13.12's pending pixel gradient.
- 13.19d work: `MaskAloneTests`, `PaintRefusalTests` and `SelectionCanvasTests` pass. Mutants: the three reachable `paintRefusal` seams swapped, caught (mask off and adjustment exclude each other, an equivalent swap); the Shift axis test without either `abs`, without both, and `>`, caught; `drawMaskAlone` without its `isMask` or its layer term, caught; `setIsMaskSelected` keeping the view, caught. The 12.5b, 8.6 and 13.11 lines match the code. `sizeLabel`'s tie claim was false (1062.5 over 1000 pixels is exactly 106.25%): ties to even are now tested both ways (ICU's default rounding, which Swift's `.formatted` uses), `round` and `floor(x + 0.5)` caught.
- 13.19d review: round 1 FAIL: the 8.6 bullet's whole-sample blur timing, the 13.11 bullet's typed values as persistent, `->` on optionals in `MaskAloneTests`. Fixed; the same `->` in `CanvasEditContractTests`, `ImageFileDropTests`, `RawDevelopTests` and `SessionRecord.h` went too. Their run found `SelectionSignalTests` stale since 13.11 (Inverse of everything deselects, so the `deselect` after it is silent): the test now says so.
- 13.19d round 2: FAIL: other tests still read optionals through `->` and `*`, through aliases a text scan missed. Found by the compiler: in a throwaway container `std::optional`'s `operator->` and `operator*` were marked deprecated and every target built (`-O0`); 271 sites in 30 test files and headers, each now `.value()`, and the rerun finds none in `tests/` with every unit compiled. The full suite at `-j8` then failed `FilterSessionTests` and `SelectionFeatherTests` alone too: both still called `addMask(false)` to reveal a selection, the meaning 13.12 flipped (`0b0d36a`); now `addMask(true)`. 329 of 329 at `-j8`.
- 13.19d round 3: PASS.
- [x] 13.19e `70318de` (13.13 and 13.16 fixes and tests) built and run.
- 13.19e work: `70318de`'s tests pass. Mutants: the field's deferred finish on a mouse press, both ways, caught; `committedPoints` at twice the spacing, without its last-point term, without `kept`, caught, and its `<=` and its one-pixel floor survived until rows for exactly the spacing and a small brush were added, now caught. `WarpFailureTests` (new, a binary of its own) starves a Liquify dab's scratch (skipped with its warning), Smudge's carried colour and Liquify's offsets (each `ExportError(render)`); each guard's removal caught; the untouched copy's null check is equivalent under starvation (the offsets after it are twice its size). `blurSample` drops Swift's dead `if let` on the mask and `?? 0` for the document. The 13.16 line named `compositor.rendering`; it is `omaphoto.rendering`. 330 of 330 at `-j8`.
- 13.19e review: round 1 FAIL: the starvation tests took any `ExportError` kind; no test made the Move bar's deferred finish run by itself; AGENTS still called allocating Liquify's buffers for every mode equivalent. Fixed: both tests compare the kind (`encode` and `tooLarge` mutants caught), `aValueLeftByMouseForAnotherFieldApplies` clicks Y with no canvas press (dropping the deferred finish caught), the AGENTS line says caught.
- 13.19e round 2: PASS.
- [x] 13.19f The fixes since `81894f7` already tested (`382c9c2` for 13.9a and 13.9b, `6735aad` for 13.7, 13.14 and 13.15, `5c8e18c` for 13.17 and 13.18) reviewed: review 3's scope.
- 13.19f work: the three commits' tests pass. Mutants on their code: the tab strip's release watch (its button test, its start, its ending, its other-tab guard) and the glow's whole steps. The other-tab guard (Swift's `guard reorder?.id == id`) survived; `anotherTabPressedBeforeTheWatchMovesNothing` now catches it. The glow's rounded-up small sides are equivalent (the padded sides are whole steps).
- 13.19f review: round 1 FAIL: `aDoubleClickInTheStripMovesADivider` pressed twice at one place, so a double click that skipped the divider passed. Now the first click and the double click land apart; that mutant is caught.
- 13.19f round 2: PASS.
- [x] 13.19g The full suite under `ctest -j16`; the shared state behind the five parallel failures found and isolated.
- 13.19g work: eight full runs at `-j16` and `-j32` on 32 cores passed 330 of 330; the five failures of `81894f7` did not come back, alone or with the nineteen suites that share `QStandardPaths`' test folder, so their cause is not known (the old runs had a 12-core cap and bwrap). Under a 4-CPU cap at `-j16` a new one failed three of three: `AcceptanceTests::paintingTools`' rectangle made no layer. Diagnostics showed the gradient edit still pending when the drag began, with the project free: `finishRasterCommit` freed the project, then posted the continuation that clears the gradient, so for one turn of the event loop the edit refused every press (Swift's caller resumes from `await` in the same job). The continuation now runs where the commit ends, for the gradient, the pixel move and fills; their callers' callbacks stay posted. Round 1 of review found the same gap one level up, through posted callbacks that carry work: the floating selection installed after the clear, and a filter, Levels, Hue/Saturation or Invert begun after a pending gradient read whatever layer was active by then. With the continuation inline, both run where the commit ends; only early paths post. `CommitContinuationTests` posts a check as the project frees: the gradient gone, the move gone, the floating edit ready, a tool chosen then staying chosen, and each chained command keeping its layer; the two first were red before the fix, posting the continuation again is caught, and posting the gradient's or the fill's continuation is caught by the chained and floating rows. Then 331 of 331 at `-j16` and three of three under the 4-CPU cap.
- 13.19g stress past that: 2 CPUs at `-j32`, sixteen processes a core, failed six and eight suites, every one a time limit (waits of 5 to 20 s, QtTest's 300 s watchdog on `LargeCanvasBrushTests` and `LayerContextMenuEdgeTests`) but `SubjectRemovalFailureTests::aModelRunThatFindsNoMemoryIsARenderError`, whose 512 KB limit then found room; it passes alone at 2 CPUs three of three and under the 4-CPU load, and its cause under that stress is not established. `dev.sh test` and the distro checks run the suite one test at a time.
- 13.19g review: round 1 FAIL (the floating selection and the commands chained after a gradient still had a gap); round 2 PASS.
- [x] 13.19h The acceptance run and its shots.
- 13.19h work: the acceptance run checked none of the seventeen rows new in 1.4.5, though the README says it checks each feature. `AcceptanceNewTests` (Color Range, Grid Settings, snapping; presets, Quick Look, Ungroup, tabs), `AcceptanceEditTests` (fonts and their preview, painting at a layer's resolution, a hidden layer's refusal, Blur's Radius, Liquify, Smudge, masks; Move's held keys and typed width, Soft Light and a Linear Dodge stack on the canvas against the export) and `AcceptanceFinishTests` (Camera Raw's curves, Dither's ASCII and Scanlines, the JPEG preview's keys, Quit with a gradient pending and with Levels open) drive the app's window for each. All six acceptance binaries pass, twice in parallel; the shots are taken again, two of them new (`17-tabs`, `18-mask-alone`); the table says what each new row's run checks, and every row but the distro and package rows reads pass.
- 13.19h review: round 1 FAIL: the font scenario emitted the menu's signals itself, Dither and Blur's Radius were set past their controls, the ASCII check took blocks for letters, the mask-alone view was not looked at, and seven rows said more than their scenarios (my rewrite of the table had not landed). Fixed: the font popup opened, hovered and clicked (`popupRow`, `choose`); Dither's style, characters and text size through its sheet and OK, Camera Raw through OK, the Radius typed in the bar, Ctrl+Shift+G and Ctrl+Z as keys, Color Range's OK, Save As from the menu; ASCII of \"@\" must keep holes (inked share under three quarters) and grow about twice at 28; the mask-alone canvas checked black and white; each row says what its scenario does. The reviewer's mutants (no popup, no style handler, blocks for letters, no mask-alone drawing) and a Radius field that writes nothing are each caught.
- 13.19h round 2: FAIL: the committed Camera Raw shot showed the zero-exposure preview, since the scenario waited for any preview. It now waits for the preview whose ground has brightened; the shot is retaken.
- 13.19h round 3: PASS.
- [x] 13.19i Distro checks: arch, fedora, fedora44, nixos, resolute.
- 13.19i work: first run failed on every newer Qt. Arch (6.11), Fedora 43 and 44, Ubuntu 26.04 (6.10) and NixOS (6.11) found two real bugs in the pickers: Qt 6.10 emits `highlighted` when a closed combo box follows its value, so the font menu started a preview as the caret moved and a later close put back text typed since; and it closes the popup on Escape without `hidePopup`, so Escape kept a font or blend preview. Fixed by previewing only while the popup shows and ending on the popup window's `Hide`, tested on both Qts with mutants caught. Two test fixes: `GridSettingsSheetTests` sets an English default locale (6.10's C locale groups nothing), and Dither's starvation test skips off Qt 6.4 as the other five do.
- 13.19i second run: all five pass, 334 of 334 each; the full suite passes on the dev image.
- 13.19i review: round 1 PASS (the Qt behaviour probed on 6.4.2, 6.10.2 and 6.11.2, six mutants caught).
- 13.19j work: `versionupdate.md` gains 13.19i's blend menu fix; `v1.4.5` tagged on main and pushed; the workflow's run is checked below.
- [x] 13.19j Tag `v1.4.5`; every package published.
- 2026-10-11: GitHub suspended the account; the project moved to Gitea (git.zacharyzhang.com), the workflow became `scripts/release.sh`, and releases gain an AppImage. `scripts/release.sh v1.4.5` published both DEBs, both RPMs, the Arch package, the AppImage and `SHA256SUMS`.
- 13.19k (live installs) and 13.19l (the acceptance checklist) move to 14.32: the user's order of 2026-10-11 ports on to v1.4.9 and releases that.
## 14 Compositor 1.4.9 (the user's order, 2026-10-11: port through v1.4.9, release OmaPhoto 1.4.9 on Gitea)
`Compositor-Ref` moves to `v1.4.9`. One item at a time, top to bottom. Each is reviewed by codex (`gpt-6.1-sol`, `xhigh`, fast) in a herdr pane split to the right, five rounds at most. Items follow the first release that holds each feature (`git merge-base --is-ancestor`); a later fix to a feature travels with it. A C kernel is copied whole at the state of the item's last upstream commit, and later items move it on (`DitherPixels` at `196342c` in 14.2, at `cdde327` in 14.19; `AdjustPixels` at `42a559b` in 14.8, `0b4c56c` in 14.16, then 14.25 to 14.28). Nothing to port: release, feed and Xcode commits (`11d8d7a`, `e64f749`, `fa41b9b`, `675d8a4`, `af30c45`, `24ef288`, `22c7b8d`, `0ab3ff5`), merges with no conflict (`673cdb6`, `c9d769d`, `41bd281`, `aac6e02`, `7e14acc`, `28d237c`, `61ea182`, `b5f9b80`; `git show --remerge-diff` shows none), macOS's Edit menu extras (`710dd66`: Writing Tools, AutoFill, Dictation and Emoji are AppKit's own) and Sparkle's update alert (`759bcbe`: no updater on Linux). Keys: Cmd is Ctrl, Option is Alt, Control is Meta.
Compositor 1.4.6:
- [x] 14.0 Survey v1.4.5..v1.4.9 (76 commits) and split them into the items below; `Compositor-Ref` at `v1.4.9`; 13.19 closed.
- 14.0 review (codex `gpt-6.1-sol`, xhigh): round 1 FAIL (kernels at v1.4.9 broke the items' builds; 14.1 claimed live text survives Image Size; `3c6cf5e` had a conflict; items not in release order); round 2 PASS.
- [x] 14.1 Color Overlay, Inner Glow and Inner Shadow recolour translucent pixels fully (`e7e295e`, `9cb57db`); the effects button says "Add layer effect" (`f25effa`).
- 14.1 work: `LayerEffectsKernel` recolours the pixels as v1.4.9's `effects_compose` does; Swift's three `ColorOverlayTests` and exact rows for the inner shadow and glow; 7 mutants, all caught. Review: round 1 PASS.
- [x] 14.2 RAW import rounds to 8 bits with fine noise, in C (`6345432`, `196342c`, `6e6ca38`'s test); the develop sheet says Importing… until the layer lands (`0907f51`).
- 14.2 work: `dither_quantize16` in the kernel, the develop rounds its 16-bit look through it; `rawImporting` and `endRawDevelop`; the sheet's Importing… plate and locked dialog; `EditorSession+Imports.h` takes the import declarations out of the full header. 20 mutants, all caught. Review: round 1 FAIL (the 12.6a bullet still said the request ends at once), round 2 PASS.
- [x] 14.3 New Canvas: inches, cm or mm at 72 or 300 DPI, transparent, white or black, every setting a pill; it opens transparent, in pixels, at 72 DPI (`b0b4f01`, `ab9779a`, `a00d8b6`).
- 14.3 work: `NewCanvasUnit` and `NewCanvasBackground`, Swift's three pills, the resolution and background through `createNewProject`, `firstLayer`; one trim of Foundation's `.whitespaces` (U+200B included) for New Canvas, `validDimension` and hex. 33 mutants, all caught. Review: round 1 FAIL (midpoints rounded up; U+200B untrimmed), round 2 FAIL (cm and mm midpoints: Foundation rounds the shortest decimal), round 3 PASS (the reviewer compared 180,000 cases with Swift).
- [x] 14.4 Image › Rotate Canvas 90° Clockwise and Counterclockwise (`754f188`).
- 14.4 work: `CanvasRotation.cpp`, the Image menu's two entries, `restore` fitting a resized canvas; QPainter's turns made exact (`degrees()`, `QTransform::rotate`) and a pixel-for-pixel quarter turn unfiltered (`LayerRenderer::upright`), so Swift's exact tests hold; `ImageMenuTests` split out at 500 lines. 41 mutants, all caught or equivalent (`degrees()` at three grid maps). Review: round 1 FAIL (corners alone let a stretched side or a turned painter through; one layer tested), round 2 FAIL (no open edit before the turn), round 3 PASS.
- [x] 14.5 One View › Snap for all snapping (`bba92dc`'s session and menu half).
- 14.5 work: `snappingEnabled()` reads `m_snapEnabled`; `setSnapEnabled` clears the guides; the first View › Snap goes. 3 mutants, all caught. Review: round 1 FAIL (the signal's order untested), round 2 PASS.
- [x] 14.6 The command palette: menu commands, tools and their modes, masks, toggles shown on, on Ctrl+F (`cb139a1`, `384632d`, `231b7d3`, `07b8b1e`, `94b57f7`, `bba92dc`'s palette half, the palette lines of `088a5c1` and `720c121`).
- 14.6 work: `UI/CommandPalette` and `UI/CommandPaletteView`, View › Search Commands… on Ctrl+F, a `Qt::Popup` centred on the window; scores checked against Swift 6.0.3 running v1.4.9's code. 57 mutants, all caught but two equivalent (AGENTS.md). Review: round 1 FAIL (canonical matching, a chosen second row, a mode back from its sibling), round 2 FAIL (the key's drawing untested), round 3 PASS.
- [x] 14.7 Canvas Only and Toggle Fullscreen on F and Esc (`ad4bddf`, `088a5c1`, `720c121`).
- 14.7 work: the session's three members, `ProjectWorkspaceView::toggleCanvasOnly` and its Escape filter, `ContentView`'s hidden bars, the black surround, View › Toggle Fullscreen on F; menu actions also live on the window, since a hidden bar's keys go dead. 57 mutants, all caught. Review: round 1 FAIL (the layers edge and the new definitions untested), round 2 PASS.
- [x] 14.8 Camera Raw's colour noise reduction: clear pixels unsaturated, brightness kept (`a2718d9`, `42a559b`).
- 14.8 work: `kernels/AdjustPixels.c` copied from 42a559b unchanged; `CameraRawColorNoiseTests` holds Swift's two and pins upstream pixels. 31 mutants on the kernel's new section, all caught. Review: round 1 FAIL (coverage and Smoothness), round 2 FAIL (each plane's edge, amount and scale), round 3 FAIL (half the Detail), round 4 PASS.
Compositor 1.4.7:
- [x] 14.9 Filter › Last Filter on Ctrl+Meta+F, the last filter with its settings (`0415aa4`, `6b81d4a`).
- 14.9 work: `repeatsAsLastFilter`, `FilterEdit::repeating`, the session's `lastFilter`, `canRepeatLastFilter`, `repeatLastFilter`; Filter › Last Filter on Ctrl+Meta+F; the panel shut for a repeat. 18 mutants, all caught but one equivalent. Review: round 1 FAIL (a newer edit's survival untested; optional reads), round 2 PASS.
- [x] 14.10 The hand stays closed for a whole Space or Hand pan (`c0a1cd0`).
- 14.10 held: `updateCursor` ranks a pan above Space and the Hand tool, a repeated release is no release; tests added. 3 mutants, all caught. Review: round 1 FAIL (Space let go mid-pan untested), round 2 PASS.
- [x] 14.11 Layers footer in the primary colour, dimmed as far as its menus when disabled (`7fb40a9`, `790195f`).
- 14.11 work: `footerIcon` gives the four buttons the primary ink and the menus' dimmed ink when disabled; the panel redraws its icons from the event loop. 12 mutants, all caught. Review: round 1 FAIL (a button disabled after being made), round 2 PASS.
- [x] 14.12 The unsaved-changes alert: Don't Save destructive, as plain as Cancel (`283d360`, `4e035bf`).
- 14.12 held: Swift's net change is none; the twin's Save is the default, Don't Save as plain as Cancel; tested. 3 mutants, all caught. Review: round 1 FAIL (colours unchecked), round 2 PASS.
- [x] 14.13 A layer's name field keeps its size while renaming (`8f7d344`).
- 14.13 work: a frameless field of the name's size, a tenth of the ink beneath, the label hidden meanwhile. 6 mutants, all caught. Review: round 1 FAIL (the old name drawn beneath), round 2 PASS.
- [x] 14.14 Tool headers scroll when the window is too narrow (`68396ff`).
- 14.14 work: `ToolHeaderBar::scrolling`, used by the Brush, Gradient and selection bars; the Brush's notes scroll inside. 12 mutants, all caught. Review: round 1 FAIL (each note's place), round 2 PASS.
Compositor 1.4.8:
- [x] 14.15 Layers selected on the canvas are revealed in the Layers panel (`d2b8aaf`, `b4e147b`).
- 14.15 work: `revealActiveLayer` after both selections, a second signal when a folder opens; the list scrolls a newly active row in once it exists. 14 mutants, all caught but one equivalent. Review: round 1 FAIL (a child of a shut folder never scrolled in), round 2 PASS.
- [x] 14.16 Camera Raw's Shadows and Highlights keep tones in order (`0b4c56c`).
- 14.16 work: `kernels/AdjustPixels.c` copied from 0b4c56c unchanged; `CameraRawToneOrderTests`. 10 mutants, all caught. Review: round 1 FAIL (partial amounts each way), round 2 PASS.
- [x] 14.17 The Layers panel grays only for what lasts (`06313a9`).
- 14.17 work: `layersLookEditable`; the list, footer, effects menu and mask button follow it, effect eyes keep `canEditLayers`. 30 mutants, all caught but one dead term removed. Review: round 1 FAIL (effect eyes; the adjustment menu untested), round 2 PASS.
- [x] 14.18 File › Export As…: PNG, JPEG or PDF, sized and previewed over the checkerboard (`9287cf1`, `450af5f`, `b1db93d`).
- 14.18 Export As: PNG, JPEG and one-page PDF (a hand-written PDF 1.4, since QPdfWriter rounds pages), sized and scaled, over Swift's checkerboard; the last format remembered; passed review round 5.
- [x] 14.19 Filter › Scanlines, its own filter: Displace, Threshold, Thickness, Color Split, smoothed displacement, Dots by tone, Black Level (`06c3a22`, `1187736`, `cdde327`).
- 14.19 Filter › Scanlines on cdde327's kernel (blocks as band functions), Swift's own bytes matched; passed review round 2.
- [x] 14.20 The Navigator: a minimap in the canvas's corner from 300% (`6490bb3`, `3e09948`; `3c6cf5e` resolves a conflict in `ContentView` between the Navigator's column and Canvas Only, which the minimap supersedes).
- 14.20 The Navigator minimap from 300% (View › Navigator), Swift's geometry and picture; passed review round 3.
- [x] 14.21 The slider snap test draws its slider before pressing it (`07eee7e`).
- 14.21 Held: Qt's style measures the groove unpainted; the test pins a press before any paint; passed review round 2.
Compositor 1.4.9:
- [x] 14.22 Canvas Size, Crop and Trim keep effects, live text and live shapes; Image Size scales effects by the root of its two factors and turns text and shapes into pixels (a size that only changes resolution keeps all three); Hue/Saturation, Levels, Invert and a floating merge keep effects (`94e90bd`), with `docs/project-format.md`.
- 14.22 Effects, live text and shapes outlast Canvas Size, Crop, Trim and resolution-only Image Size; Image Size scales effects; passed review round 2.
- [x] 14.23 WebP import (`5724099`).
- 14.23 WebP import through Qt's plugin; passed review round 3.
- [x] 14.24 Ctrl or Meta with a middle drag zooms (`e03923f`).
- 14.24 Ctrl or Meta with a middle drag zooms, Blender's way; passed review round 4.
- [x] 14.25 Camera Raw's Light and Color sliders match Photoshop's, with `CameraRawTables` (`57ab3bb`).
- 14.25 Photoshop's measured tables for Light and Color; passed review round 3.
- [x] 14.26 Camera Raw's adaptive Contrast, Highlights and Shadows; Dehaze, Texture, Clarity and Sharpening (`75147a2`).
- 14.26 Adaptive tables, Dehaze, Texture, Clarity, Sharpening; passed review round 2.
- [x] 14.27 Camera Raw's Color Mixer, Calibration, curve, vignettes and Color Grading (`bdfb9c2`, `9dac4f1`, `7b5a592`).
- 14.27 Mixer, Calibration, curve, vignettes, Grading from Photoshop's tables (xz); passed review round 4.
- [x] 14.28 Camera Raw's preview 5 to 20 times faster (`a8630e4`).
- 14.28 Preview 5–20× faster: one table, one measure, sparser scope, bands; passed review round 2.
- [x] 14.29 Tabs: the blank start tab stays put; Untitled numbers stay low (`d0ec2f3`).
- 14.29 Blank start tab stays, low Untitled numbers; passed review round 1.
Release:
- [x] 14.30 Version 1.4.9: CMake, PKGBUILD, flake, `versionupdate.md`, README (`75f3556`, `cea9083`, `e1a5210` and every row new since 1.4.5), AGENTS.md.
- 14.30 Version 1.4.9, README, change log; passed review round 2.
- [x] 14.31 The acceptance run for the rows new in 1.4.6 to 1.4.9; the full suite; distro checks (arch, fedora, fedora44, nixos, resolute).
- 14.31 Acceptance for 1.4.6–1.4.9, full suite, five distro checks; passed review round 3.
- [x] 14.32 Tag `v1.4.9`, `scripts/release.sh v1.4.9`, live installs from Gitea in fresh containers, the acceptance checklist.
- 14.32 v1.4.9 tagged and released on Gitea; live installs pass on Ubuntu 24.04 and 26.04, Fedora 43 and 44, Arch and NixOS, Debian 13 refused; passed review round 1.
## Skills by phase
+79
View File
@@ -0,0 +1,79 @@
# OmaPhoto 1.4.9 acceptance
The acceptance table for OmaPhoto 1.4.9, the Linux port of Compositor 1.4.9. Each row is a feature from the README. The rows under "New in 1.4.5" cover what changed since 1.3.3, those under "New in 1.4.6 to 1.4.9" what changed since 1.4.5.
The acceptance run drives the app's own window, the way a person uses it. It chooses menu entries, drags on the canvas, types into sheets and answers the real file panels. Then it checks concrete results: pixels, layers, sizes and files on disk. The run is seven test programs: `AcceptanceTests`, `AcceptanceColorTests` and `AcceptanceProjectTests` for the table, `AcceptanceNewTests`, `AcceptanceEditTests` and `AcceptanceFinishTests` for the rows new in 1.4.5, and `AcceptanceLatestTests` for those new in 1.4.6 to 1.4.9. They take the screenshots below when `ACCEPTANCE_SHOTS` names a folder.
```sh
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e QT_QPA_PLATFORM=offscreen -v "$PWD:$PWD" -w "$PWD" omaphoto-dev \
sh -c 'cmake -S . -B build && cmake --build build --target AcceptanceTests AcceptanceColorTests AcceptanceProjectTests AcceptanceNewTests AcceptanceEditTests AcceptanceFinishTests AcceptanceLatestTests &&
for t in AcceptanceTests AcceptanceColorTests AcceptanceProjectTests AcceptanceNewTests AcceptanceEditTests AcceptanceFinishTests AcceptanceLatestTests; do ACCEPTANCE_SHOTS=$PWD/docs/acceptance build/$t || exit 1; done'
```
The screenshots come from Qt's offscreen platform, so floating panels appear without the window manager's title bars. The run uses the built-in dark theme, except for the last row.
## Table
| Feature | What the run does and checks | Screenshot | Suites that hold the details | Result |
| --- | --- | --- | --- | --- |
| Layers, folders, masks, clipping masks, blend modes, opacity | Imports a photo and paints a layer. Clips it to the photo and sets Multiply at 60%. Masks the photo and groups both in a folder. Undo takes a step back. | [01](acceptance/01-layers.png) | `GroupTests`, `LayerMaskTests`, `LiveMaskTests`, `LayerAppearanceTests`, `LayerRendererTests`, `HistoryTests` | pass |
| Selections | Drags a marquee to within 2 px. The wand takes the flat ground. Select › Subject finds the red disc with the U²-Net model. | [02](acceptance/02-selections.png) | `SelectionTests`, `MagicWandTests`, `SelectSubjectTests`, `SelectionEditTests`, `ObjectSelectionTests`, `SelectionFeatherTests` | pass |
| Painting tools | Drags a gradient that fades to clear, a rectangle shape and a soft white brush stroke. Types "Hello OmaPhoto" on the canvas and colours "Hello" red. That leaves one colour run, committed with Ctrl+Return. | [03](acceptance/03-painting.png) | `BrushTests`, `GradientTests`, `ShapeToolTests`, `TypeToolTests`, `TextColorRunsTests`, `SpotHealingTests`, `CloneStampTests`, `SmudgeLiquifyTests` | pass |
| Move, transform, distort; rulers, guides, grid | Drags the layer with the Move tool. Transform Layer halves it, and Return applies. Rulers, a guide and the grid are shown. | [04](acceptance/04-transform-guides-grid.png) | `TransformSessionTests`, `DistortTests`, `GuideTests`, `FloatingSelectionTests` | pass |
| Adjustments as edits and as layers | Hue/Saturation turns the red disc green. An Invert layer inverts the view and leaves the pixels alone. A Curves layer opens its editor. | [05](acceptance/05-adjustments.png) | `HueSaturationTests`, `LevelsTests`, `CurvesControlsTests`, `AdjustmentLayerTests`, `AdjustmentEditorTests`, `ImageAdjustmentTests` | pass |
| Camera Raw Filter, docked | Opens in the dock beside the canvas. One stop of exposure previews, commits and brightens the ground. | [06](acceptance/06-camera-raw.png) | `CameraRawTests`, `CameraRawSessionTests`, `CameraRawPanelTests`, `CameraRawCanvasTests` | pass |
| Layer effects | A 12 px red stroke rings a shape outside its edge, and a drop shadow's sheet shows. The shape keeps its fill. | [07](acceptance/07-effects.png) | `LayerEffectsTests`, `LayerEffectsKernelTests`, `EffectsPanelTests`, `EffectsCanvasTests`, `InnerGlowTests` | pass |
| Filters | Dither leaves only black and white dots. Remove Background masks the photo around its subject: the centre stays opaque and a corner turns clear. | [08](acceptance/08-filters-dither.png), [09](acceptance/09-remove-background.png) | `DitherTests`, `FilterTests`, `FinishingFilterTests`, `SubjectRemovalTests`, `RemoveBackgroundTests` | pass |
| Crop, trim, canvas size, image size; PNG and JPEG export | Crops to 1000 × 600. Canvas Size widens it to 1400. Trim finds the photo's own 1200 again. Image Size halves it. Exports a PNG through the save panel and a JPEG through its sheet and panel, then reads both back at 600 × 300. | [10](acceptance/10-canvas-size.png), [11](acceptance/11-export-jpeg.png) | `CropTests`, `ImageTrimTests`, `ProjectResizeTests`, `ProjectExportTests`, `JPEGExportTests` | pass |
| Imports | Takes JPEG, PNG, TIFF, WebP (red and clear halves kept), HEIC, SVG, a layered Photoshop file (it arrives as a folder) and a camera RAW file, which asks how to develop it. That makes nine entries, eight image layers and one folder, with no import error. | [12](acceptance/12-raw-develop.png), [13](acceptance/13-imports.png) | `ImageImportTests`, `PSDImportTests`, `RawImporterTests`, `RawDevelopTests`, `SVGImportTests` | pass |
| Open Recent; Keyboard Shortcuts window | Saves a project, and Open Recent lists it. The shortcuts window opens over the editor. | [15](acceptance/15-shortcuts.png) | `OpenRecentTests`, `RecentProjectsCoreTests`, `KeyboardShortcutsTests`, `KeyboardShortcutsSheetTests` | pass |
| Scrub a number by its label | Dragging the Opacity title 40 points left lowers the layer's opacity. | [14](acceptance/14-scrub.png) | `NumericScrubTests`, `ScrubbableLabelsTests`, `ScrubbableSheetsTests` | pass |
| Keep working while a project saves | Another layer is added while a save runs. The file holds the state the save began with. | — | `ProjectSaveWhileEditingTests` | pass |
| Reload a project changed on disk | With unsaved edits open, another program renames a layer on disk. The app's alert asks, Revert is chosen, and the saved state shows with the new name. | [15](acceptance/15-shortcuts.png) | `ExternalChangeTests` | pass |
| Follows the Omarchy theme | Starts in the built-in dark. A Catppuccin Latte `colors.toml` written while the app runs retints the window. | [16](acceptance/16-omarchy-light.png) | `OmarchyThemeTests` | pass |
| Builds and tests on each system | The full suite, 360 binaries, passes on Ubuntu 24.04 (Qt 6.4.2), Ubuntu 26.04 (6.10.2), Arch (6.12.0, GCC 16.2), Fedora 43 (6.10.3) and 44 (6.11.2), and NixOS (6.11.2). Warnings count as errors. | — | `scripts/distro-check.sh` | pass |
| Packages and install scripts | Each install script runs from Gitea in a fresh container. On Ubuntu 24.04 and 26.04, Fedora 43 and 44 and Arch, it checks its package against `SHA256SUMS`, installs it and starts 1.4.9. On NixOS, `nixos.sh` resolves `v1.4.9`, builds its flake and starts 1.4.9. Debian 13 is refused, as intended. Run on 2026-10-11 against the published release: `omaphoto 1.4.9` installed on each, the app up until an 8 s timeout (Qt 6.4.2 on 24.04, 6.10.2 on 26.04), the flake locked at `9bff275`. | — | `DesktopIntegrationTests`, `scripts/release.sh` | pass |
## New in 1.4.5
| Feature | What the run checks | Screenshot | Suites that hold the details | Result |
| --- | --- | --- | --- | --- |
| Select › Color Range | Samples the red disc on the canvas; the selection covers the disc, 400 pixels across, and leaves the ground. | — | `ColorRangeTests`, `ColorRangeSheetTests`, `ColorRangeCanvasTests` | pass |
| View › Grid Settings | Types a spacing of 100 in the sheet; Restore Defaults puts the grid back. | — | `GridSettingsSheetTests`, `GuideGridTests` | pass |
| Snapping for selections and shapes | A marquee and a rectangle shape drawn near a guide land on it; with Ctrl they do not. | — | `SnapDrawingCanvasTests`, `TransformSnapTests` | pass |
| New Canvas presets | Chooses Instagram Story from the ⋮ menu; the fields and the check follow, and Create makes 1080 × 1920. | — | `NewCanvasSheetTests` | pass |
| Quick Look preview | A saved project holds `QuickLook/Preview.jpg`, 576 × 1024, and its manifest keeps its four layers. | — | `QuickLookPreviewTests` | pass |
| Ungroup Layers | Ctrl+Shift+G on a folder leaves its layers where the folder stood, in order; Ctrl+Z brings the folder back. | — | `GroupSessionTests`, `LayerContextMenuTests` | pass |
| Project tabs reorder and overflow | Drags a tab past its neighbour; with many tabs, the "more tabs" menu switches to a hidden one. | [17](acceptance/17-tabs.png) | `ProjectTabDragTests`, `ProjectTabLayoutTests` | pass |
| Fonts on selected letters and previews | In the open font menu, the pointer over a face previews it on the selected word and Escape puts it back; a click gives the word that face; Ctrl+Z takes back typing. | — | `TextFontRunsTests`, `TypeFontPreviewTests`, `InlineTextDrawTests` | pass |
| Painting at the layer's resolution | Paints on a layer scaled to 25%: its pixels stay 800 wide and the stroke is 80 of them across. A hidden layer says why it cannot paint. | — | `NativeResolutionPaintTests`, `PaintRefusalTests` | pass |
| Liquify, Smudge and Blur | Liquify moves stripes and leaves few grays; Smudge leaves one fading trail; a Radius of 20 typed in the bar softens wider than 2. | — | `SmudgeLiquifyTests`, `WarpStrokeTests`, `BlurToolTests` | pass |
| Masks as Photoshop's | The mask button reveals the selection, Alt hides it; Alt-click on the mask thumbnail shows the mask alone on the canvas, black where it hides and white past it. | [18](acceptance/18-mask-alone.png) | `MaskAloneTests`, `SelectionEditTests` | pass |
| Move tool keys and values | Held Ctrl flips Auto Select; a typed width applies as one step. | — | `TransformInspectorTests`, `ResizeSnapCanvasTests` | pass |
| Blending and a sharper canvas | Soft Light and a clipping stack in Linear Dodge show on the canvas as exported. | — | `SharpCanvasTests`, `BlendingTests`, `ClippingStackTests` | pass |
| Camera Raw curves | Lifts a region of the parametric curve and adds a point on the point curve, then OK; the ground brightens. | — | `CameraRawCurveTests`, `CameraRawCurveGraphTests` | pass |
| Dither ASCII and Scanlines | In the Dither sheet, ASCII of "@" draws that letter, holes and all, about twice as tall at a text size of 28; Filter › Scanlines, a filter of its own since 1.4.9, draws dark gaps between rows. | — | `DitherTests`, `ScanlinesTests` | pass |
| Export JPEG preview | The View keys zoom the preview to 100%, in, and back to Fit. | — | `JPEGPreviewTests`, `ExportAsSheetTests` | pass |
| Quit mid-edit | Quits with a gradient pending: it is applied before the one question. With Levels open, Levels closes and the window goes. | — | `QuitPendingEditsTests`, `WindowTeardownTests` | pass |
## New in 1.4.6 to 1.4.9
| Feature | What the run checks | Screenshot | Suites that hold the details | Result |
| --- | --- | --- | --- | --- |
| Search Commands and Rotate Canvas 90° | Ctrl+F, "rotate canvas clockwise" and Return turn a 1200 × 800 photo to 800 × 1200, one undo step; the new top left holds the old bottom left's pixels. | [19](acceptance/19-search.png) | `CommandPaletteTests`, `CommandPaletteWindowTests`, `CanvasRotationTests` | pass |
| Toggle Fullscreen | F puts the menus and toolbar away over the whole screen; Escape brings them back. | — | `CanvasOnlyWindowTests`, `CanvasOnlySessionTests` | pass |
| Last Filter | After a Gaussian Blur of radius 3 typed in its sheet, Filter › Last Filter is titled for it and blurs again as one more step, without its panel, to the same pixels the sheet makes at radius 3. | — | `LastFilterTests` | pass |
| Camera Raw as Photoshop's | Temperature +100 typed into the docked panel turns mid gray to Photoshop's own warm (235, 189, 117), within four levels. | [20](acceptance/20-camera-raw.png) | `CameraRawTests`, `CameraRawTablesTests`, `CameraRawFoldTests` | pass |
| Navigator and Ctrl with the middle button | At 400% the minimap shows in the corner; a click at its edge centres the view on the document point under it, both ways. Ctrl with the middle button dragged up 100 points doubles the zoom. | [21](acceptance/21-navigator.png) | `NavigatorTests`, `CanvasNavigationTests` | pass |
| New Canvas in units, DPI and background | The blank start tab has no close button and Close Project leaves that very tab. Inches, 300 DPI and White make a 2 × 1 inch canvas of 600 × 300 white pixels at 300 DPI. | — | `NewCanvasUnitTests`, `NewCanvasSheetTests`, `ProjectTabStripTests` | pass |
| Export As PDF | File › Export As, PDF chosen in the sheet, writes a one-page PDF 1.4 whose page is 144 × 72 points, two inches by one. | [22](acceptance/22-export-as.png) | `ExportPDFTests`, `ExportAsSheetTests`, `ProjectExportTests` | pass |
## Known differences from Compositor
`AGENTS.md` lists each place where the port differs from the Swift app, and why. The main ones:
- Remove Background and Select Subject use U²-Net on ONNX Runtime, not Apple's Vision.
- Rendering is CPU only.
- There is no updater.
- A `.comp` project is a folder.
- A Wayland compositor places windows itself.
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+54
View File
@@ -0,0 +1,54 @@
# OmaPhoto project format, versions 1–11
OmaPhoto reads and writes [Compositor](https://github.com/robbietilton/Compositor)'s project format; this page follows Compositor's own description.
A `.comp` project is a folder (a document package on macOS) containing `manifest.json` and an `images/` directory of `<layer UUID>.png` assets.
The manifest identifies `com.compositor.project`, version `11` for new saves (versions `1`–`10` remain readable), and the sRGB working space. It stores document UUID, pixel dimensions, active layer UUID, and layers in bottom-to-top order. Each layer stores its UUID, name, visibility, transform (origin, size, clockwise rotation, flips, sampling), and optional image filename. Blank layers have no image asset.
Embedded PNGs preserve source pixels and transparency; transforms remain separate. Projects survive moving or deleting imported source photos. Saving writes a hidden sibling folder and syncs it. A first save renames it into place; a later save swaps it with the old project atomically (`renameat2` with `RENAME_EXCHANGE`). Where the file system knows no exchange, the old project is moved aside and the new one moved in, two steps with a moment between them and a warning in the log, and the old copy is kept until the new one is synced. Unsupported versions, invalid metadata, missing assets, unsafe paths, and oversized data are rejected before replacing the live document.
Limits: 30,000 pixels per canvas/image side, a pixel budget for layer images and another as large for masks (a sixteenth of the computer's memory in pixels, between 200 and 800 million: `src/Document/DocumentLimits.cpp`), 200 million pixels for any one surface, 10,000 layers, 4 MiB manifest, 512 MiB per encoded asset. See `src/IO/ProjectStore.cpp` for validation.
Undo history and viewport are session-only. Opening fits the canvas, restores selection, and starts with clean history. Future editable features must extend the schema and round-trip tests. PNG export is a flattened derivative and does not mark project edits saved.
Image Size adds optional `resolution` (pixels/inch, 1–9600). Older manifests without it default to 72. This additive field retains version 1 compatibility. Both PNG and JPEG exports include document resolution metadata. Resampling stores the new layer pixels and bounds; undo retains the prior sources only during the current session.
Version 2 adds optional `parentID` and `isGroup` on layer records. A group has no image file. Root nodes have no parent; children refer to an existing group. Array order defines bottom-to-top sibling order; renderers traverse each group as a contiguous subtree. Visibility is inherited without changing child flags. Cycles, missing/non-group parents, image-bearing groups, and nesting beyond 64 ancestor levels are rejected. Group ancestors permit room for leaf nodes at the deepest level. Group metadata survives image/canvas resizing and cropping. Older app builds reject version 2 rather than misrender grouped documents. Collapse state is not serialized.
Version 3 adds optional per-layer `opacity` (finite 0–1) and `blendMode` (Normal, Multiply, Screen, Overlay, Darken, Lighten, Difference, Color Dodge, Color Burn). Missing fields default to full opacity and Normal. Group records required those defaults until version 8, which lets a folder carry its own opacity; a folder's opacity multiplies into every layer inside it, while its blend mode stays Normal because folders are pass-through. Effects are applied during compositing and retained as metadata when resizing sources. Files declaring older versions cannot contain non-default appearance values.
Version 4 adds optional `maskFile` and `maskEnabled` fields to individual layers. Mask filenames must be `<layer UUID>.mask.png` under `images/`; enabled defaults to true when a mask exists. Records without masks omit both fields. Groups cannot carry masks in this version. Files declaring versions 1–3 cannot contain mask metadata.
Masks store 8-bit grayscale coverage without alpha (white reveals, black hides). Their normalized extent matches the image’s local rectangle, so the same layer transform applies to both. A uniform 1×1 mask is valid and avoids allocating full-resolution pixels before painting. Nonuniform mask pixels and a thumbnail are immutable assets shared by history. Image Size resamples them with the image transform; Canvas Size and Crop preserve their pixels. Mask pixels count toward their own budget, as large as the image budget; per-side and per-file limits also apply to masks. Disabled masks remain embedded and editable but do not affect compositing. Image-versus-mask target selection is session-only and reopens on image pixels.
Version 5 adds optional `maskSourceID`: the UUID of a non-group layer supplying live alpha in document coordinates. It multiplies the target’s alpha alongside its enabled raster mask. Source pixels, transform, opacity, raster mask and upstream live masks contribute coverage; visibility and RGB color do not. Sources remain independent layers. Missing references, self-links, cycles, group endpoints and chains over 256 nodes are rejected. Deletion can bake the live coverage into dependent image pixels (retaining their raster masks) or remove the links, as one undoable operation. Links survive image/canvas resize and crop. Older versions default to no live mask; older app builds reject v5.
UI terminology: these alpha links are clipping masks. Alt-click in a row's clipping strip (Option-click on macOS) assigns the lower sibling’s base or releases the connection. Multiple clipped layers share one base, show indented above it, and release when moved outside the contiguous stack. The underlying `maskSourceID` representation is unchanged.
Version 6 allows `maskFile` and `maskEnabled` on group records. A folder has no image, so its mask covers the folder's own transform rectangle (the canvas size when the folder was created); Image Size resamples it through that transform, and Canvas Size and Crop preserve its pixels, exactly as for layer masks. Groups are pass-through, so an enabled folder mask multiplies the coverage of every descendant layer, together with that layer's own mask and any enclosing folders' masks; clipping-mask coverage is unaffected. Files declaring versions 1–5 cannot give a group a mask, and older app builds reject v6.
Version 7 adds adjustment layers: a layer record with an optional `adjustment` object and no `imageFile`. Adjustment layers cannot be groups and cannot carry `text`; like any layer they take a transform, opacity, blend mode, raster mask and clipping link, and they affect everything composited below them. Folders are pass-through, so an adjustment inside a folder also changes what lies below the folder; only a clipping stack gives its members a backdrop of their own. `kind` is one of `Hue/Saturation`, `Levels`, `Curves`, `Exposure`, `Gradient Map`, `Grain`, `Invert`, `Black & White` and `Color Balance` (version 9 adds three more). Every adjustment, whatever its kind, must carry `hue`, `saturation`, `lightness`, `colorize`, `levels` and `curves`; the other kinds' settings are optional, and a missing one takes that kind's own defaults (not always an identity: Gaussian Blur's radius defaults to 10). The settings: `hue` (±360), `saturation` and `lightness` (±100), `colorize` and `hsvSettings` for Hue/Saturation (`hsvSettings`, when present, wins over the other four); `levels` (four channel ranges, RGB then red, green, blue); `curves` (four channel point lists); `exposureSettings`; `gradientMapSettings` (`shadows`, `highlights`, `reversed`); `grainSettings`; `blackWhiteSettings`; `colorBalanceSettings`. Out-of-range or non-finite values are rejected. Files declaring versions 1–6 cannot contain adjustment records, and older app builds reject v7. See `src/Document/LayerAdjustment.cpp` for the exact ranges.
Version 8 lets a folder carry its own `opacity`, which multiplies into every layer inside it; a folder's blend mode stays Normal because folders are pass-through (files declaring 1–7 require folders at full opacity). It also adds an optional top-level `guides` array of alignment guides, each with `id`, `axis` (`horizontal` or `vertical`) and `position` in document pixels (finite, at most 1,000,000 in magnitude). At most 1,000 guides are stored; files declaring 1–7 cannot contain guides. Guides survive Canvas Size and Crop by offsetting with the canvas.
Version 9 adds three adjustment kinds that sample neighboring pixels: `Gaussian Blur` (`blurRadius`, 0.1–250 document pixels), `Motion Blur` (`motionAngle`, −90 to 90 degrees, and `motionDistance`, 1–2000) and `Add Noise` (`noiseAmount`, 0.1–400, `noiseGaussian`, `noiseMonochromatic` and `noiseSeed`, so the pattern is stable between sessions). Files declaring 1–8 cannot contain these kinds; the earlier adjustment kinds remain valid at version 7 and up.
Version 10 lets a text layer color some of its letters differently: optional `colorRuns` in its `text` metadata (see Editable text). Files declaring 1–9 cannot contain it.
Version 11 lets those letters use different faces too: optional `fontRuns` in the same metadata. Files declaring 1–10 cannot contain it. `colorRuns` stays valid from version 10.
### Additive layer fields
Later fields are optional and not gated on the version, so older readers ignore them and keep the pixels or the linked mask as they were:
- `maskPlacement` and `maskLinked`: an unlinked mask (`maskLinked` false; missing means linked) keeps its own transform in `maskPlacement`, a document-space rectangle like the layer transform, and no longer follows the layer when it moves. `maskPlacement` requires a `maskFile`; `maskLinked` without a mask is accepted and changes nothing.
- `shape`: a layer made with the Shape tool keeps its style (`kind`, `red`/`green`/`blue`, `cornerRadius` in document pixels, and for lines `lineWidth` plus `start` and `end` as fractions of the layer box) so it redraws cleanly when scaled. Its PNG is still an ordinary raster; once anything else changes those pixels the metadata is dropped.
### Editable text
Pixel layer records may include optional `text` metadata: content, PostScript font name, font size in pixels, RGB color, alignment, tracking, line spacing and optional `boxSize` paragraph bounds. Text wraps inside these bounds; changing them reflows the text without scaling the font. The PNG remains the display and export fallback. Older readers ignore this metadata. Transforms, duplication, masks, Canvas Size, Crop, Trim and an Image Size that changes only the resolution preserve it; destructive pixel operations, resampling included, rasterize text and omit the metadata on the next save. A missing face is replaced through Qt and fontconfig when edited, which can pick another family, while the saved PNG preserves the original appearance until then. From version 10, optional `colorRuns` lists letters painted in another color than the text's own `red`/`green`/`blue`: each run has `location` and `length` in UTF-16 units of the content, plus `red`, `green` and `blue` (0–1). From version 11, optional `fontRuns` lists letters set in another face than `fontName`: the same `location` and `length`, plus `fontName`. Runs of either kind are sorted, do not overlap, have a positive length and end within the content; letters outside every run use the text's color or face.
### Layer effects
An optional `effects` record contains independent `stroke`, `shadow`, `colorOverlay`, `innerShadow`, `outerGlow` and `innerGlow` records. Stroke carries a size (0–500 layer pixels), a color, an opacity and an `inside` flag choosing which side of the edge it sits on; drop shadow and inner shadow each carry an angle, a distance, a blur, a color and an opacity; color overlay carries a color and an opacity; outer glow and inner glow each carry a size (0–500 layer pixels), a color and an opacity. Each supports optional `enabled` visibility (missing means visible); hidden effects keep all parameters and remain listed under their layer. Effects, including their visibility, are saved and participate in document undo. They stay with their layer through every edit, pixel edits included; Image Size scales their sizes, distances and blurs with the image. Canvas previews run on a serial background worker with a shared pixel budget; exports render the full-resolution effects. A record omitting an effect means that layer does not have it, so older readers see the effects they understand and ignore the rest.
+139
View File
@@ -0,0 +1,139 @@
# Writing OmaPhoto projects (for AI agents and scripts)
An OmaPhoto project (`.comp`) is a folder of PNG layer images plus a `manifest.json`: the same format as [Compositor](https://github.com/robbietilton/Compositor)'s, so a project moves between the two apps. Anything that can write files can build or edit one, and OmaPhoto updates the open canvas as the files change. No plugin or API is involved.
## Try it
1. Open a project in OmaPhoto 1.3 or later (save a new canvas somewhere, e.g. `~/Pictures/demo.comp`), and keep it open.
2. Ask an AI agent that can edit files on your computer (Claude Code, Codex and the like):
> Read docs/writing-comp-files.md in git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto, then design a moody night scene in ~/Pictures/demo.comp. Work in steps, one or two layers at a time.
3. Watch the canvas. Each time the agent writes the project, OmaPhoto reloads it, usually within half a second.
What you get are ordinary layers: select them, change their opacity or blend mode, paint on their masks, save.
## The project folder
```
Example.comp/
├── manifest.json
└── images/
├── 6F1D3C2A-0B7E-4E8A-9C4D-2A1B3C4D5E6F.png a layer's pixels
└── 6F1D3C2A-0B7E-4E8A-9C4D-2A1B3C4D5E6F.mask.png its mask (optional)
```
A minimal manifest with one full-canvas image layer:
```json
{
"format": "com.compositor.project",
"version": 11,
"colorSpace": "sRGB",
"documentID": "0C5E7A91-3B2D-4F6A-8E1C-9D0B7A6F5E4D",
"width": 1920,
"height": 1080,
"resolution": 72,
"activeLayerID": "6F1D3C2A-0B7E-4E8A-9C4D-2A1B3C4D5E6F",
"layers": [
{
"id": "6F1D3C2A-0B7E-4E8A-9C4D-2A1B3C4D5E6F",
"name": "Background",
"imageFile": "6F1D3C2A-0B7E-4E8A-9C4D-2A1B3C4D5E6F.png",
"isVisible": true,
"isGroup": false,
"opacity": 1,
"blendMode": "Normal",
"transform": {
"origin": [0, 0],
"size": [1920, 1080],
"rotation": 0,
"flipX": false,
"flipY": false,
"sampling": "High quality"
}
}
]
}
```
- `layers` runs **bottom to top**: the last layer draws on top.
- Keep `documentID` as it is when editing an existing project.
- `transform` places the layer in document pixels: `origin` is its top-left corner, `size` its width and height, `rotation` is in degrees, clockwise. The image is stretched to `size`, so a layer can be smaller than the canvas (a cut-out placed with `origin`) or scaled.
- `sampling` is `"High quality"`, `"Smooth"` or `"Nearest"`.
- `opacity` runs from 0 to 1.
## Rules that matter
Break one of these and OmaPhoto refuses the whole file **without any message**: the open canvas just stays as it was. If nothing updates, check these first.
- **Image files are named after their layer.** A layer with `"id": "6F1D…"` must use `"imageFile": "6F1D….png"`, and a mask `"maskFile": "6F1D….mask.png"`, with the ID in uppercase as written in the manifest. One ID per layer, unique in the project.
- **Images are PNGs** in `images/`, at most 8 bits a channel: 16-bit PNGs are refused. Write layer images as 8-bit RGBA (RGB, grayscale and lower-depth grayscale load too, converted); masks are 8-bit grayscale (white shows the layer, black hides it).
- **Blend modes are spelled exactly** as OmaPhoto names them: `Normal`, `Darken`, `Multiply`, `Color Burn`, `Linear Burn`, `Lighten`, `Screen`, `Color Dodge`, `Linear Dodge (Add)`, `Overlay`, `Soft Light`, `Hard Light`, `Vivid Light`, `Linear Light`, `Pin Light`, `Hard Mix`, `Difference`, `Exclusion`, `Subtract`, `Divide`, `Hue`, `Saturation`, `Color`, `Luminosity`.
- **Every `imageFile` and `maskFile` the manifest names is in place**, and the manifest is valid JSON. Blank layers, folders and adjustment layers have no `imageFile`.
## Writing safely while the project is open
OmaPhoto reads the project as soon as it changes, so never leave it half written:
1. Write any new or changed PNGs into `images/` first.
2. Then write the manifest to a temporary file inside the project folder (for example `.manifest.json.tmp`) and rename it over `manifest.json`. A rename is atomic: OmaPhoto sees either the old manifest or the new one, never part of one.
To change an existing layer, keep its `id` and overwrite its PNG, then rewrite the manifest. The layer updates in place, in the same spot in the stack.
Remove images you no longer reference once the manifest no longer lists them.
A project OmaPhoto saved also has a `QuickLook` folder (`Preview.jpg`), which Finder shows as its Space-bar preview on a Mac. When you change a project, delete that folder, so Finder doesn't preview an out-of-date picture; OmaPhoto writes it again the next time it saves.
## What the open app does
- It reloads about a third of a second after writes stop. Several writes in quick succession arrive as one update, so pause briefly between steps if a viewer should see each one.
- A reload keeps the zoom, scroll and selection, but clears undo, as reopening a file does.
- If the person has unsaved changes of their own, OmaPhoto asks them to revert to your version or keep theirs, and never replaces their work silently.
- A write that fails to load is ignored until the next change, so a mistake you then fix will still show up.
- Changes are noticed from the manifest's contents and from each image's name and size, not from when files were written. Rewriting a PNG with different pixels changes its size in practice. If you replace an image with one of exactly the same byte size, also make a change to the manifest, such as renaming the layer (any change to its bytes will do, even added whitespace); writing identical manifest bytes back isn't enough.
- Replacing the whole project folder at once is noticed too. A populated folder cannot be renamed over another on Linux; exchange the two instead with `renameat2(RENAME_EXCHANGE)` (Python's `os.rename` cannot), then remove the old one.
## Masks
Add a mask to any layer with `"maskFile": "<id>.mask.png"` and `"maskEnabled": true`. The mask is stretched over the layer's box, so it usually has the same pixel size as the layer's image; a 1×1 mask covers the whole layer with one value. Soft grays give soft edges.
## Adjustment layers
An adjustment layer has an `adjustment` object and no `imageFile`, and it affects everything below it. Every kind carries identity `levels` and `curves` blocks, plus its own settings. A warming Curves layer:
```json
{
"id": "A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D",
"name": "Warm Grade",
"isVisible": true,
"isGroup": false,
"opacity": 1,
"blendMode": "Normal",
"transform": { "origin": [0, 0], "size": [1920, 1080], "rotation": 0, "flipX": false, "flipY": false, "sampling": "High quality" },
"adjustment": {
"kind": "Curves",
"hue": 0, "saturation": 0, "lightness": 0, "colorize": false,
"levels": { "channel": "RGB", "ranges": [
{ "black": 0, "gamma": 1, "white": 255, "outputBlack": 0, "outputWhite": 255 },
{ "black": 0, "gamma": 1, "white": 255, "outputBlack": 0, "outputWhite": 255 },
{ "black": 0, "gamma": 1, "white": 255, "outputBlack": 0, "outputWhite": 255 },
{ "black": 0, "gamma": 1, "white": 255, "outputBlack": 0, "outputWhite": 255 } ] },
"curves": { "channel": "RGB", "channels": [
[ { "x": 0, "y": 0 }, { "x": 255, "y": 255 } ],
[ { "x": 0, "y": 0 }, { "x": 120, "y": 147 }, { "x": 255, "y": 255 } ],
[ { "x": 0, "y": 0 }, { "x": 100, "y": 114 }, { "x": 255, "y": 255 } ],
[ { "x": 0, "y": 0 }, { "x": 115, "y": 97 }, { "x": 255, "y": 238 } ] ] }
}
}
```
- `ranges` and `channels` run RGB, then red, green, blue. Curve points run from x 0 to x 255, in increasing x.
- `kind` is one of `Hue/Saturation`, `Levels`, `Curves`, `Exposure`, `Gradient Map`, `Grain`, `Invert`, `Black & White`, `Color Balance`, `Gaussian Blur`, `Motion Blur`, `Add Noise`.
- For Hue/Saturation, set `hue`, `saturation` and `lightness` on the adjustment itself when it has no `hsvSettings`. A Hue/Saturation layer saved by OmaPhoto carries `hsvSettings`, which wins over those three: there, edit its Master entry in `hsvSettings.adjustments`, a list of range names each followed by its values (for example `["Master", { "hue": 120, "saturation": 0, "lightness": 0 }]`), and leave the other ranges as they are. Color Balance takes a `colorBalanceSettings` object (`shadowCyanRed`, `shadowMagentaGreen`, `shadowYellowBlue`, and the same for `mid` and `highlight`, each −100 to 100, plus `preserveLuminosity`).
- For the other kinds, the easiest way to get the exact shape is to add one in OmaPhoto, save, and copy it from that project's manifest.
## More
- Folders, text layers, layer effects and everything else the format holds: [project-format.md](project-format.md).
- Limits: canvases up to 30,000 pixels on a side; layers and masks count toward a memory budget that scales with the computer's memory.
+6 -5
View File
@@ -13,14 +13,14 @@
in {
packages.x86_64-linux.default = pkgs.stdenv.mkDerivation {
pname = "omaphoto";
version = "1.2.3";
# dev.sh builds in ./build; the package starts clean.
version = "1.4.9";
# dev.sh's build and the Swift spec stay out.
src = pkgs.lib.cleanSourceWith {
src = self;
filter = path: type: path != "${toString self}/build";
filter = path: type: !(builtins.elem path [ "${toString self}/build" "${toString self}/Compositor-Ref" ]);
};
nativeBuildInputs = with pkgs; [ cmake ninja pkg-config qt6.wrapQtAppsHook ];
buildInputs = with pkgs; [ qt6.qtbase qt6.qtimageformats libheif libde265 libraw fontconfig onnxruntime ];
buildInputs = with pkgs; [ qt6.qtbase qt6.qtsvg qt6.qtimageformats libheif libde265 libraw fontconfig xz onnxruntime ];
cmakeFlags = [ "-DOMAPHOTO_MODEL=${model}" ];
ninjaFlags = [ "omaphoto" ];
# Staged: profiles build MIME caches outside the store.
@@ -39,7 +39,8 @@
# fontconfig's own rules, which NixOS keeps in /etc.
includes = [ "${pkgs.fontconfig.out}/etc/fonts/conf.d" ];
};
XDG_DATA_DIRS = "${pkgs.runCommand "u2net" { } "mkdir -p $out/share/omaphoto && ln -s ${model} $out/share/omaphoto/u2net.onnx"}/share";
# The MIME database too, which NixOS keeps in its profile.
XDG_DATA_DIRS = "${pkgs.runCommand "u2net" { } "mkdir -p $out/share/omaphoto && ln -s ${model} $out/share/omaphoto/u2net.onnx"}/share:${pkgs.shared-mime-info}/share";
};
};
}
+815 -284
View File
File diff suppressed because it is too large. Load diff
+50 -14
View File
@@ -30,16 +30,35 @@ void adjust_color_balance(uint8_t *rgba, size_t width, size_t height, size_t str
// After resampling with a filter that rings (Lanczos), premultiplied RGBA colors can exceed their alpha;
// this clamps each channel back to its pixel's alpha. `count` is the number of pixels.
void rgba_clamp_premultiplied(uint8_t *rgba, size_t count);
// Camera Raw's Light and Color groups on premultiplied RGBA pixels, in this order: white balance
// (the three channel gains), exposure in stops of linear light, contrast about mid gray, highlights,
// shadows, whites, blacks, vibrance, then saturation. Temperature and tint are relative, so the gains
// are computed by the caller. Amounts are Camera Raw's own ranges (exposure −5…5, the rest −100…100).
// `clipping` 0 renders the grade; 1 replaces it with a highlight-clip view (clipped channels lit on
// black); 2 replaces it with a shadow-clip view (clipped channels dark on white). Alpha is kept.
void adjust_camera_raw(uint8_t *rgba, size_t width, size_t height, size_t stride,
double redGain, double greenGain, double blueGain, double exposure, double contrast,
double highlights, double shadows, double whites, double blacks,
double vibrance, double saturation, int clipping);
// One step of Camera Raw: up to eight measured color tables (`size`³ sRGB colors, red slowest, 3 bytes each) blended
// by weight, for settings between the ones measured. A null table is no change.
typedef struct {
const uint8_t *table[8];
float weight[8];
} CameraRawStage;
// Runs `count` stages in order over a `grid`³ lattice of sRGB colors into `out` (3 floats per color, 0…1, red
// slowest), so a whole run of stages costs a pixel one lookup.
void camera_raw_compose(float *out, int grid, const CameraRawStage *stages, int count, int size);
// Runs `count` stages over every entry of a composed table already made (3 floats a color), in place.
void camera_raw_compose_onto(float *table, int grid, const CameraRawStage *stages, int count, int size);
// Runs a composed table's colors through a tone curve, then each channel's curve (256 entries each), in place.
void camera_raw_table_curves(float *table, int grid, const float *toneLut, const float *redLut, const float *greenLut,
const float *blueLut);
// Camera Raw's histogram (256 bins each of red, green and blue, added into `bins`) and vectorscope (`side`² cells,
// added into `scope`) from every `step`th pixel each way, opaque ones counting by their alpha.
void camera_raw_scope(const uint8_t *rgba, size_t width, size_t height, size_t stride, int step, double *bins, double *scope,
int side);
// One stage on one sRGB color (0…1), in place.
void camera_raw_stage_color(const CameraRawStage *stage, int size, double *rgb);
// What Camera Raw's adaptive sliders read from an image, as sRGB levels (0…1), opaque pixels counting by their
// alpha: the mean of each pixel's brightest channel in linear light (Contrast), the mean linear luminance (Shadows),
// and the log-average of the brightest channel (Highlights).
void camera_raw_statistics(const uint8_t *rgba, size_t width, size_t height, size_t stride, double *out);
// Camera Raw's Light and Color groups on premultiplied RGBA pixels, through one composed table (null for none).
// `clipping` 0 renders the grade; 1 replaces it with a highlight-clip view (clipped channels lit on black); 2 replaces
// it with a shadow-clip view (clipped channels dark on white). Alpha is kept.
void adjust_camera_raw(uint8_t *rgba, size_t width, size_t height, size_t stride, const float *table, int grid,
int clipping);
// Camera Raw Effects after Light and Color. Texture is a fine local contrast, Clarity a broader one.
// Dehaze raises contrast and saturation when positive and lifts the shadows when negative. Glow, its
// range, spread and warmth do nothing until `glow` is above zero: styles are 0 diffusion, 1 bloom,
@@ -48,20 +67,36 @@ void adjust_camera_raw(uint8_t *rgba, size_t width, size_t height, size_t stride
// the radii match a full-size render. Grain is applied separately. Alpha is kept.
// Blue over clipped shadows and red over clipped highlights, on top of the grade. Preview only.
void adjust_camera_raw_clip_overlay(uint8_t *rgba, size_t width, size_t height, size_t stride, int shadows, int highlights);
// Curve, Color Mixer, and Color Grading after the basic grade. `lumaLut` and the channel LUTs are 256
// Curve, Color Mixer, and Color Grading after the basic grade. `toneLut` and the channel LUTs are 256
// entries. `mixer` is 24 floats: hue, saturation, luminance for eight families, −1…1. Each point color is
// 9 floats (hue, saturation, luminance, three shifts −1…1, three range half-widths). `grade` is four wheels
// of hue turns, saturation 0…1, and luminance −1…1. `visualize` darkens pixels outside that point color.
// `mixerTable` and `gradeTable` (null for none) are the Color Mixer's and Color Grading's composed tables, `grid`³
// colors, run after the curve and before the grading formula.
void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride,
const float *lumaLut, const float *redLut, const float *greenLut, const float *blueLut,
const float *toneLut, const float *redLut, const float *greenLut, const float *blueLut,
double refineSaturation, const float *mixer, int pointCount, const float *points,
const float *grade, double blending, double balance, int visualize);
const float *grade, double blending, double balance, int visualize,
const float *mixerTable, const float *gradeTable, int grid);
void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_t stride,
double texture, double clarity, double dehaze,
double glow, int glowStyle, double glowRange, double glowSpread, double glowWarmth,
double vignetteAmount, double vignetteMidpoint, double vignetteRoundness,
double vignetteFeather, double vignetteHighlights, int vignetteStyle,
double scale);
// Standalone Vignette: blends straight sRGB toward the selected edge color using Camera Raw's
// falloff shape and Highlight Priority. Preserves the source alpha and premultiplied storage.
// The vignette is shaped to the frame (in the image's pixels). With fillsClear it paints transparent pixels too;
// without, it recolors only the pixels that are there.
void adjust_colored_vignette(uint8_t *rgba, size_t width, size_t height, size_t stride,
double frameX, double frameY, double frameWidth, double frameHeight, int fillsClear,
double amount, double midpoint, double roundness, double feather,
double highlights, double red, double green, double blue);
// Local luminance contrast with independent shadow, midtone, and highlight gains.
// `blurred` is the same premultiplied RGBA image blurred at the chosen detail radius.
void adjust_tonal_contrast(uint8_t *rgba, const uint8_t *blurred, size_t width, size_t height,
size_t stride, size_t blurredStride, double amount,
double shadows, double midtones, double highlights);
// Manual noise reduction, then sharpening. `scale` maps radius to preview pixels. Applied after the creative grade.
void adjust_camera_raw_detail(uint8_t *rgba, size_t width, size_t height, size_t stride,
double sharpenAmount, double sharpenRadius, double sharpenDetail, double sharpenMasking,
@@ -75,7 +110,8 @@ void adjust_camera_raw_optics(uint8_t *rgba, size_t width, size_t height, size_t
int removeChromatic, int lensProfile, double profileDistortion, double profileVignetting,
double distortionK, double purpleAmount, double purpleHueLow, double purpleHueHigh,
double greenAmount, double greenHueLow, double greenHueHigh,
double vignetteAmount, double vignetteMidpoint, double scale);
double vignetteAmount, double vignetteMidpoint, double scale,
const uint8_t *exposureTables, int tableSize);
// Camera calibration before the main grade. Primary hue and saturation shifts are −100…100; shadow tint is green/magenta.
void adjust_camera_raw_calibration(uint8_t *rgba, size_t width, size_t height, size_t stride,
double shadowTint, double redHue, double redSaturation,
+643
View File
@@ -0,0 +1,643 @@
#include "DitherPixels.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
#include <stdatomic.h>
// Linux: GCD's dispatch_apply, run on QtConcurrent by Dither.cpp.
void dither_bands(size_t bands, void (*band)(void *context, size_t index), void *context);
static inline float clamp01(float v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
typedef void (*band_body)(void *context, size_t start, size_t end);
typedef struct { size_t count, size; band_body body; void *context; } band_job;
static void run_band(void *context, size_t band) {
const band_job *job = context;
size_t count = job->count, size = job->size;
size_t start = band * size, end = start + size < count ? start + size : count;
if (start < end) job->body(job->context, start, end);
}
// Runs `body` over `count` items split into a few runs per core, each a (start, end) range, all at once.
static void in_bands(size_t count, band_body body, void *context) {
size_t bands = count < 64 ? 1 : 32, size = (count + bands - 1) / bands;
band_job job = { count, size, body, context };
dither_bands(bands, run_band, &job);
}
// Density darkens (positive) or lightens as a gamma, so black and white stay put; contrast pivots on mid gray.
static inline float adjust_tone(float v, float gamma, float contrast) {
v = powf(clamp01(v), gamma);
return clamp01((v - 0.5f) * contrast + 0.5f);
}
// One error-diffusion kernel: neighbors to the right on this row and below, with their weights over `divisor`.
typedef struct { int dx, dy, weight; } Tap;
typedef struct { const Tap *taps; int count; float divisor; } Kernel;
static const Tap atkinson[] = { {1,0,1}, {2,0,1}, {-1,1,1}, {0,1,1}, {1,1,1}, {0,2,1} };
static const Tap floyd[] = { {1,0,7}, {-1,1,3}, {0,1,5}, {1,1,1} };
// Atkinson passes on only six eighths of the error, which is what gives the Mac's crisp, contrasty look.
static Kernel kernel_for(int style) {
return style == DITHER_ATKINSON ? (Kernel){ atkinson, 6, 8 } : (Kernel){ floyd, 4, 16 };
}
static inline float quantize(float v, int levels) {
float steps = (float)(levels - 1);
return roundf(clamp01(v) * steps) / steps;
}
// Diffuses each plane in serpentine order, so the error's drift doesn't streak to one side.
static void diffuse(float *plane, const uint8_t *alpha, size_t width, size_t height, const DitherParams *p) {
Kernel k = kernel_for(p->style);
for (size_t y = 0; y < height; ++y) {
int reverse = (int)(y & 1);
for (size_t i = 0; i < width; ++i) {
size_t x = reverse ? width - 1 - i : i;
size_t at = y * width + x;
if (!alpha[at]) continue;
float old = plane[at], q = quantize(old, p->levels);
plane[at] = q;
float error = (old - q) * p->diffusion / k.divisor;
for (int t = 0; t < k.count; ++t) {
long nx = (long)x + (reverse ? -k.taps[t].dx : k.taps[t].dx), ny = (long)y + k.taps[t].dy;
if (nx < 0 || nx >= (long)width || ny >= (long)height) continue;
plane[(size_t)ny * width + (size_t)nx] += error * (float)k.taps[t].weight;
}
}
}
}
static const uint8_t bayer8[64] = {
0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26,
12, 44, 4, 36, 14, 46, 6, 38, 60, 28, 52, 20, 62, 30, 54, 22,
3, 35, 11, 43, 1, 33, 9, 41, 51, 19, 59, 27, 49, 17, 57, 25,
15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29, 53, 21,
};
// The ordered threshold for a pixel, in [0, 1). Smaller Bayer matrices are the top-left corners of the 8 × 8 one,
// rescaled, which is how the recursive construction nests them.
static inline float ordered_threshold(int style, size_t x, size_t y) {
switch (style) {
case DITHER_BAYER_2: { static const uint8_t m[4] = { 0, 2, 3, 1 }; return ((float)m[(y & 1) * 2 + (x & 1)] + 0.5f) / 4; }
case DITHER_BAYER_4: {
static const uint8_t m[16] = { 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5 };
return ((float)m[(y & 3) * 4 + (x & 3)] + 0.5f) / 16;
}
default: return ((float)bayer8[(y & 7) * 8 + (x & 7)] + 0.5f) / 64;
}
}
static inline float ordered(float v, float threshold, int levels) {
float steps = (float)(levels - 1);
float q = floorf(clamp01(v) * steps + threshold);
return (q > steps ? steps : q) / steps;
}
// How much of a halftone cell a point must be covered by before it's marked, for each screen shape. `u` and `v`
// run from −0.5 to 0.5 across the cell; the shapes grow from its middle as coverage rises.
static inline float spot(int style, float u, float v) {
float au = fabsf(u), av = fabsf(v);
switch (style) {
case DITHER_DOTS: return 3.14159265f * (u * u + v * v);
case DITHER_LINES: return av * 2;
default: return au + av;
}
}
// Old Mac fill patterns, 8 × 8, one byte per row with the leftmost pixel in the top bit, from sparsest to fullest.
static const uint8_t patterns[][8] = {
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 },
{ 0x80, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00 },
{ 0x88, 0x00, 0x22, 0x00, 0x88, 0x00, 0x22, 0x00 },
{ 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01 },
{ 0x88, 0x22, 0x88, 0x22, 0x88, 0x22, 0x88, 0x22 },
{ 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00 },
{ 0x11, 0x22, 0x44, 0x88, 0x11, 0x22, 0x44, 0x88 },
{ 0xAA, 0x00, 0xAA, 0x00, 0xAA, 0x00, 0xAA, 0x00 },
{ 0x88, 0x55, 0x22, 0x55, 0x88, 0x55, 0x22, 0x55 },
{ 0xFF, 0x80, 0x80, 0x80, 0xFF, 0x08, 0x08, 0x08 },
{ 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55 },
{ 0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81 },
{ 0x77, 0xAA, 0xDD, 0xAA, 0x77, 0xAA, 0xDD, 0xAA },
{ 0xEE, 0xDD, 0xBB, 0x77, 0xEE, 0xDD, 0xBB, 0x77 },
{ 0x77, 0xFF, 0xDD, 0xFF, 0x77, 0xFF, 0xDD, 0xFF },
{ 0x7F, 0xFF, 0xFF, 0xFF, 0xF7, 0xFF, 0xFF, 0xFF },
{ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF },
};
static const int patternCount = (int)(sizeof patterns / sizeof patterns[0]);
static inline void write_pixel(uint8_t *px, float r, float g, float b) {
float a = (float)px[3] / 255.0f;
px[0] = (uint8_t)lroundf(clamp01(r) * a * 255.0f);
px[1] = (uint8_t)lroundf(clamp01(g) * a * 255.0f);
px[2] = (uint8_t)lroundf(clamp01(b) * a * 255.0f);
}
typedef struct {
uint8_t *rgba; size_t stride, width, count; uint8_t *alpha; float *tone, *source; const DitherParams *p;
float gamma, contrast;
} tone_job;
static void tone_band(void *context, size_t first, size_t last) {
const tone_job *job = context;
uint8_t *rgba = job->rgba; size_t stride = job->stride, width = job->width, count = job->count;
uint8_t *alpha = job->alpha; float *tone = job->tone, *source = job->source; const DitherParams *p = job->p;
float gamma = job->gamma, contrast = job->contrast;
for (size_t y = first; y < last; ++y) {
const uint8_t *row = rgba + y * stride;
for (size_t x = 0; x < width; ++x) {
const uint8_t *px = row + x * 4;
size_t at = y * width + x;
alpha[at] = px[3];
float r = 0, g = 0, b = 0;
if (px[3]) {
float scale = 1.0f / (float)px[3];
r = px[0] * scale; g = px[1] * scale; b = px[2] * scale;
}
if (p->originalColors) {
tone[at] = adjust_tone(r, gamma, contrast);
tone[count + at] = adjust_tone(g, gamma, contrast);
tone[2 * count + at] = adjust_tone(b, gamma, contrast);
source[at * 3] = r; source[at * 3 + 1] = g; source[at * 3 + 2] = b;
} else {
tone[at] = adjust_tone(0.2126f * r + 0.7152f * g + 0.0722f * b, gamma, contrast);
}
}
}
}
int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const DitherParams *p) {
size_t count = width * height;
if (!count) return 1;
int planes = p->originalColors ? 3 : 1;
float *tone = malloc(count * sizeof(float) * (size_t)planes);
uint8_t *alpha = malloc(count);
// The image's own colors, unadjusted: halftone dots and glyphs take them in Original mode.
float *source = p->originalColors ? malloc(count * sizeof(float) * 3) : NULL;
if (!tone || !alpha || (p->originalColors && !source)) { free(tone); free(alpha); free(source); return 0; }
float gamma = exp2f(p->density * 1.5f);
float contrast = p->contrast >= 0 ? 1.0f / (1.0f - 0.95f * p->contrast) : 1.0f + p->contrast;
tone_job toning = { rgba, stride, width, count, alpha, tone, source, p, gamma, contrast };
in_bands(height, tone_band, &toning);
float dark[3] = { p->dark[0] / 255.0f, p->dark[1] / 255.0f, p->dark[2] / 255.0f };
float light[3] = { p->light[0] / 255.0f, p->light[1] / 255.0f, p->light[2] / 255.0f };
int style = p->style;
int levels = p->levels < 2 ? 2 : p->levels > 16 ? 16 : p->levels;
if (style <= DITHER_BAYER_8) {
// Diffusion and ordered dithering: each plane is quantized to `levels` tones, then mapped to colors.
if (style <= DITHER_FLOYD_STEINBERG) {
DitherParams local = *p;
local.levels = levels;
for (int c = 0; c < planes; ++c) diffuse(tone + (size_t)c * count, alpha, width, height, &local);
} else {
for (int c = 0; c < planes; ++c) {
float *plane = tone + (size_t)c * count;
for (size_t y = 0; y < height; ++y)
for (size_t x = 0; x < width; ++x) {
size_t at = y * width + x;
if (alpha[at]) plane[at] = ordered(plane[at], ordered_threshold(style, x, y), levels);
}
}
}
for (size_t y = 0; y < height; ++y) {
uint8_t *row = rgba + y * stride;
for (size_t x = 0; x < width; ++x) {
size_t at = y * width + x;
if (!alpha[at]) continue;
if (p->originalColors) {
write_pixel(row + x * 4, tone[at], tone[count + at], tone[2 * count + at]);
} else {
float t = tone[at];
write_pixel(row + x * 4, dark[0] + (light[0] - dark[0]) * t, dark[1] + (light[1] - dark[1]) * t,
dark[2] + (light[2] - dark[2]) * t);
}
}
}
} else {
// Marks (halftone shapes, patterns, glyphs) cover as much of each spot as the tone calls for. On light, they
// stand for darkness and are drawn in the dark color; light on dark, the reverse.
float *marks = p->originalColors ? malloc(count * sizeof(float)) : tone;
if (!marks) { free(tone); free(alpha); free(source); return 0; }
if (p->originalColors)
for (size_t i = 0; i < count; ++i)
marks[i] = 0.2126f * tone[i] + 0.7152f * tone[count + i] + 0.0722f * tone[2 * count + i];
int cell = p->cell < 2 ? 2 : p->cell;
float cosA = cosf(p->angle), sinA = sinf(p->angle);
float *ink = p->lightOnDark ? light : dark, *paper = p->lightOnDark ? dark : light;
// Glyphs: each cell shares one, picked from the cell's average tone, worked out once per cell.
size_t gw = (size_t)(p->glyphWidth < 1 ? 1 : p->glyphWidth), gh = (size_t)(p->glyphHeight < 1 ? 1 : p->glyphHeight);
size_t columns = (width + gw - 1) / gw, cellRows = (height + gh - 1) / gh;
int *picked = NULL;
if (style == DITHER_GLYPHS && p->glyphCount > 0) {
picked = malloc(columns * cellRows * sizeof(int));
if (!picked) { if (marks != tone) free(marks); free(tone); free(alpha); free(source); return 0; }
for (size_t row = 0; row < cellRows; ++row)
for (size_t column = 0; column < columns; ++column) {
float sum = 0; int n = 0;
for (size_t yy = row * gh; yy < (row + 1) * gh && yy < height; ++yy)
for (size_t xx = column * gw; xx < (column + 1) * gw && xx < width; ++xx) {
size_t i = yy * width + xx;
if (alpha[i]) { sum += marks[i]; ++n; }
}
float t = n ? sum / (float)n : 1;
float wanted = (p->lightOnDark ? t : 1 - t) * p->glyphCoverage[p->glyphCount - 1];
int best = 0;
float bestDistance = 2;
for (int g = 0; g < p->glyphCount; ++g) {
float d = fabsf(p->glyphCoverage[g] - wanted);
if (d < bestDistance) { bestDistance = d; best = g; }
}
picked[row * columns + column] = best;
}
}
// Original colors: marks take the pixel's own color, on black (light on dark) or white.
float paperOriginal = p->lightOnDark ? 0.0f : 1.0f;
for (size_t y = 0; y < height; ++y) {
uint8_t *row = rgba + y * stride;
for (size_t x = 0; x < width; ++x) {
size_t at = y * width + x;
if (!alpha[at]) continue;
float amount;
if (picked) {
int glyph = picked[(y / gh) * columns + x / gw];
amount = p->glyphs[(size_t)glyph * gw * gh + (y % gh) * gw + x % gw] / 255.0f;
} else if (style == DITHER_PATTERNS) {
float t = marks[at];
float coverage = p->lightOnDark ? t : 1 - t;
int index = (int)lroundf(coverage * (float)(patternCount - 1));
amount = (patterns[index][y & 7] >> (7 - (x & 7))) & 1;
} else {
float fx = (float)x + 0.5f, fy = (float)y + 0.5f;
float u = (fx * cosA + fy * sinA) / (float)cell, v = (-fx * sinA + fy * cosA) / (float)cell;
u -= floorf(u) + 0.5f; v -= floorf(v) + 0.5f;
float t = marks[at];
amount = (p->lightOnDark ? t : 1 - t) > spot(style, u, v) ? 1 : 0;
}
if (p->originalColors) {
const float *s = source + at * 3;
write_pixel(row + x * 4, paperOriginal + (s[0] - paperOriginal) * amount,
paperOriginal + (s[1] - paperOriginal) * amount, paperOriginal + (s[2] - paperOriginal) * amount);
} else {
write_pixel(row + x * 4, paper[0] + (ink[0] - paper[0]) * amount, paper[1] + (ink[1] - paper[1]) * amount,
paper[2] + (ink[2] - paper[2]) * amount);
}
}
}
free(picked);
if (marks != tone) free(marks);
}
free(tone); free(alpha); free(source);
return 1;
}
void dither_dots(uint8_t *rgba, size_t width, size_t height, size_t stride, int block, const uint8_t *gap) {
if (block < 2) return;
float radius = (float)block * 0.42f, middle = (float)block / 2;
for (size_t y = 0; y < height; ++y) {
uint8_t *row = rgba + y * stride;
float dy = (float)(y % (size_t)block) + 0.5f - middle;
for (size_t x = 0; x < width; ++x) {
uint8_t *px = row + x * 4;
if (!px[3]) continue;
float dx = (float)(x % (size_t)block) + 0.5f - middle;
float cover = clamp01(radius - sqrtf(dx * dx + dy * dy) + 0.5f);
if (cover >= 1) continue;
for (int c = 0; c < 3; ++c)
px[c] = (uint8_t)lroundf((float)px[c] * cover + (float)gap[c] * (float)px[3] / 255.0f * (1 - cover));
}
}
}
typedef struct { uint8_t *rgba; const uint8_t *glow; size_t width, stride; float amount; } glow_job;
static void glow_band(void *context, size_t first, size_t last) {
const glow_job *job = context;
uint8_t *rgba = job->rgba; const uint8_t *glow = job->glow; size_t width = job->width, stride = job->stride;
float amount = job->amount;
for (size_t y = first; y < last; ++y) {
uint8_t *row = rgba + y * stride;
const uint8_t *light = glow + y * stride;
for (size_t x = 0; x < width * 4; x += 4) {
float a = row[x + 3];
for (int c = 0; c < 3; ++c) {
// The light eases in as the pixel nears full brightness rather than clipping there, so where the
// picture is bright the gaps between lines glow without filling up to the lines.
float v = row[x + c], added = (float)light[x + c] * amount * a / 255.0f, room = (a - v) * 0.7f;
if (room > 0) v += room * (1 - expf(-added / room));
row[x + c] = (uint8_t)lroundf(v > a ? a : v);
}
}
}
}
void dither_glow(uint8_t *rgba, const uint8_t *glow, size_t width, size_t height, size_t stride, float amount) {
glow_job glowing = { rgba, glow, width, stride, amount };
in_bands(height, glow_band, &glowing);
}
// A fixed pseudo-random value in [0, 1) for each pixel and draw.
static inline float hash_noise(size_t x, size_t y, uint32_t draw) {
uint32_t h = (uint32_t)x * 0x9E3779B1u ^ (uint32_t)y * 0x85EBCA77u ^ draw * 0xC2B2AE3Du;
h ^= h >> 15; h *= 0x2C1B3C6Du; h ^= h >> 12; h *= 0x297A2D39u; h ^= h >> 15;
return (float)(h >> 8) / (float)(1u << 24);
}
typedef struct { const uint16_t *wide; uint8_t *rgba; size_t width, stride; } quantize_job;
static void quantize_band(void *context, size_t first, size_t last) {
const quantize_job *job = context;
const uint16_t *wide = job->wide; uint8_t *rgba = job->rgba; size_t width = job->width, stride = job->stride;
for (size_t y = first; y < last; ++y) {
uint8_t *out = rgba + y * stride;
const uint16_t *in = wide + y * width * 4;
for (size_t x = 0; x < width; ++x) {
const uint16_t *p = in + x * 4;
long alpha = lroundf((float)p[3] * (255.0f / 65535.0f));
for (uint32_t c = 0; c < 3; ++c) {
// Two uniform draws added: noise that's strongest at zero and gone past one step.
float value = (float)p[c] * (255.0f / 65535.0f) + hash_noise(x, y, c) + hash_noise(x, y, c + 3) - 1.0f;
long rounded = lroundf(value);
// Premultiplied: the noise mustn't lift a color past its own alpha.
out[x * 4 + c] = (uint8_t)(rounded < 0 ? 0 : rounded > alpha ? alpha : rounded);
}
out[x * 4 + 3] = (uint8_t)alpha;
}
}
}
void dither_quantize16(const uint16_t *wide, uint8_t *rgba, size_t width, size_t height, size_t stride) {
quantize_job quantizing = { wide, rgba, width, stride };
in_bands(height, quantize_band, &quantizing);
}
typedef struct { float *lift; size_t width; long radius; atomic_int failed; } blur_job;
static void blur_band(void *context, size_t firstLine, size_t lastLine) {
blur_job *job = context;
float *lift = job->lift; size_t width = job->width; long radius = job->radius;
float *copy = malloc(width * sizeof(float));
if (!copy) { atomic_store(&job->failed, 1); return; }
for (size_t line = firstLine; line < lastLine; ++line) {
float *row = lift + line * width;
for (int pass = 0; pass < 3; ++pass) {
memcpy(copy, row, width * sizeof(float));
float sum = 0;
for (long x = -radius; x <= radius; ++x) sum += copy[x < 0 ? 0 : x >= (long)width ? width - 1 : (size_t)x];
for (size_t x = 0; x < width; ++x) {
row[x] = sum / (float)(2 * radius + 1);
long out = (long)x - radius, in = (long)x + radius + 1;
sum += copy[in >= (long)width ? width - 1 : (size_t)in] - copy[out < 0 ? 0 : (size_t)out];
}
}
}
free(copy);
}
typedef struct { const float *lift; float *spread; size_t width, lines; } spread_job;
static void spread_band(void *context, size_t firstLine, size_t lastLine) {
const spread_job *job = context;
const float *lift = job->lift; float *spread = job->spread; size_t width = job->width, lines = job->lines;
for (size_t line = firstLine; line < lastLine; ++line) {
const float *above = lift + (line ? line - 1 : 0) * width, *here = lift + line * width;
const float *below = lift + (line + 1 < lines ? line + 1 : line) * width;
for (size_t x = 0; x < width; ++x) spread[line * width + x] = (above[x] + 2 * here[x] + below[x]) / 4;
}
}
typedef struct {
uint8_t *rgba; size_t width, height, stride, lines, spacing, plane; const float *scan, *tone, *lift; const uint8_t *alpha;
int original, rising; float middle, dots, threshold, displace, thickness, blackLevel; const float *screen, *phosphor;
atomic_int failed;
} column_job;
static void column_band(void *context, size_t first, size_t last) {
column_job *job = context;
uint8_t *rgba = job->rgba; size_t width = job->width, height = job->height, stride = job->stride, lines = job->lines;
size_t spacing = job->spacing, plane = job->plane; const float *scan = job->scan, *tone = job->tone, *lift = job->lift;
const uint8_t *alpha = job->alpha; int original = job->original, rising = job->rising;
float middle = job->middle, dots = job->dots, threshold = job->threshold, displace = job->displace;
float thickness = job->thickness, blackLevel = job->blackLevel; const float *screen = job->screen, *phosphor = job->phosphor;
float *cover = malloc(height * sizeof(float) * 4);
if (!cover) { atomic_store(&job->failed, 1); return; }
float *color = cover + height;
for (size_t x = first; x < last; ++x) {
memset(cover, 0, height * sizeof(float));
float along = fmodf((float)x + 0.5f, (float)spacing) - middle;
// The edge of everything drawn so far in this column; lines further back show only beyond it.
float horizon = rising ? INFINITY : -INFINITY;
for (size_t step = 0; step < lines; ++step) {
size_t line = rising ? lines - 1 - step : step;
// Dots: darker than the Dots level, the line breaks into beads, one every line spacing, each lit in the
// tone at its middle; just above it, into dashes that close up into the solid line.
float beading = dots > 0 ? clamp01((dots - tone[line * width + x]) / 0.2f) : 0, across = along * beading;
long centered = lroundf((float)x - across);
size_t at = centered < 0 ? 0 : (size_t)centered >= width ? width - 1 : (size_t)centered;
size_t i = line * width + at;
float t = tone[i];
float lit = threshold > 0 ? clamp01((t - threshold) / 0.04f) : 1;
if (lit <= 0) continue;
// Drawn between this column's height and the last's, so a steep climb never breaks the line.
float base = (float)line * (float)spacing + middle;
float here = base - displace * lift[i], before = at > 0 ? base - displace * lift[i - 1] : here;
float lo = fminf(here, before), hi = fmaxf(here, before);
// Black Level: the line's least brightness, so it still shows where the picture is black.
float level = blackLevel + (1 - blackLevel) * t, c[3];
for (int k = 0; k < 3; ++k)
c[k] = original ? blackLevel + (1 - blackLevel) * scan[(size_t)k * plane + i] : screen[k] + (phosphor[k] - screen[k]) * level;
// Half the line's height: thinner where the picture is dim.
float beam = middle * thickness * (0.29f + 0.71f * sqrtf(clamp01(level)));
long top = (long)floorf(rising ? lo - beam - 1 : fmaxf(lo - beam - 1, horizon - 1));
long bottom = (long)ceilf(rising ? fminf(hi + beam + 1, horizon + 1) : hi + beam + 1);
if (top < 0) top = 0;
if (bottom > (long)height) bottom = (long)height;
for (long y = top; y < bottom; ++y) {
float yy = (float)y + 0.5f;
float off = yy < lo ? lo - yy : yy > hi ? yy - hi : 0;
float distance = sqrtf(off * off + across * across);
float hidden = rising ? clamp01(horizon - yy + 0.5f) : clamp01(yy - horizon + 0.5f);
float shown = clamp01(beam - distance + 0.5f) * lit * hidden;
if (shown > cover[y]) {
cover[y] = shown;
color[y * 3] = c[0]; color[y * 3 + 1] = c[1]; color[y * 3 + 2] = c[2];
}
}
if (lit > 0.5f) horizon = rising ? fminf(horizon, lo - beam) : fmaxf(horizon, hi + beam);
}
for (size_t y = 0; y < height; ++y) {
if (!alpha[y * width + x]) continue;
// The beam is driven brighter than the picture, making up for the dark screen between lines.
float shown = cover[y];
write_pixel(rgba + y * stride + x * 4, screen[0] + (color[y * 3] * 1.35f - screen[0]) * shown,
screen[1] + (color[y * 3 + 1] * 1.35f - screen[1]) * shown,
screen[2] + (color[y * 3 + 2] * 1.35f - screen[2]) * shown);
}
}
free(cover);
}
// Draws the lines over the scanned tones, a column at a time. Displaced, they're a landscape seen from the front, after
// the Rutt-Etra video synthesizer: each line is lifted (or, displaced the other way, lowered) by the picture's
// brightness smoothed into hills, and hides whatever lies behind it, so nearer lines wrap over the shapes rather than
// crossing the ones beyond. Nearest line first: the bottom one when lines rise, the top one when they fall.
static int draw_lines(uint8_t *rgba, size_t width, size_t height, size_t stride, const ScanlinesParams *p,
const float *scan, const uint8_t *alpha, size_t lines, size_t spacing) {
int original = p->originalColors;
size_t plane = lines * width;
float *tone = malloc(plane * sizeof(float)), *lift = malloc(plane * sizeof(float)), *spread = malloc(plane * sizeof(float));
if (!tone || !lift || !spread) { free(tone); free(lift); free(spread); return 0; }
for (size_t at = 0; at < plane; ++at)
tone[at] = original ? 0.2126f * scan[at] + 0.7152f * scan[plane + at] + 0.0722f * scan[2 * plane + at] : scan[at];
// The height: brightness blurred along each line (three box passes, close to a Gaussian) and then across its
// neighbors, so a face rises as one rounded hill instead of a staircase of the pixels under it.
float smoothness = clamp01(p->smoothness);
long radius = lroundf(smoothness * (float)spacing * 2);
memcpy(lift, tone, plane * sizeof(float));
if (radius > 0 && p->displace != 0) {
blur_job blurring = { lift, width, radius, 0 };
in_bands(lines, blur_band, &blurring);
int failed = atomic_load(&blurring.failed);
if (failed) { free(tone); free(lift); free(spread); return 0; }
long across = lroundf(smoothness * 2);
for (long step = 0; step < across; ++step) {
spread_job spreading = { lift, spread, width, lines };
in_bands(lines, spread_band, &spreading);
memcpy(lift, spread, plane * sizeof(float));
}
}
free(spread);
float middle = (float)spacing / 2, dots = clamp01(p->dots), threshold = clamp01(p->threshold), displace = p->displace;
float thickness = clamp01(p->thickness), blackLevel = clamp01(p->blackLevel);
int rising = displace >= 0;
float dark[3] = { p->dark[0] / 255.0f, p->dark[1] / 255.0f, p->dark[2] / 255.0f };
float light[3] = { p->light[0] / 255.0f, p->light[1] / 255.0f, p->light[2] / 255.0f };
if (original) dark[0] = dark[1] = dark[2] = 0;
const float *screen = dark, *phosphor = light;
column_job drawing = { rgba, width, height, stride, lines, spacing, plane, scan, tone, lift, alpha, original, rising, middle,
dots, threshold, displace, thickness, blackLevel, screen, phosphor, 0 };
in_bands(width, column_band, &drawing);
int failed = atomic_load(&drawing.failed);
free(tone); free(lift);
return !failed;
}
typedef struct {
const uint8_t *rgba; size_t stride, width, count; uint8_t *alpha; float *tone; float gamma, contrast; int original;
} scanlines_tone_job;
static void scanlines_tone_band(void *context, size_t first, size_t last) {
const scanlines_tone_job *job = context;
const uint8_t *rgba = job->rgba; size_t stride = job->stride, width = job->width, count = job->count;
uint8_t *alpha = job->alpha; float *tone = job->tone; float gamma = job->gamma, contrast = job->contrast;
int original = job->original;
for (size_t y = first; y < last; ++y) {
const uint8_t *row = rgba + y * stride;
for (size_t x = 0; x < width; ++x) {
const uint8_t *px = row + x * 4;
size_t at = y * width + x;
alpha[at] = px[3];
float r = 0, g = 0, b = 0;
if (px[3]) { float scale = 1.0f / (float)px[3]; r = px[0] * scale; g = px[1] * scale; b = px[2] * scale; }
if (original) {
tone[at] = adjust_tone(r, gamma, contrast);
tone[count + at] = adjust_tone(g, gamma, contrast);
tone[2 * count + at] = adjust_tone(b, gamma, contrast);
} else {
tone[at] = adjust_tone(0.2126f * r + 0.7152f * g + 0.0722f * b, gamma, contrast);
}
}
}
}
typedef struct {
size_t width, height, count, spacing, lines; int planes; const uint8_t *alpha; const float *tone; float *scan; float wobble;
} line_scan_job;
static void line_scan_band(void *context, size_t firstLine, size_t lastLine) {
const line_scan_job *job = context;
size_t width = job->width, height = job->height, count = job->count, spacing = job->spacing, lines = job->lines;
int planes = job->planes; const uint8_t *alpha = job->alpha; const float *tone = job->tone; float *scan = job->scan;
float wobble = job->wobble;
for (size_t line = firstLine; line < lastLine; ++line) {
size_t top = line * spacing, bottom = top + spacing < height ? top + spacing : height;
// A slow wave down the screen with a quicker one over it, as a CRT's picture wavers when its sync drifts.
float wave = sinf((float)line * 0.45f) * 0.7f + sinf((float)line * 1.7f + 1.3f) * 0.3f;
long shift = lroundf(wobble * wave);
for (size_t x = 0; x < width; ++x) {
float sum[3] = { 0, 0, 0 }; int n = 0;
long sx = (long)x - shift;
if (sx >= 0 && sx < (long)width)
for (size_t y = top; y < bottom; ++y) {
size_t at = y * width + (size_t)sx;
if (!alpha[at]) continue;
for (int c = 0; c < planes; ++c) sum[c] += tone[(size_t)c * count + at];
++n;
}
for (int c = 0; c < planes; ++c) scan[((size_t)c * lines + line) * width + x] = n ? sum[c] / (float)n : 0;
}
}
}
typedef struct { uint8_t *rgba; size_t stride, width; long split; atomic_int failed; } split_job;
static void split_band(void *context, size_t first, size_t last) {
split_job *job = context;
uint8_t *rgba = job->rgba; size_t stride = job->stride, width = job->width; long split = job->split;
uint8_t *copy = malloc(width * 4);
if (!copy) { atomic_store(&job->failed, 1); return; }
for (size_t y = first; y < last; ++y) {
uint8_t *row = rgba + y * stride;
memcpy(copy, row, width * 4);
for (size_t x = 0; x < width; ++x) {
long from = (long)x - split, to = (long)x + split;
uint8_t a = row[x * 4 + 3];
uint8_t r = from >= 0 ? copy[from * 4] : 0, b = to < (long)width ? copy[to * 4 + 2] : 0;
row[x * 4] = r > a ? a : r;
row[x * 4 + 2] = b > a ? a : b;
}
}
free(copy);
}
int scanlines_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const ScanlinesParams *p) {
size_t count = width * height;
if (!count) return 1;
size_t spacing = (size_t)(p->spacing < 2 ? 2 : p->spacing);
size_t lines = (height + spacing - 1) / spacing;
int planes = p->originalColors ? 3 : 1;
float *tone = malloc(count * sizeof(float) * (size_t)planes);
uint8_t *alpha = malloc(count);
// Each line's tone along it: the average of the rows it covers, sampled where its wobble moves it from.
float *scan = malloc(lines * width * sizeof(float) * (size_t)planes);
if (!tone || !alpha || !scan) { free(tone); free(alpha); free(scan); return 0; }
float gamma = exp2f(p->density * 1.5f);
float contrast = p->contrast >= 0 ? 1.0f / (1.0f - 0.95f * p->contrast) : 1.0f + p->contrast;
int original = p->originalColors;
scanlines_tone_job toning = { rgba, stride, width, count, alpha, tone, gamma, contrast, original };
in_bands(height, scanlines_tone_band, &toning);
float wobble = p->wobble;
line_scan_job scanning = { width, height, count, spacing, lines, planes, alpha, tone, scan, wobble };
in_bands(lines, line_scan_band, &scanning);
int drawn = draw_lines(rgba, width, height, stride, p, scan, alpha, lines, spacing);
free(tone); free(alpha); free(scan);
if (!drawn) return 0;
// Color split: red moved one way and blue the other, for colored fringes on the lines' edges.
long split = lroundf(p->split);
if (split > 0) {
split_job splitting = { rgba, stride, width, split, 0 };
in_bands(height, split_band, &splitting);
int failed = atomic_load(&splitting.failed);
if (failed) return 0;
}
return 1;
}
+88
View File
@@ -0,0 +1,88 @@
#ifndef DitherPixels_h
#define DitherPixels_h
#include <stdint.h>
#include <stddef.h>
// The dither styles, in the order the Filter panel lists them.
enum {
DITHER_ATKINSON, DITHER_FLOYD_STEINBERG,
DITHER_BAYER_2, DITHER_BAYER_4, DITHER_BAYER_8,
DITHER_DOTS, DITHER_LINES, DITHER_DIAMONDS,
DITHER_PATTERNS, DITHER_GLYPHS
};
typedef struct {
int style;
// Tones per channel for diffusion and ordered styles, 2–8. Two is pure 1-bit.
int levels;
// How much of each pixel's error diffusion passes on, 0–1.
float diffusion;
// −1…1: darker (more ink) or lighter, and flatter or punchier, before dithering.
float density;
float contrast;
// Halftone and glyph cells, in pixels, and the halftone screen's angle in radians.
int cell;
float angle;
// Halftone dots, patterns and glyphs mark the light tones on the dark color instead of the dark on the light.
int lightOnDark;
// 0: the result is made of `dark` and `light` (straight sRGB). 1: it keeps the image's own colors.
int originalColors;
uint8_t dark[3];
uint8_t light[3];
// Glyphs: `glyphCount` coverage maps of `glyphWidth` × `glyphHeight` bytes (255 is fully inked), from least
// inked to most, with each map's mean coverage (0–1) in `glyphCoverage`. The image is laid out in cells that size,
// like lines of monospaced text.
int glyphWidth;
int glyphHeight;
const uint8_t *glyphs;
const float *glyphCoverage;
int glyphCount;
} DitherParams;
// Filter › Scanlines: the picture as a CRT draws it, in lines of light on a dark screen.
typedef struct {
// Pixels from one line to the next.
int spacing;
// How much of the gap a full-bright line fills, 0–1; dimmer parts draw it thinner.
float thickness;
// 0–1: tones darker than this break the lines into round dots, blending through dashes into the solid line above it.
float dots;
// Pixels a line wavers sideways, in a wave down the screen.
float wobble;
// Pixels a line rises where the picture under it is bright (falls, if negative), so lines swell into its shapes.
float displace;
// 0–1: tones darker than this draw no line at all.
float threshold;
// Pixels the red and blue are moved apart, either way, for colored fringes.
float split;
// −1…1: darker or lighter, and flatter or punchier, before the lines are drawn.
float density;
float contrast;
// 0–1: how bright the lines are where the picture is black.
float blackLevel;
// 0–1: how far the brightness is smoothed, over a few line spacings, before it displaces the lines.
float smoothness;
// 0: lines of `light` on `dark` (straight sRGB), brighter where the picture is. 1: lines in the picture's colors.
int originalColors;
uint8_t dark[3];
uint8_t light[3];
} ScanlinesParams;
// Draws premultiplied RGBA pixels (4 bytes per pixel, `stride` bytes per row) as scanlines, in place. Alpha is kept and
// fully transparent pixels are left alone. Returns 0 if working memory couldn't be had.
int scanlines_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const ScanlinesParams *params);
// Dithers premultiplied RGBA pixels (4 bytes per pixel, `stride` bytes per row) in place. Alpha is kept and fully
// transparent pixels are left alone. Returns 0 if working memory couldn't be had.
int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const DitherParams *params);
// Turns each `block` × `block` square of premultiplied RGBA pixels into a round dot in its own color on `gap` (straight
// sRGB), like the lit pixels of a dot-matrix screen. The dot's edge is smoothed and alpha is kept.
void dither_dots(uint8_t *rgba, size_t width, size_t height, size_t stride, int block, const uint8_t *gap);
// Adds `glow` (premultiplied RGBA, same layout) over the pixels at `amount`, easing off as they near full brightness
// and never past their own alpha.
void dither_glow(uint8_t *rgba, const uint8_t *glow, size_t width, size_t height, size_t stride, float amount);
// Rounds 16-bit premultiplied RGBA (`width` × `height`, tightly packed) to 8 bits into `rgba` (`stride` bytes per
// row), adding noise of about one 8-bit step first so smooth gradients don't come out in steps. The noise is fixed per
// pixel, so the same input always gives the same output.
void dither_quantize16(const uint16_t *wide, uint8_t *rgba, size_t width, size_t height, size_t stride);
#endif
+32
View File
@@ -26,3 +26,35 @@ void levels_histogram(const uint8_t *pixels, const uint8_t *coverage, size_t cou
}
}
}
/// A color lookup through `cube` (`dimension`³ RGBA entries, red varying fastest), blended between the eight nearest
/// entries, on unpremultiplied colors; alpha is kept.
void cube_apply(uint8_t *pixels, size_t count, const float *cube, int dimension) {
const float scale = (dimension - 1) / 255.0f;
const size_t dy = dimension, dz = (size_t)dimension * dimension;
for (size_t i=0; i<count; ++i) {
uint8_t *p = pixels + i*4;
float alpha = p[3];
if (!alpha) continue;
float position[3], fraction[3];
int lo[3];
for (int channel=0; channel<3; ++channel) {
position[channel] = fminf(255, p[channel]*255.0f/alpha) * scale;
lo[channel] = (int)position[channel];
if (lo[channel] > dimension - 2) lo[channel] = dimension - 2;
fraction[channel] = position[channel] - lo[channel];
}
const float *base = cube + ((size_t)lo[0] + lo[1]*dy + lo[2]*dz) * 4;
const size_t sx = 4, sy = dy*4, sz = dz*4;
for (int channel=0; channel<3; ++channel) {
const float *c = base + channel;
float x00 = c[0] + (c[sx] - c[0]) * fraction[0];
float x10 = c[sy] + (c[sy+sx] - c[sy]) * fraction[0];
float x01 = c[sz] + (c[sz+sx] - c[sz]) * fraction[0];
float x11 = c[sz+sy] + (c[sz+sy+sx] - c[sz+sy]) * fraction[0];
float y0 = x00 + (x10 - x00) * fraction[1];
float y1 = x01 + (x11 - x01) * fraction[1];
float result = y0 + (y1 - y0) * fraction[2];
p[channel] = (uint8_t)fminf(alpha, fmaxf(0, roundf(result*alpha)));
}
}
}
+1
View File
@@ -2,3 +2,4 @@
#include <stddef.h>
void levels_apply(uint8_t *pixels, size_t count, const float *tables);
void levels_histogram(const uint8_t *pixels, const uint8_t *coverage, size_t count, double *bins);
void cube_apply(uint8_t *pixels, size_t count, const float *cube, int dimension);
+31
View File
@@ -172,3 +172,34 @@ fail:
free(lens);
return -1;
}
static inline int color_near(const int *rgb, const uint8_t *colors, int count, int fuzziness) {
for (int i = 0; i < count; ++i) {
const uint8_t *c = colors + i * 3;
if (abs(rgb[0] - c[0]) <= fuzziness && abs(rgb[1] - c[1]) <= fuzziness && abs(rgb[2] - c[2]) <= fuzziness) return 1;
}
return 0;
}
long color_range_mask(const uint8_t *rgba, size_t width, size_t height, size_t stride,
const uint8_t *include, int includeCount, const uint8_t *exclude, int excludeCount,
int fuzziness, int invert, uint8_t *mask) {
long count = 0;
for (size_t y = 0; y < height; ++y) {
const uint8_t *row = rgba + y * stride;
uint8_t *out = mask + y * width;
for (size_t x = 0; x < width; ++x) {
const uint8_t *px = row + x * 4;
int matches = 0;
if (px[3]) {
int rgb[3];
for (int c = 0; c < 3; ++c) rgb[c] = (px[c] * 255 + px[3] / 2) / px[3];
matches = color_near(rgb, include, includeCount, fuzziness) && !color_near(rgb, exclude, excludeCount, fuzziness);
}
if (invert) matches = !matches;
out[x] = matches ? 255 : 0;
count += matches;
}
}
return count;
}
+7
View File
@@ -10,6 +10,13 @@
// `mask` (width * height bytes). Returns the number selected, or -1 when memory runs out.
long wand_mask(const uint8_t *rgba, size_t width, size_t height, size_t stride,
size_t seedX, size_t seedY, size_t radius, int tolerance, int contiguous, uint8_t *mask);
// Select > Color Range over premultiplied RGBA (as above). A pixel matches when every color channel is within
// `fuzziness` of one of the `includeCount` colors in `include` and of none in `exclude` (straight sRGB, 3 bytes
// each). Transparent pixels never match. With `invert`, the pixels that don't match are selected instead. Writes
// 255 for selected, 0 elsewhere, into `mask`, and returns the number selected.
long color_range_mask(const uint8_t *rgba, size_t width, size_t height, size_t stride,
const uint8_t *include, int includeCount, const uint8_t *exclude, int excludeCount,
int fuzziness, int invert, uint8_t *mask);
// Outline of the nonzero pixels of `mask`, along pixel edges, as closed loops of corner points
// (x, y pairs in pixel-edge coordinates). Outer boundaries run clockwise and holes
// counterclockwise in top-left coordinates, so the winding rule fills exactly those pixels.
+3
View File
@@ -0,0 +1,3 @@
# The AppImage's model, MIME data and HEVC decoder come first.
export XDG_DATA_DIRS="$APPDIR/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
export LIBHEIF_PLUGIN_PATH="$APPDIR/usr/lib/libheif"
+3 -3
View File
@@ -1,12 +1,12 @@
# Built from this checkout: run makepkg in packaging/arch.
pkgname=omaphoto
pkgver=1.2.3
pkgver=1.4.9
pkgrel=1
pkgdesc='Layered image editing for Linux, a port of Compositor'
arch=('x86_64')
url='https://github.com/ZacharyZhang-NY/OmaPhoto'
url='https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto'
license=('MIT')
depends=('qt6-base' 'qt6-imageformats' 'libheif' 'libraw' 'libde265' 'fontconfig' 'hicolor-icon-theme' 'shared-mime-info')
depends=('qt6-base' 'qt6-svg' 'qt6-imageformats' 'libheif' 'libraw' 'libde265' 'fontconfig' 'xz' 'hicolor-icon-theme' 'shared-mime-info')
makedepends=('cmake' 'ninja')
# ONNX Runtime ships inside: Arch's changes its symbols each release.
source=('u2net.onnx::https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx'
@@ -7,5 +7,5 @@ Exec=omaphoto %F
Icon=io.github.ZacharyZhang_NY.OmaPhoto
Terminal=false
Categories=Graphics;2DGraphics;RasterGraphics;
MimeType=application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;image/x-dcraw;
MimeType=application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/webp;image/vnd.adobe.photoshop;image/x-photoshop-large-image;image/svg+xml;image/svg+xml-compressed;image/x-dcraw;
StartupWMClass=OmaPhoto
@@ -5,4 +5,11 @@
<sub-class-of type="inode/directory"/>
<glob pattern="*.comp"/>
</mime-type>
<mime-type type="image/x-photoshop-large-image">
<comment>Photoshop Large Document</comment>
<glob pattern="*.psb"/>
<magic priority="50">
<match type="string" value="8BPS\x00\x02" offset="0"/>
</magic>
</mime-type>
</mime-info>
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Builds omaphoto-x86_64.AppImage inside the AppImage image.
set -euo pipefail
id=io.github.ZacharyZhang_NY.OmaPhoto
cmake -S . -B build-appimage -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr \
-DOMAPHOTO_BUNDLE_ORT=ON -DOMAPHOTO_MODEL=/usr/local/share/omaphoto/u2net.onnx
cmake --build build-appimage --target omaphoto
rm -rf AppDir
DESTDIR="$PWD/AppDir" cmake --install build-appimage
# libheif loads its HEVC decoder as a plugin.
mkdir -p AppDir/usr/lib/libheif AppDir/apprun-hooks
cp /usr/lib/x86_64-linux-gnu/libheif/plugins/libheif-libde265.so AppDir/usr/lib/libheif/
cp packaging/appimage/omaphoto.sh AppDir/apprun-hooks/
# Qt's Wayland client needs its shell and decoration plugins.
qtplugins=/usr/lib/x86_64-linux-gnu/qt6/plugins
mkdir -p AppDir/usr/plugins
for dir in wayland-shell-integration wayland-decoration-client wayland-graphics-integration-client; do
cp -r "$qtplugins/$dir" AppDir/usr/plugins/
done
export QMAKE=/usr/lib/qt6/bin/qmake6 APPIMAGE_EXTRACT_AND_RUN=1 OUTPUT=omaphoto-x86_64.AppImage
export EXTRA_PLATFORM_PLUGINS="libqwayland-egl.so;libqwayland-generic.so" EXTRA_QT_MODULES=svg
linuxdeploy --appdir AppDir --plugin qt --output appimage \
--desktop-file "AppDir/usr/share/applications/$id.desktop" \
--icon-file "AppDir/usr/share/icons/hicolor/512x512/apps/$id.png" \
--deploy-deps-only AppDir/usr/lib/libheif \
--deploy-deps-only AppDir/usr/plugins/wayland-shell-integration \
--deploy-deps-only AppDir/usr/plugins/wayland-decoration-client \
--deploy-deps-only AppDir/usr/plugins/wayland-graphics-integration-client
+3 -1
View File
@@ -1,12 +1,14 @@
#!/usr/bin/env bash
# Builds and tests the app in one distribution's own container.
set -euo pipefail
distro=${1:?"usage: distro-check.sh arch|fedora|nixos|resolute"}
distro=${1:?"usage: distro-check.sh arch|fedora|fedora44|nixos|resolute"}
here=$(cd "$(dirname "$0")" && pwd)
repo=$(cd "$here/.." && pwd)
# Ubuntu 26.04 is the dev image on another base.
if [ "$distro" = resolute ]; then
docker build -t omaphoto-resolute --build-arg BASE=ubuntu:26.04 -f "$repo/Dockerfile.dev" "$here"
elif [ "$distro" = fedora44 ]; then
docker build -t omaphoto-fedora44 --build-arg RELEASE=44 "$here/distro/fedora"
else
docker build -t "omaphoto-$distro" "$here/distro/$distro"
fi
+10
View File
@@ -0,0 +1,10 @@
# The dev image plus linuxdeploy, pinned by checksum.
ARG DEV=omaphoto-dev
FROM $DEV
RUN curl -fsSL -o /usr/local/bin/linuxdeploy \
https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20251107-1/linuxdeploy-x86_64.AppImage \
&& curl -fsSL -o /usr/local/bin/linuxdeploy-plugin-qt \
https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/1-alpha-20250213-1/linuxdeploy-plugin-qt-x86_64.AppImage \
&& echo "c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d /usr/local/bin/linuxdeploy" | sha256sum -c - \
&& echo "15106be885c1c48a021198e7e1e9a48ce9d02a86dd0a1848f00bdbf3c1c92724 /usr/local/bin/linuxdeploy-plugin-qt" | sha256sum -c - \
&& chmod +x /usr/local/bin/linuxdeploy /usr/local/bin/linuxdeploy-plugin-qt
+1 -1
View File
@@ -1,6 +1,6 @@
# Arch and Omarchy: Qt and GCC as shipped.
FROM archlinux:latest
RUN pacman -Syu --noconfirm --needed base-devel cmake ninja pkgconf qt6-base qt6-imageformats libheif libde265 libraw \
RUN pacman -Syu --noconfirm --needed base-devel cmake ninja pkgconf qt6-base qt6-svg qt6-imageformats libheif libde265 libraw xz \
fontconfig ttf-dejavu curl \
&& pacman -Scc --noconfirm
# The ONNX Runtime the packages carry: Microsoft's own build.
+6 -4
View File
@@ -1,7 +1,9 @@
# Fedora 43, the oldest still supported; HEVC from RPM Fusion.
FROM fedora:43
RUN dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-43.noarch.rpm \
&& dnf install -y gcc-c++ cmake ninja-build pkgconf qt6-qtbase-devel qt6-qtimageformats libheif-devel LibRaw-devel libheif-freeworld \
# A supported Fedora, 43 unless named; HEVC from RPM Fusion.
ARG RELEASE=43
FROM fedora:${RELEASE}
ARG RELEASE
RUN dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-${RELEASE}.noarch.rpm \
&& dnf install -y gcc-c++ cmake ninja-build pkgconf qt6-qtbase-devel qt6-qtsvg-devel qt6-qtimageformats libheif-devel LibRaw-devel xz-devel libheif-freeworld \
fontconfig-devel dejavu-sans-fonts dejavu-serif-fonts dejavu-sans-mono-fonts curl rpm-build file \
&& dnf clean all
# The ONNX Runtime the packages carry: Microsoft's own build.
+9 -2
View File
@@ -1,7 +1,14 @@
#!/usr/bin/env bash
# Installs OmaPhoto's latest package on Arch and Omarchy.
# Installs OmaPhoto's newest package on Arch and Omarchy.
set -euo pipefail
base=https://github.com/ZacharyZhang-NY/OmaPhoto/releases/latest/download
repo=https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto
# The newest release's tag, from Gitea's API.
tag=$(curl -fsSL --proto '=https' https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/OmaPhoto/releases/latest | grep -o '"tag_name":"v[^"]*"' | cut -d'"' -f4) || tag=
if [ -z "$tag" ]; then
echo "No release found at $repo/releases." >&2
exit 1
fi
base=$repo/releases/download/$tag
package=omaphoto-x86_64.pkg.tar.zst
. /etc/os-release
case " ${ID:-} ${ID_LIKE:-} " in
+17 -5
View File
@@ -1,16 +1,28 @@
#!/usr/bin/env bash
# Installs OmaPhoto's latest RPM on Fedora.
# Installs OmaPhoto's newest RPM on Fedora 43 or 44.
set -euo pipefail
base=https://github.com/ZacharyZhang-NY/OmaPhoto/releases/latest/download
package=omaphoto.x86_64.rpm
repo=https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto
# The newest release's tag, from Gitea's API.
tag=$(curl -fsSL --proto '=https' https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/OmaPhoto/releases/latest | grep -o '"tag_name":"v[^"]*"' | cut -d'"' -f4) || tag=
if [ -z "$tag" ]; then
echo "No release found at $repo/releases." >&2
exit 1
fi
base=$repo/releases/download/$tag
. /etc/os-release
release=
case " ${ID:-} ${ID_LIKE:-} " in
*" fedora "*) ;;
*" fedora "*) release=${VERSION_ID:-} ;;
esac
# Each RPM names its release's LibRaw.
case "$release" in
43 | 44) ;;
*)
echo "This script is for Fedora; this system is ${PRETTY_NAME:-unknown}." >&2
echo "This script is for Fedora 43 or 44; this system is ${PRETTY_NAME:-unknown}." >&2
exit 1
;;
esac
package=omaphoto-fc${release}.x86_64.rpm
if [ "$(uname -m)" != x86_64 ]; then
echo "OmaPhoto's packages are built for x86_64 alone." >&2
exit 1
+7 -10
View File
@@ -9,14 +9,11 @@ if [ "$(uname -m)" != x86_64 ]; then
echo "OmaPhoto's flake builds for x86_64 alone." >&2
exit 1
fi
# The newest release's tag, as GitHub redirects to it.
latest=$(curl -fsSLI --proto '=https' -o /dev/null -w '%{url_effective}' https://github.com/ZacharyZhang-NY/OmaPhoto/releases/latest)
tag=${latest##*/}
case "$tag" in
v[0-9]*) ;;
*)
echo "No release found at $latest." >&2
repo=https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto
# The newest release's tag, from Gitea's API.
tag=$(curl -fsSL --proto '=https' https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/OmaPhoto/releases/latest | grep -o '"tag_name":"v[^"]*"' | cut -d'"' -f4) || tag=
if [ -z "$tag" ]; then
echo "No release found at $repo/releases." >&2
exit 1
;;
esac
nix --extra-experimental-features "nix-command flakes" profile install "github:ZacharyZhang-NY/OmaPhoto/$tag"
fi
nix --extra-experimental-features "nix-command flakes" profile install "git+$repo?ref=refs/tags/$tag"
+9 -2
View File
@@ -1,7 +1,14 @@
#!/usr/bin/env bash
# Installs OmaPhoto's latest DEB on Ubuntu LTS and derivatives.
# Installs OmaPhoto's newest DEB on Ubuntu LTS and derivatives.
set -euo pipefail
base=https://github.com/ZacharyZhang-NY/OmaPhoto/releases/latest/download
repo=https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto
# The newest release's tag, from Gitea's API.
tag=$(curl -fsSL --proto '=https' https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/OmaPhoto/releases/latest | grep -o '"tag_name":"v[^"]*"' | cut -d'"' -f4) || tag=
if [ -z "$tag" ]; then
echo "No release found at $repo/releases." >&2
exit 1
fi
base=$repo/releases/download/$tag
. /etc/os-release
# Each DEB names its LTS's libraries; derivatives name their base.
case "${UBUNTU_CODENAME:-}" in
+4 -2
View File
@@ -23,13 +23,15 @@ if not os.path.isfile(os.path.join(repo, 'CMakeLists.txt')):
sys.exit('run mutate.py from the repository root')
original = open(args.path).read()
targets = [t for t in args.targets.split(',') if t]
# Cores for docker and the build, shared by parallel workers.
cpus = os.environ.get('OMAPHOTO_CPUS', '32')
def docker(command):
uid, gid = os.getuid(), os.getgid()
runtime = '/run/user/%d' % uid
done = subprocess.run(
['docker', 'run', '--rm', '--init', '-u', '%d:%d' % (uid, gid), '-e', 'HOME=/tmp', '-e', 'QT_QPA_PLATFORM=offscreen',
['docker', 'run', '--rm', '--init', '--cpus=' + cpus, '-u', '%d:%d' % (uid, gid), '-e', 'HOME=/tmp', '-e', 'QT_QPA_PLATFORM=offscreen',
'--tmpfs', '%s:uid=%d,gid=%d,mode=0700' % (runtime, uid, gid), '-e', 'XDG_RUNTIME_DIR=' + runtime,
'-v', '%s:%s' % (repo, repo), '-w', repo, 'omaphoto-dev', 'timeout', '600', 'sh', '-c', command],
capture_output=True, text=True)
@@ -38,7 +40,7 @@ def docker(command):
def build():
wanted = ' --target ' + ' '.join(targets) if targets else ''
return docker('cmake -S . -B build -G Ninja -DOMAPHOTO_WERROR=ON >/dev/null && cmake --build build' + wanted)
return docker('cmake -S . -B build -G Ninja -DOMAPHOTO_WERROR=ON >/dev/null && cmake --build build -j' + cpus + wanted)
def test():
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Builds a tag's packages and publishes a Gitea release.
set -euo pipefail
tag=${1:?"usage: GITEA_TOKEN=... scripts/release.sh vX.Y.Z"}
token=${GITEA_TOKEN:?"GITEA_TOKEN must hold a Gitea access token"}
api=https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/OmaPhoto
repo=$(cd "$(dirname "$0")/.." && pwd)
cd "$repo"
version=${tag#v}
# The tag must name the version each build file carries.
if ! git show "$tag:CMakeLists.txt" | grep -qF "project(omaphoto VERSION $version "; then
echo "CMakeLists.txt at $tag is not $version" >&2
exit 1
fi
if ! git show "$tag:packaging/arch/PKGBUILD" | grep -qxF "pkgver=$version"; then
echo "The PKGBUILD at $tag is not $version" >&2
exit 1
fi
if ! git show "$tag:flake.nix" | grep -qF "version = \"$version\";"; then
echo "flake.nix at $tag is not $version" >&2
exit 1
fi
out=$repo/release/$tag
rm -rf "$out"
mkdir -p "$out"
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
user="$(id -u):$(id -g)"
configure="cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr -DOMAPHOTO_BUNDLE_ORT=ON -DOMAPHOTO_MODEL=/usr/local/share/omaphoto/u2net.onnx && cmake --build build --target omaphoto"
# Each package builds from a fresh copy of the tag.
fresh() {
rm -rf "$work/src"
mkdir "$work/src"
git archive "$tag" | tar -x -C "$work/src"
}
build() {
docker run --rm -u "$user" -e HOME=/tmp -v "$work/src:/src" -w /src "$1" sh -c "$2"
}
# One DEB per Ubuntu LTS: each names its own libraries.
docker build -t omaphoto-dev --build-arg BASE=ubuntu:24.04 -f Dockerfile.dev .
docker build -t omaphoto-resolute --build-arg BASE=ubuntu:26.04 -f Dockerfile.dev .
for pair in noble:omaphoto-dev resolute:omaphoto-resolute; do
fresh
build "${pair#*:}" "$configure && cd build && cpack -G DEB"
mv "$work"/src/build/omaphoto_*.deb "$out/omaphoto-${pair%%:*}_amd64.deb"
done
# One RPM per Fedora release: each names its own LibRaw.
docker build -t omaphoto-fedora --build-arg RELEASE=43 scripts/distro/fedora
docker build -t omaphoto-fedora44 --build-arg RELEASE=44 scripts/distro/fedora
for pair in 43:omaphoto-fedora 44:omaphoto-fedora44; do
fresh
build "${pair#*:}" "$configure && cd build && cpack -G RPM"
mv "$work"/src/build/omaphoto-*.x86_64.rpm "$out/omaphoto-fc${pair%%:*}.x86_64.rpm"
done
# makepkg refuses root; a user of our id builds.
docker build --pull -t omaphoto-arch scripts/distro/arch
fresh
docker run --rm -v "$work/src:/src" omaphoto-arch sh -c "useradd -u $(id -u) builder && chown -R builder /src && su builder -c 'cd /src/packaging/arch && makepkg -f'"
mv "$work"/src/packaging/arch/omaphoto-[0-9]*-x86_64.pkg.tar.zst "$out/omaphoto-x86_64.pkg.tar.zst"
# The AppImage builds on Ubuntu 24.04, the oldest glibc here.
docker build -t omaphoto-appimage --build-arg DEV=omaphoto-dev scripts/distro/appimage
fresh
build omaphoto-appimage scripts/appimage.sh
mv "$work/src/omaphoto-x86_64.AppImage" "$out/"
names="omaphoto-noble_amd64.deb omaphoto-resolute_amd64.deb omaphoto-fc43.x86_64.rpm omaphoto-fc44.x86_64.rpm omaphoto-x86_64.pkg.tar.zst omaphoto-x86_64.AppImage"
(cd "$out" && sha256sum $names > SHA256SUMS)
# The tag must already be on Gitea.
notes="Install with the script for your system, or run the AppImage: see the README's Install section."
body=$(printf '{"tag_name":"%s","name":"OmaPhoto %s","body":"%s"}' "$tag" "$version" "$notes")
release=$(curl -fsS -X POST -H "Authorization: token $token" -H "Content-Type: application/json" -d "$body" "$api/releases")
id=$(echo "$release" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
for name in $names SHA256SUMS; do
echo "Uploading $name"
curl -fsS -o /dev/null -X POST -H "Authorization: token $token" -F "attachment=@$out/$name" "$api/releases/$id/assets?name=$name"
done
echo "Published https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto/releases/tag/$tag"
+19 -4
View File
@@ -1,4 +1,5 @@
#include "ContentView.h"
#include "UI/ColorRangeSheet.h"
#include "UI/LassoControls.h"
#include "UI/EffectsSheet.h"
#include "UI/FilterSheet.h"
@@ -41,8 +42,20 @@ void ContentView::synchronizePanels()
m_selectionAmountPanel.close();
}
}
if (m_filterShown != m_session.filterEdit().has_value()) {
m_filterShown = m_session.filterEdit().has_value();
// Swift's onChange of colorRange == nil.
if (m_colorRangeShown != m_session.colorRange().has_value()) {
m_colorRangeShown = m_session.colorRange().has_value();
if (m_colorRangeShown) {
m_colorRangePanel.onClose = [this] { m_session.cancelColorRange(); };
m_colorRangePanel.show(QStringLiteral("Color Range"), new ColorRangeSheet(m_session));
} else {
m_colorRangePanel.close();
}
}
// Last Filter applies without the panel.
const bool filtering = m_session.filterEdit() && !m_session.filterEdit()->repeating;
if (m_filterShown != filtering) {
m_filterShown = filtering;
if (m_filterShown) {
m_filterPanel.onClose = [this] { m_session.cancelFilter(); };
const FilterKind kind = m_session.filterEdit()->kind;
@@ -87,7 +100,7 @@ void ContentView::showImporter()
m_importer = new QFileDialog(this);
m_importer->setAttribute(Qt::WA_DeleteOnClose);
m_importer->setFileMode(QFileDialog::ExistingFiles);
m_importer->setNameFilter(QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd %1)").arg(RawImporter::globs().join(' ')));
m_importer->setNameFilter(QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.webp *.psd *.psb *.svg *.svgz %1)").arg(RawImporter::globs().join(' ')));
connect(m_importer, &QDialog::finished, this, [this, panel = m_importer.data()](int result) {
// Ours no longer: the flag must not reject it again.
if (m_importer != panel)
@@ -172,8 +185,10 @@ void ContentView::showRawDevelopSheet()
shown->reject();
return;
}
if (m_rawSheet)
if (m_rawSheet) {
m_rawSheet->findChild<RawDevelopSheet *>()->setImporting(m_session.rawImporting());
return;
}
m_rawSheet = new QDialog(this);
m_rawSheet->setObjectName(QStringLiteral("rawDevelopSheet"));
m_rawSheet->setAttribute(Qt::WA_DeleteOnClose);
+32 -8
View File
@@ -11,6 +11,8 @@
#include "UI/ShapeControls.h"
#include "UI/TypeControls.h"
#include "UI/LassoControls.h"
#include "UI/LayerMaskMenu.h"
#include "UI/NavigatorMinimap.h"
#include "UI/NavigationToolHeader.h"
#include "UI/TransformInspector.h"
#include "UI/NewCanvasSheet.h"
@@ -196,7 +198,8 @@ protected:
ContentView::ContentView(EditorSession &session, ProjectController *projects, QWidget *parent)
: QWidget(parent), m_session(session), m_projects(projects), m_column(new QVBoxLayout(this)), m_canvasSlot(new QGridLayout),
m_canvas(new CanvasView(session, this)), m_layersPanel(new LayersPanel(session, this)), m_dock(new QWidget(this)), m_dropRing(new DropRing(this)), m_zoom(statusText(new QLabel(this))), m_dimensions(statusText(new QLabel(this))), m_colour(statusText(new QLabel(this))),
m_canvas(new CanvasView(session, this)), m_layersPanel(new LayersPanel(session, this)), m_dock(new QWidget(this)), m_dropRing(new DropRing(this)),
m_maskAloneBadge(new MaskAloneBadge(session, this)), m_zoom(statusText(new QLabel(this))), m_dimensions(statusText(new QLabel(this))), m_colour(statusText(new QLabel(this))),
m_activity(statusText(new ElidedLabel(this))),
m_spinner(new QProgressBar(this))
{
@@ -240,12 +243,23 @@ ContentView::ContentView(EditorSession &session, ProjectController *projects, QW
// The welcome sits over the canvas, as Swift's ZStack.
m_canvasSlot->addWidget(m_canvas, 0, 0);
m_canvasSlot->addWidget(m_dropRing, 0, 0);
// A layout takes no clicks; the canvas keeps the rest.
auto *foot = new QVBoxLayout;
// Swift's minimap sits 12 in, at the top right.
foot->setContentsMargins(12, 12, 12, 0);
foot->addWidget(new NavigatorMinimap(session, canvas), 0, Qt::AlignRight);
foot->addStretch(1);
foot->addWidget(m_maskAloneBadge, 0, Qt::AlignHCenter);
foot->addSpacing(14);
m_canvasSlot->addLayout(foot, 0, 0);
setAcceptDrops(true);
auto *middle = new QHBoxLayout;
middle->setSpacing(0);
middle->addWidget(rail);
middle->addWidget(divider(QFrame::VLine, this));
m_panels = {rail, divider(QFrame::VLine, this), new PanelResizeEdge(*m_layersPanel, this), m_layersPanel, divider(QFrame::HLine, this),
divider(QFrame::HLine, this), new QWidget(this)};
middle->addWidget(m_panels[1]);
// The rulers frame the canvas above and left, Swift's stacks.
auto *framed = new QWidget(this);
auto *rulers = new QGridLayout(framed);
@@ -259,7 +273,7 @@ ContentView::ContentView(EditorSession &session, ProjectController *projects, QW
rulers->addWidget(canvas, 1, 1);
middle->addWidget(framed, 1);
m_layersPanel->setFixedWidth(int(layersPanelWidth()));
middle->addWidget(new PanelResizeEdge(*m_layersPanel, this));
middle->addWidget(m_panels[2]);
middle->addWidget(m_layersPanel);
m_dock->setObjectName(QStringLiteral("panelDock"));
m_dock->setFixedWidth(FloatingPanel::dockedWidth);
@@ -276,7 +290,7 @@ ContentView::ContentView(EditorSession &session, ProjectController *projects, QW
m_spinner->setRange(0, 0);
m_spinner->setTextVisible(false);
m_spinner->setFixedSize(36, 6);
auto *status = new QWidget(this);
QWidget *status = m_panels[6];
status->setObjectName(QStringLiteral("statusBar"));
status->setFixedHeight(30);
auto *statusRow = new QHBoxLayout(status);
@@ -289,9 +303,9 @@ ContentView::ContentView(EditorSession &session, ProjectController *projects, QW
// The hint takes what is left, and shrinks first.
statusRow->addWidget(m_activity, 1);
m_column->addWidget(divider(QFrame::HLine, this));
m_column->addWidget(m_panels[4]);
m_column->addLayout(middle, 1);
m_column->addWidget(divider(QFrame::HLine, this));
m_column->addWidget(m_panels[5]);
m_column->addWidget(status);
// The filter panel follows the session by itself.
@@ -390,8 +404,17 @@ void ContentView::synchronize()
}
const std::optional<CanvasDocument> &document = m_session.document();
showWelcome(!document);
const std::optional<ImageLayer> alone = m_session.maskAloneLayer();
if (alone)
m_maskAloneBadge->setLayerName(alone->name);
m_maskAloneBadge->setVisible(alone.has_value());
for (QWidget *ruler : m_rulers)
ruler->setVisible(m_session.showsRulers() && document);
ruler->setVisible(m_session.showsRulers() && document && !m_session.canvasOnly());
for (QWidget *panel : m_panels)
panel->setVisible(!m_session.canvasOnly());
// A bar being made signals too: there is none yet.
if (m_header)
m_header->setVisible(!m_session.canvasOnly());
m_zoom->setVisible(document.has_value());
m_dimensions->setVisible(document.has_value());
if (document) {
@@ -422,7 +445,7 @@ void ContentView::showWelcome(bool shown)
if (!shown)
return;
m_welcome = new NewCanvasSheet(
m_session, [this](int width, int height) { m_session.createNewProject(width, height); },
m_session, [this](int width, int height, double resolution, std::optional<QColor> background) { m_session.createNewProject(width, height, resolution, background); },
[this] {
if (m_projects)
m_projects->open();
@@ -445,6 +468,7 @@ void ContentView::showHeader(NavigationTool tool)
return;
m_shownTool = tool;
delete m_header;
m_header = nullptr;
// A tool without a bar yet keeps the bar's height.
if (tool == NavigationTool::idle)
m_header = new ToolHeaderBar(QStringLiteral("Select a tool"), this);
+7
View File
@@ -14,6 +14,7 @@
#include <QWidget>
class LayersPanel;
class MaskAloneBadge;
class ToolButton;
class ProjectWorkspace;
class QFileDialog;
@@ -74,8 +75,12 @@ private:
QWidget *const m_dock;
// An accent ring while a drop may land.
QWidget *const m_dropRing;
// Over the canvas's foot while a mask shows alone.
MaskAloneBadge *const m_maskAloneBadge;
// Swift 1.1.7's rulers: the corner, then the two strips.
std::vector<QWidget *> m_rulers;
// Bars and panels Canvas Only puts away.
std::vector<QWidget *> m_panels;
ProjectWorkspace *m_workspace = nullptr;
// A document that appears hands the canvas the keys.
bool m_hadDocument = false;
@@ -102,5 +107,7 @@ private:
FloatingPanel m_effectsPanel{QStringLiteral("effectsPanel"), *this};
FloatingPanel m_selectionAmountPanel{QStringLiteral("selectionAmountPanel"), *this};
std::optional<SelectionAmountOperation> m_selectionAmountShown;
FloatingPanel m_colorRangePanel{QStringLiteral("colorRangePanel"), *this};
bool m_colorRangeShown = false;
std::optional<LayerEffectSelection> m_effectsShown;
};
+2 -2
View File
@@ -1,4 +1,4 @@
// Swift's BlurTool extension: what a Blur stroke paints.
public:
// The active layer, or its mask, softened at document size.
std::optional<QImage> blurSample(const CanvasDocument &document, bool mask = false) const;
// The stroke's pixels or mask, sharp; `render` softens parts.
std::optional<BrushStroke::Clone> blurSample(const BrushStroke &stroke) const;
+61 -35
View File
@@ -2,49 +2,75 @@
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include "Rendering/LayerRenderer.h"
#include <cmath>
// Swift's BlurTool extension: the layer, or its mask, softened.
std::optional<QImage> EditorSession::blurSample(const CanvasDocument &document, bool mask) const
namespace {
// `part` padded by `margin`: clear outside, or edges repeated.
QImage padded(const QImage &image, const QRect &part, int margin, bool repeat)
{
const std::optional<ImageLayer> layer = activeLayer();
if (!layer)
const QRect around = part.adjusted(-margin, -margin, margin, margin);
if (!repeat) {
const QImage copy = image.copy(around);
if (copy.isNull())
throw ExportError(ExportError::Kind::render);
return copy;
}
QImage copy = BrushRaster::context(around.width(), around.height(), true);
for (int y = 0; y < around.height(); ++y) {
const uchar *row = image.constScanLine(std::clamp(around.top() + y, 0, image.height() - 1));
uchar *out = copy.scanLine(y);
for (int x = 0; x < around.width(); ++x)
out[x] = row[std::clamp(around.left() + x, 0, image.width() - 1)];
}
return copy;
}
// A piece blurred alone equals that part of the whole.
std::function<QImage(const QRect &)> softened(const QImage &sharp, double sigma, bool mask)
{
return [sharp, sigma, mask](const QRect &part) {
// The pad holds the edges; the blur's rule never reaches.
const int margin = int(std::ceil(3 * sigma));
const QImage piece = PixelAdjust::gaussianBlur(padded(sharp, part, margin, mask), sigma, false).copy(margin, margin, part.width(), part.height());
if (piece.isNull())
throw ExportError(ExportError::Kind::render);
return piece;
};
}
}
// Swift's BlurTool extension: the layer's pixels, or mask, softened.
std::optional<BrushStroke::Clone> EditorSession::blurSample(const BrushStroke &stroke) const
{
const ImageLayer &layer = stroke.layer;
const std::optional<ImportedImage> &asset = stroke.isMask ? (layer.mask ? std::optional(layer.mask->asset) : std::nullopt) : layer.asset;
if (!asset)
return std::nullopt;
// The softening follows the brush's size.
const double sigma = std::min(30.0, std::max(1.5, m_brushSettings.diameter / 10));
// The bar's Radius, carried into the layer's pixels.
const QTransform &map = stroke.pixelToDocument;
const double perPixel = std::max(1e-6, std::sqrt(std::abs(map.m11() * map.m22() - map.m12() * map.m21())));
const double sidePixels = std::max(stroke.sourceRect.width(), stroke.sourceRect.height());
const double sigma = std::min(std::min(50.0, std::max(0.5, m_brushSettings.blurRadius)) / perPixel, sidePixels / 2);
// Room for the blur to spread past the pixels' edges.
const double margin = std::ceil(3 * sigma);
const QRectF region = stroke.sourceRect.adjusted(-margin, -margin, margin, margin);
// A huge layer's sample is made coarser, within several canvases.
const qint64 canvas = qint64(m_document.value().width) * m_document.value().height;
const double budget = double(std::min(DocumentLimits::maxSurfacePixels, std::max<qint64>(16'000'000, 4 * canvas)));
const double fit = std::min(1.0, std::sqrt(budget / (region.width() * region.height())));
const int width = std::max(1, int(std::ceil(region.width() * fit))), height = std::max(1, int(std::ceil(region.height() * fit)));
const QRectF placed(margin * fit, margin * fit, stroke.sourceRect.width() * fit, stroke.sourceRect.height() * fit);
try {
if (mask) {
if (!layer->mask)
return std::nullopt;
QImage context = BrushRaster::context(document.width, document.height, true);
// Past its pixels a mask keeps its edge tone.
const int tone = int(std::round(LayerMask::background(layer->mask->asset.thumbnail) * 255));
context.fill(QColor(tone, tone, tone));
QPainter painter(&context);
// Coverage only adds white: the mask's area starts black.
const LayerTransform placement = layer->maskTransform();
painter.save();
QTransform box;
box.translate(placement.center().x(), placement.center().y());
box.rotateRadians(placement.radians());
painter.setTransform(box, true);
painter.fillRect(QRectF(-placement.size.width() / 2, -placement.size.height() / 2, placement.size.width(), placement.size.height()),
Qt::black);
painter.restore();
LayerRenderer::drawCoverage(layer->mask->asset.image(), placement, painter);
painter.end();
return PixelAdjust::gaussianBlur(context, sigma, true);
}
if (!layer->asset)
return std::nullopt;
QImage context = BrushRaster::context(document.width, document.height, false);
QImage context = BrushRaster::context(width, height, stroke.isMask);
// Past its pixels a mask keeps its edge tone.
if (stroke.isMask)
context.fill(int(std::round(LayerMask::background(asset->thumbnail) * 255)));
QPainter painter(&context);
// A painted asset flattens here; the catch covers it too.
const LayerTransform transform = displayedTransform(*layer);
LayerRenderer::draw(layer->asset->image(), transform, transform.center(), painter, {});
BrushRaster::draw(asset->image(), placed, painter);
painter.end();
return PixelAdjust::gaussianBlur(context, sigma, false);
// Sharp; only the parts the brush reaches are softened.
return BrushStroke::Clone{context, region, true, softened(context, sigma * fit, stroke.isMask)};
} catch (const ExportError &error) {
qCWarning(lcApp) << "the Blur tool cannot sample the layer:" << error.what();
return std::nullopt;
+14 -3
View File
@@ -58,6 +58,14 @@ LayerTransform BrushStroke::transform(const QRectF &bounds) const
return result;
}
// Offset into grid pixels, turned and scaled as the layer.
QRectF BrushStroke::gridRect(const QRectF &rect, QSizeF offset) const
{
const QTransform toGrid = pixelToDocument.inverted();
const QPointF shift = toGrid.map(QPointF(offset.width(), offset.height())) - toGrid.map(QPointF(0, 0));
return rect.translated(-shift);
}
// Swift's heal: the painted spot rebuilt from nearby texture.
void BrushStroke::heal()
{
@@ -131,8 +139,9 @@ PaintSnapshot BrushStroke::paintSnapshot() const
const QRectF placed = rect.translated(tile.rect.topLeft());
bounds = bounds ? std::optional(bounds->united(placed)) : std::optional(placed);
}
const QRectF crop = bounds.value_or(committedBounds());
const std::shared_ptr<const RasterSnapshot> raster = RasterSnapshot::replacing(m_source, sourceRect, patches(), crop, isMask);
// A mask keeps every tile touched: past its pixels, grows.
const QRectF crop = isMask ? committedBounds() : bounds.value_or(committedBounds());
const std::shared_ptr<const RasterSnapshot> raster = RasterSnapshot::replacing(m_source, sourceRect, patches(), crop, isMask, maskBackground);
return {ImportedImage(raster, raster->thumbnail(), layer.name), transform(crop), crop};
}
@@ -142,7 +151,7 @@ BrushCommit::Input BrushStroke::commitInput() const
std::vector<BrushPatch> shifted;
for (const BrushPatch &patch : patches())
shifted.push_back({patch.rect.translated(-bounds.topLeft()), patch.image});
return {int(bounds.width()), int(bounds.height()), m_source, shifted, isMask, layer.name, sourceRect.translated(-bounds.topLeft())};
return {int(bounds.width()), int(bounds.height()), m_source, shifted, isMask, layer.name, sourceRect.translated(-bounds.topLeft()), maskBackground};
}
ImportedImage BrushCommit::expandMask(const ImportedImage &asset, const Input &input, const QRectF &crop)
@@ -161,6 +170,8 @@ ImportedImage BrushCommit::expandMask(const ImportedImage &asset, const Input &i
BrushCommit::Output BrushCommit::render(const Input &input)
{
QImage context = BrushRaster::context(input.width, input.height, input.mask);
if (input.mask)
context.fill(qRound(input.fill * 255));
QPainter painter(&context);
if (input.source)
drawSource(*input.source, input.sourceRect, painter);
+55 -22
View File
@@ -1,4 +1,5 @@
#include "Document/BrushStroke.h"
#include "Document/DocumentLimits.h"
#include "Rendering/PoolMap.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
@@ -63,20 +64,26 @@ double BrushStroke::spacingFraction(double hardness)
return hardness >= 1 ? 0.015 : 0.025;
}
BrushStroke::Grid BrushStroke::grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas)
BrushStroke::Grid BrushStroke::grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, bool growsMask)
{
// A placed mask paints in its grid; else the layer's.
const bool placedMask = mask && layer.mask && layer.mask->placement;
const LayerTransform base = placedMask ? *layer.mask->placement : layer.transform;
const int originalWidth = placedMask ? layer.mask->asset.size().width()
: layer.asset ? layer.asset->size().width() : int(std::round(layer.size().width()));
const int originalHeight = placedMask ? layer.mask->asset.size().height()
: layer.asset ? layer.asset->size().height() : int(std::round(layer.size().height()));
if (originalWidth < 1 || originalWidth > 30'000 || originalHeight < 1 || originalHeight > 30'000)
// A solid placed mask gets a pixel per document pixel.
const bool solidPlaced = placedMask && layer.mask->asset.size().width() <= 2 && layer.mask->asset.size().height() <= 2;
const int originalWidth = solidPlaced ? std::max(1, int(std::round(base.size.width())))
: placedMask ? layer.mask->asset.size().width()
: layer.asset ? layer.asset->size().width()
: int(std::round(layer.size().width()));
const int originalHeight = solidPlaced ? std::max(1, int(std::round(base.size.height())))
: placedMask ? layer.mask->asset.size().height()
: layer.asset ? layer.asset->size().height()
: int(std::round(layer.size().height()));
if (originalWidth < 1 || originalWidth > DocumentLimits::maxSide || originalHeight < 1 || originalHeight > DocumentLimits::maxSide)
throw ProjectError(ProjectError::Kind::tooLarge);
const QTransform originalMapping = BrushRaster::pixelToDocument(base, originalWidth, originalHeight);
const QRectF originalBounds(0, 0, originalWidth, originalHeight);
const QRectF extent = mask ? originalBounds : originalBounds.united(integral(originalMapping.inverted().mapRect(canvas)));
const QRectF extent = mask && !growsMask ? originalBounds : originalBounds.united(integral(originalMapping.inverted().mapRect(canvas)));
// Judged as a double: past int the conversion is undefined.
if (extent.width() < 1 || extent.width() > 1'000'000'000 || extent.height() < 1 || extent.height() > 1'000'000'000)
throw ProjectError(ProjectError::Kind::tooLarge);
@@ -85,21 +92,22 @@ BrushStroke::Grid BrushStroke::grid(const ImageLayer &layer, bool mask, const Br
grid.paintTransform.size = {grid.width * base.size.width() / originalWidth, grid.height * base.size.height() / originalHeight};
const QPointF center = originalMapping.map(extent.center());
grid.paintTransform.origin = {center.x() - grid.paintTransform.size.width() / 2, center.y() - grid.paintTransform.size.height() / 2};
if (!std::isfinite(settings.diameter) || settings.diameter < 1 || settings.diameter > 2000
if (!std::isfinite(settings.diameter) || settings.diameter < 1 || settings.diameter > maxDiameter
|| !std::isfinite(settings.hardness) || settings.hardness < 0 || settings.hardness > 1
|| !std::isfinite(settings.opacity) || settings.opacity < 0.01 || settings.opacity > 1)
throw ProjectError(ProjectError::Kind::tooLarge);
return grid;
}
BrushStroke::BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas)
: BrushStroke(layer, mask, settings, QRectF(QPointF(0, 0), canvas), grid(layer, mask, settings, QRectF(QPointF(0, 0), canvas)))
BrushStroke::BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas, bool growsMask)
: BrushStroke(layer, mask, settings, QRectF(QPointF(0, 0), canvas), grid(layer, mask, settings, QRectF(QPointF(0, 0), canvas), growsMask))
{
}
BrushStroke::BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, const Grid &grid)
: layer(layer), isMask(mask), settings(settings), canvas(canvas), width(grid.width), height(grid.height), sourceRect(grid.sourceRect),
pixelToDocument(grid.pixelToDocument), paintTransform(grid.paintTransform),
maskBackground(mask && layer.mask ? LayerMask::background(layer.mask->asset.thumbnail) : 1),
m_source(mask ? (layer.mask ? std::optional(layer.mask->asset) : std::nullopt) : layer.asset),
m_paintColor(mask ? QColor::fromRgbF(settings.red, settings.red, settings.red) : QColor::fromRgbF(settings.red, settings.green, settings.blue))
{
@@ -231,7 +239,7 @@ void BrushStroke::publish(const std::set<qint64> &changed)
tile.context.detach();
if (tile.context.isNull())
throw ExportError(ExportError::Kind::render);
work.push_back({&tile, &m_coverage.at(key), selectionCoverage(tile), clonePixels(tile)});
work.push_back({&tile, &m_coverage.at(key), selectionCoverage(tile), clonePixels(key, tile)});
const QRectF rect = pixelToDocument.mapRect(tile.rect).intersected(canvas);
m_dirtyDocumentRect = m_dirtyDocumentRect ? std::optional(m_dirtyDocumentRect->united(rect)) : std::optional(rect);
}
@@ -242,9 +250,11 @@ void BrushStroke::publish(const std::set<qint64> &changed)
const double alpha = healing ? 0.45 : settings.opacity;
PoolMap::blocking(work, [&](const Fill &item) {
Tile &tile = *item.tile;
if (clone && (!isMask || isBlur))
fillClone(item.sample, *item.coverage, settings.opacity, tile.context, item.clip, replacesWithClone);
else
if (m_clone && (!isMask || isBlur)) {
// Where the sample does not reach, nothing is painted.
if (!item.sample.isNull())
fillClone(item.sample, *item.coverage, settings.opacity, tile.context, item.clip, replacesWithClone);
} else
BrushRaster::fill(colour, *item.coverage, QRectF(QPointF(0, 0), tile.rect.size()), alpha, tile.context, mode, item.clip);
tile.image = tile.context;
});
@@ -259,17 +269,37 @@ QImage BrushStroke::selectionCoverage(const Tile &tile) const
QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument);
}
// The clone as this tile sees it, before the fill.
QImage BrushStroke::clonePixels(const Tile &tile) const
void BrushStroke::setClone(std::optional<Clone> clone)
{
if (!clone)
m_clone = std::move(clone);
m_clonePieces.clear();
}
// The clone as this tile sees it, before the fill.
QImage BrushStroke::clonePixels(qint64 key, const Tile &tile)
{
if (!m_clone)
return QImage();
const int width = int(tile.rect.width()), height = int(tile.rect.height());
// Tile pixels to clone pixels: grid, document, then offset.
const QTransform toClone = QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument
* QTransform::fromTranslate(clone->offset.width(), clone->offset.height());
// Tile to sample pixels; each tile's piece is cut once.
const QRectF &placed = m_clone->placed;
const QTransform toWhole = QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * (m_clone->inGrid ? QTransform() : pixelToDocument)
* QTransform::fromTranslate(-placed.left(), -placed.top())
* QTransform::fromScale(m_clone->image.width() / placed.width(), m_clone->image.height() / placed.height());
if (!m_clonePieces.contains(key)) {
const QRect part =
toWhole.mapRect(QRectF(0, 0, width, height)).adjusted(-2, -2, 2, 2).toAlignedRect().intersected(m_clone->image.rect());
if (part.isEmpty())
return QImage();
const QImage piece = m_clone->render ? m_clone->render(part) : m_clone->image.copy(part);
if (piece.isNull())
throw ExportError(ExportError::Kind::render);
m_clonePieces.emplace(key, ClonePiece{piece, part.topLeft()});
}
const ClonePiece &piece = m_clonePieces.at(key);
const QTransform toClone = toWhole * QTransform::fromTranslate(-piece.origin.x(), -piece.origin.y());
// Colour even for gray clones: edges carry their coverage.
const QImage &image = clone->image;
const QImage &image = piece.image;
if (toClone.type() > QTransform::TxTranslate) {
// Swift's medium quality: bilinear under a scale or turn.
QImage sample = BrushRaster::context(width, height, false);
@@ -301,10 +331,13 @@ void BrushStroke::allocateTile(qint64 key, qint64 x, qint64 y)
return;
const QRectF rect(x * tileSize, y * tileSize, std::min<qint64>(tileSize, width - x * tileSize), std::min<qint64>(tileSize, height - y * tileSize));
const QRectF nextBounds = m_allocatedBounds ? m_allocatedBounds->united(rect) : m_source ? sourceRect.united(rect) : rect;
if (nextBounds.width() > 30'000 || nextBounds.height() > 30'000 || nextBounds.width() * nextBounds.height() > pixelLimit)
if (nextBounds.width() > DocumentLimits::maxSide || nextBounds.height() > DocumentLimits::maxSide || nextBounds.width() * nextBounds.height() > pixelLimit)
throw ProjectError(ProjectError::Kind::tooLarge);
m_allocatedBounds = nextBounds;
QImage context = BrushRaster::context(int(rect.width()), int(rect.height()), isMask);
// Past the mask's pixels a tile starts as its background.
if (isMask)
context.fill(qRound(maskBackground * 255));
QPainter painter(&context);
const QRectF initialRect = sourceRect.translated(-rect.topLeft());
if (m_source && m_source->raster) {
+33 -1
View File
@@ -3,6 +3,7 @@
#include "IO/ImageExporter.h"
#include <QtConcurrent>
#include <cmath>
#include <cstring>
#include <numeric>
#include <stdexcept>
@@ -28,6 +29,37 @@ QImage BrushRaster::context(int width, int height, bool mask)
return surface;
}
QImage BrushRaster::copy(const QImage &image)
{
QImage result = context(image.width(), image.height(), false);
if (image.format() != QImage::Format_RGBA8888_Premultiplied) {
QPainter painter(&result);
draw(image, QRectF(0, 0, image.width(), image.height()), painter);
return result;
}
for (int y = 0; y < image.height(); ++y)
std::memcpy(result.scanLine(y), image.constScanLine(y), size_t(image.width()) * 4);
return result;
}
void BrushRaster::inBands(qsizetype count, const std::function<void(qsizetype, qsizetype)> &body)
{
const int bands = count < 250'000 ? 1 : QThread::idealThreadCount() * 2;
const qsizetype size = (count + bands - 1) / bands;
try {
std::vector<int> indices(static_cast<size_t>(bands));
std::iota(indices.begin(), indices.end(), 0);
PoolMap::blocking(indices, [&](int band) {
const qsizetype start = band * size;
if (start < count)
body(start, std::min(size, count - start));
});
} catch (const std::bad_alloc &) {
// Out of memory fails as a context would.
throw ExportError(ExportError::Kind::render);
}
}
void BrushRaster::draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source)
{
context.save();
@@ -60,7 +92,7 @@ QTransform BrushRaster::pixelToDocument(const LayerTransform &transform, int wid
const QPointF center = transform.center();
QTransform map;
map.translate(center.x(), center.y());
map.rotateRadians(transform.radians());
map.rotate(transform.degrees());
map.scale(transform.size.width() / width * (transform.flipX ? -1 : 1),
transform.size.height() / height * (transform.flipY ? -1 : 1));
map.translate(-width / 2.0, -height / 2.0);
+36 -7
View File
@@ -1,5 +1,6 @@
#pragma once
#include "Document/EditorSession+Model.h"
#include "Document/DocumentLimits.h"
#include "Document/Gradient.h"
#include "Document/LayerTransform.h"
#include "Document/Selection.h"
@@ -33,6 +34,8 @@ struct BrushSettings {
double opacity = 1;
// 0–100: the brush trails the pointer on this string.
double smoothing = 0;
// Blur: how far it softens, in canvas pixels.
double blurRadius = 5;
// Erase clears the layer's pixels instead of painting on them.
bool erasing = false;
// Spot healing takes nearby pixels instead of the colour.
@@ -43,6 +46,10 @@ struct BrushSettings {
// Shared raster drawing; coverage is copied without color conversion.
namespace BrushRaster {
QImage context(int width, int height, bool mask);
// A colour context holding `image`; its own layout copies bytewise.
QImage copy(const QImage &image);
// Runs `body` over `count` pixels, band by band: start, length.
void inBands(qsizetype count, const std::function<void(qsizetype, qsizetype)> &body);
// Copies `image`, or its `source` part, into `rect`.
void draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source = QRectF());
// A solid colour through gray coverage, and a clip, 1:1.
@@ -74,6 +81,8 @@ struct Input {
bool mask;
QString name;
QRectF sourceRect;
// A grown mask where no old pixel or edit reaches.
double fill = 1;
};
struct Output {
ImportedImage asset;
@@ -86,7 +95,8 @@ Output render(const Input &input);
// Only touched 256 px tiles hold pixels; snapshots copy those.
class BrushStroke {
public:
BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas);
// `growsMask`: the brush and fills reach the canvas on masks.
BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas, bool growsMask = false);
const ImageLayer layer;
const bool isMask;
@@ -97,16 +107,23 @@ public:
const QRectF sourceRect;
const QTransform pixelToDocument;
const LayerTransform paintTransform;
// A mask past its pixels: 1 reveals, 0 hides.
const double maskBackground;
// Swift's Int: the budget left may run below zero.
qint64 pixelLimit = 100'000'000;
qint64 pixelLimit = DocumentLimits::documentPixelBudget();
// Limits every edit to the document selection; nil when none.
std::optional<SelectionClip> selectionClip;
// Clone Stamp: a document-sized image, and each point's offset.
// Painted through the tip, placed: document or `inGrid` pixels.
struct Clone {
QImage image;
QSizeF offset;
QRectF placed;
bool inGrid;
// Makes a part of `image`: Blur softens piece by piece.
std::function<QImage(const QRect &)> render;
};
std::optional<Clone> clone;
const std::optional<Clone> &clone() const { return m_clone; }
// Swift's didSet: the pieces cut so far go.
void setClone(std::optional<Clone> clone);
// Blur: the clone is the layer softened, in place.
bool isBlur = false;
// The clone replaces what is under the tip, clearing too.
@@ -114,6 +131,8 @@ public:
// The undo name when the stroke's kind says none.
std::optional<QString> editName;
static constexpr int tileSize = 256;
// Size stops at 2000; the app's own commits run wider.
static constexpr double maxDiameter = 2100;
// Soft-tip deposition rate, shared with the continuous integral.
static double spacingFraction(double hardness);
@@ -138,6 +157,8 @@ public:
QRectF committedBounds() const;
LayerTransform committedTransform() const;
LayerTransform transform(const QRectF &bounds) const;
// `rect` shifted to copy from `offset` document pixels away.
QRectF gridRect(const QRectF &rect, QSizeF offset) const;
// Spot Healing's end: the painted spot rebuilt from around it.
void heal();
PaintSnapshot paintSnapshot() const;
@@ -162,14 +183,15 @@ private:
QTransform pixelToDocument;
LayerTransform paintTransform;
};
static Grid grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas);
static Grid grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, bool growsMask);
BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, const Grid &grid);
std::vector<BrushSegment> continuousCurve(QPointF start, QPointF end, QPointF before, QPointF after) const;
std::set<qint64> continuousKeys(const std::vector<BrushSegment> &segments) const;
void renderContinuous(const std::vector<BrushSegment> &settled, const std::vector<BrushSegment> &tail);
void publish(const std::set<qint64> &changed);
QImage selectionCoverage(const Tile &tile) const;
QImage clonePixels(const Tile &tile) const;
// Null where the clone does not reach the tile.
QImage clonePixels(qint64 key, const Tile &tile);
void allocateTile(qint64 key, qint64 x, qint64 y);
// `draw` in document coordinates over every tile the canvas covers.
void paintCanvas(bool withinSource, const std::function<void(QPainter &)> &draw);
@@ -188,5 +210,12 @@ private:
std::map<qint64, QImage> m_coverage;
// Selected pixels cut from the source, in layer pixels.
std::optional<Lifted> m_lifted;
std::optional<Clone> m_clone;
// Each tile's part of the clone, cut once, and where.
struct ClonePiece {
QImage image;
QPoint origin;
};
std::map<qint64, ClonePiece> m_clonePieces;
std::set<qint64> m_moveTiles;
};
+30 -61
View File
@@ -1,14 +1,13 @@
#include "Document/CameraRaw.h"
#include "Document/BrushStroke.h"
#include "Document/CameraRawTables.h"
#include "Document/Filters.h"
#include "Document/Levels.h"
#include <QPainter>
#include <algorithm>
#include <cmath>
#include <numbers>
extern "C" {
#include "AdjustPixels.h"
#include "LevelsPixels.h"
}
using ImageAdjustmentPixels::clamp;
@@ -19,6 +18,11 @@ double decode(double encoded)
return encoded <= 0.04045 ? encoded / 12.92 : std::pow((encoded + 0.055) / 1.055, 2.4);
}
double encode(double linear)
{
return linear <= 0.0031308 ? linear * 12.92 : 1.055 * std::pow(linear, 1 / 2.4) - 0.055;
}
// The image drawn into the kernels' premultiplied context.
QImage drawn(const QImage &image)
{
@@ -109,23 +113,18 @@ double CameraRawSettings::grainKernelSize() const
return 0.5 + (grainSize / 100) * 19.5;
}
CameraRawSettings::Gains CameraRawSettings::gains() const
{
const double warm = temperature / 100, magenta = tint / 100;
return {1 + temperatureGain * warm + tintRedBlue * magenta, 1 - tintGreen * magenta, 1 - temperatureGain * warm + tintRedBlue * magenta};
}
QImage CameraRawSettings::apply(const QImage &image, std::optional<CameraRawClipping> clipping, double scale, quint32 seed, int visualizePointColor,
bool sharpenMask) const
bool sharpenMask, std::optional<CameraRawTables::Brightness> known) const
{
const CameraRawSettings settings = normalized();
if (settings.isIdentity() && !clipping && visualizePointColor < 0 && !sharpenMask)
return image;
const Gains gain = settings.gains();
const int mode = clipping ? int(*clipping) : 0;
const double pixelScale = scale > 0 ? scale : 1;
const bool paintColor = !clipping && !sharpenMask
&& (settings.curve.adjusts() || settings.mixer.adjusts() || settings.grading.adjusts() || visualizePointColor >= 0);
// Curve, Mixer and Grading join the table unless curve-passed.
const bool foldsColor = !clipping && !sharpenMask && visualizePointColor < 0 && settings.mixer.points.empty() && settings.curve.refineSaturation == 0;
const bool colorAdjusts = settings.curve.adjusts() || settings.mixer.adjusts() || settings.grading.adjusts();
const bool paintColor = !foldsColor && !clipping && !sharpenMask && (colorAdjusts || visualizePointColor >= 0);
const bool paintEffects = !clipping && !sharpenMask && settings.adjustsEffects();
const bool paintDetailOptics = !clipping && (settings.detail.adjusts() || settings.optics.adjusts() || sharpenMask);
QImage source = image;
@@ -133,11 +132,17 @@ QImage CameraRawSettings::apply(const QImage &image, std::optional<CameraRawClip
source = settings.geometry.apply(source);
return ImageAdjustmentPixels::run(source, [&](uchar *pixels, int width, int height, qsizetype stride) {
const size_t w = size_t(width), h = size_t(height), s = size_t(stride);
const bool adaptive = settings.contrast != 0 || settings.highlights != 0 || settings.shadows != 0;
const CameraRawTables::Brightness brightness = !adaptive ? CameraRawTables::Brightness()
: known ? known.value()
: CameraRawTables::brightness(pixels, width, height, stride);
if (!clipping && !sharpenMask && settings.calibration.adjusts())
settings.applyCalibration(pixels, width, height, stride);
if (settings.adjustsLight() || settings.adjustsColor() || clipping)
adjust_camera_raw(pixels, w, h, s, gain.red, gain.green, gain.blue, settings.exposure, settings.contrast, settings.highlights,
settings.shadows, settings.whites, settings.blacks, settings.vibrance, settings.saturation, mode);
std::vector<float> table = CameraRawTables::compose(CameraRawTables::stages(settings, brightness));
if (foldsColor && colorAdjusts)
table = settings.foldingColor(std::move(table));
if (!table.empty() || clipping)
adjust_camera_raw(pixels, w, h, s, table.empty() ? nullptr : table.data(), CameraRawTables::grid, mode);
if (paintColor)
settings.applyCurveColor(pixels, width, height, stride, visualizePointColor);
if (paintEffects) {
@@ -154,24 +159,9 @@ QImage CameraRawSettings::apply(const QImage &image, std::optional<CameraRawClip
});
}
std::optional<CameraRawSettings::Balance> CameraRawSettings::neutralizeLinear(double red, double green, double blue)
{
if (!(red > 1e-4 && green > 1e-4 && blue > 1e-4))
return std::nullopt;
const double a1 = temperatureGain * red, b1 = tintRedBlue * red + tintGreen * green, c1 = green - red;
const double a2 = -temperatureGain * blue, b2 = tintRedBlue * blue + tintGreen * green, c2 = green - blue;
const double determinant = a1 * b2 - a2 * b1;
if (!(std::abs(determinant) > 1e-8))
return std::nullopt;
const double warm = (c1 * b2 - c2 * b1) / determinant, magenta = (a1 * c2 - a2 * c1) / determinant;
if (!std::isfinite(warm) || !std::isfinite(magenta))
return std::nullopt;
return Balance{warm * 100, magenta * 100};
}
std::optional<CameraRawSettings::Balance> CameraRawSettings::neutralizeStraight(double red, double green, double blue)
{
return neutralizeLinear(decode(red), decode(green), decode(blue));
return CameraRawTables::neutralize(red, green, blue);
}
std::optional<CameraRawSettings::Balance> CameraRawSettings::autoBalance(const QImage &image)
@@ -195,7 +185,7 @@ std::optional<CameraRawSettings::Balance> CameraRawSettings::autoBalance(const Q
}
if (count == 0)
return std::nullopt;
return neutralizeLinear(red / count, green / count, blue / count);
return neutralizeStraight(encode(red / count), encode(green / count), encode(blue / count));
}
double CameraRawScope::peak() const
@@ -207,36 +197,15 @@ std::optional<CameraRawScope> CameraRawScope::make(const QImage &image)
{
if (image.isNull())
return std::nullopt;
const QImage pixels = drawn(image);
std::array<double, binCount * 4> bins{};
// Row by row: Qt may pad rows; the kernel counts.
for (int y = 0; y < pixels.height(); ++y)
levels_histogram(pixels.constScanLine(y), nullptr, size_t(pixels.width()), bins.data());
// Every other pixel each way, from premultiplied bytes directly.
const QImage pixels = image.format() == QImage::Format_RGBA8888_Premultiplied ? image : drawn(image);
std::array<double, binCount * 3> bins{};
CameraRawScope scope;
std::copy_n(bins.begin() + binCount, binCount, scope.red.begin());
std::copy_n(bins.begin() + 2 * binCount, binCount, scope.green.begin());
std::copy_n(bins.begin() + 3 * binCount, binCount, scope.blue.begin());
for (int y = 0; y < pixels.height(); ++y) {
const uchar *row = pixels.constScanLine(y);
for (int x = 0; x < pixels.width(); ++x) {
const uchar *pixel = row + x * 4;
const double alpha = pixel[3];
if (alpha == 0)
continue;
const double r = std::min(1.0, pixel[0] / alpha), g = std::min(1.0, pixel[1] / alpha), b = std::min(1.0, pixel[2] / alpha);
const double high = std::max({r, g, b}), low = std::min({r, g, b}), chroma = high - low;
if (!(chroma > 1e-4 && high > 1e-4))
continue;
double hue = high == r ? (g - b) / chroma : high == g ? 2 + (b - r) / chroma : 4 + (r - g) / chroma;
// A negative hue needs no wrap: the angle turns round.
hue /= 6;
const double angle = hue * 2 * std::numbers::pi, reach = chroma / high;
const double plotX = 0.5 + std::cos(angle) * reach * 0.48, plotY = 0.5 + std::sin(angle) * reach * 0.48;
const int column = std::min(scopeSide - 1, std::max(0, int(plotX * scopeSide)));
const int line = std::min(scopeSide - 1, std::max(0, int(plotY * scopeSide)));
scope.vectorscope[size_t(line * scopeSide + column)] += alpha / 255;
}
}
camera_raw_scope(pixels.constBits(), size_t(pixels.width()), size_t(pixels.height()), size_t(pixels.bytesPerLine()), 2, bins.data(),
scope.vectorscope.data(), scopeSide);
std::copy_n(bins.begin(), binCount, scope.red.begin());
std::copy_n(bins.begin() + binCount, binCount, scope.green.begin());
std::copy_n(bins.begin() + 2 * binCount, binCount, scope.blue.begin());
return scope;
}
+14 -14
View File
@@ -33,13 +33,18 @@ struct CameraRawGroups {
friend bool operator==(const CameraRawGroups &, const CameraRawGroups &) = default;
};
namespace CameraRawTables {
// What adaptive sliders read first, in sRGB levels.
struct Brightness {
double brightest = 128;
double luminance = 128;
double logBrightest = 128;
};
}
// Defaults leave the image unchanged.
struct CameraRawSettings {
static constexpr double exposureLow = -5, exposureHigh = 5;
// A full warm or cool swing on red and blue.
static constexpr double temperatureGain = 0.35;
static constexpr double tintRedBlue = 0.15;
static constexpr double tintGreen = 0.30;
CameraRawWhiteBalance whiteBalance = CameraRawWhiteBalance::custom;
// Cool to warm, −100 to 100; tint green to magenta.
@@ -91,21 +96,16 @@ struct CameraRawSettings {
CameraRawSettings applying(const CameraRawGroups &shown) const;
// Camera Raw's 0 to 100 size in `adjust_grain`'s pixels.
double grainKernelSize() const;
struct Gains {
double red;
double green;
double blue;
};
Gains gains() const;
// `clipping`: the Option view; `scale`: preview pixels a pixel.
// Option view, preview scale, and the source's brightness once known.
QImage apply(const QImage &image, std::optional<CameraRawClipping> clipping = std::nullopt, double scale = 1, quint32 seed = 0,
int visualizePointColor = -1, bool sharpenMask = false) const;
int visualizePointColor = -1, bool sharpenMask = false, std::optional<CameraRawTables::Brightness> brightness = std::nullopt) const;
// Curve, Mixer and Grading run over a composed table.
std::vector<float> foldingColor(std::vector<float> composed) const;
struct Balance {
double temperature;
double tint;
};
// The temperature and tint making a linear pixel neutral.
static std::optional<Balance> neutralizeLinear(double red, double green, double blue);
// The temperature and tint turning a straight sRGB colour gray.
static std::optional<Balance> neutralizeStraight(double red, double green, double blue);
// Gray-world balance of the opaque pixels.
static std::optional<Balance> autoBalance(const QImage &image);
+57 -40
View File
@@ -1,5 +1,6 @@
#include "Document/CameraRawColor.h"
#include "Document/CameraRaw.h"
#include "Document/CameraRawTables.h"
#include "Document/ImageAdjustments.h"
#include <algorithm>
#include <cmath>
@@ -79,29 +80,44 @@ bool CameraRawCurveSettings::adjusts() const
|| !isLinear(green) || !isLinear(blue);
}
double CameraRawCurveSettings::parametric(double tone) const
// Bends tones below `lower` by `low`, above `upper` by `high`.
double CameraRawCurveSettings::bend(double tone, double lower, double low, double upper, double high)
{
const double shadow = shadowSplit / 100, dark = darkSplit / 100, light = lightSplit / 100;
double amount, lo, hi;
if (tone < shadow)
amount = shadows, lo = 0, hi = shadow;
else if (tone < dark)
amount = darks, lo = shadow, hi = dark;
else if (tone < light)
amount = lights, lo = dark, hi = light;
else
amount = highlights, lo = light, hi = 1;
const double span = std::max(0.02, hi - lo);
const double weight = 1 - std::abs(tone - (lo + hi) / 2) / (span / 2);
// A tone in its region weighs 0 to 1, unfloored.
return std::min(1.0, std::max(0.0, tone + (amount / 100) * weight * 0.22));
// Fitted to Photoshop: Darks −51 dips 0.1 there.
const double strength = 1.66;
if (tone < lower)
return lower * std::pow(tone / lower, std::pow(2, -low / 100 * strength));
if (tone > upper) {
const double rest = 1 - upper;
return 1 - rest * std::pow((1 - tone) / rest, std::pow(2, high / 100 * strength));
}
return tone;
}
std::vector<float> CameraRawCurveSettings::lumaTable() const
double CameraRawCurveSettings::parametric(double tone) const
{
if (shadows == 0 && darks == 0 && lights == 0 && highlights == 0)
return tone;
// Gamma bends, smoothed as Image › Curves smooths.
std::vector<CurvePoint> anchors;
for (int index = 0; index <= 32; ++index) {
const double x = index / 32.0;
anchors.push_back({x, bend(bend(x, shadowSplit / 100, shadows, lightSplit / 100, highlights), darkSplit / 100, darks, darkSplit / 100, lights)});
}
return point(tone, anchors);
}
bool CameraRawCurveSettings::hasMeasuredDividers() const
{
return shadowSplit == 25 && darkSplit == 50 && lightSplit == 75 && refineSaturation == 0;
}
std::vector<float> CameraRawCurveSettings::toneTable() const
{
const bool measured = hasMeasuredDividers();
std::vector<float> table(256);
for (int index = 0; index < 256; ++index)
table[size_t(index)] = float(point(parametric(index / 255.0), rgb));
table[size_t(index)] = float(point(measured ? index / 255.0 : parametric(index / 255.0), rgb));
return table;
}
@@ -180,15 +196,6 @@ std::array<double, 8> CameraRawMixerSettings::weights(double degrees)
return result;
}
std::vector<float> CameraRawMixerSettings::mixerFloats() const
{
std::vector<float> result;
for (const std::array<double, 8> *values : {&hue, &saturation, &luminance})
for (const double value : *values)
result.push_back(float(value / 100));
return result;
}
std::vector<float> CameraRawMixerSettings::pointFloats() const
{
std::vector<float> result;
@@ -242,30 +249,40 @@ bool CameraRawGradingSettings::adjusts() const
return std::any_of(all.begin(), all.end(), [](const CameraRawGradeWheel &wheel) { return wheel.saturation != 0 || wheel.luminance != 0; });
}
std::vector<float> CameraRawGradingSettings::gradeFloats() const
{
std::vector<float> result;
for (const CameraRawGradeWheel &wheel : wheels())
for (const double value : {wheel.hue / 360, wheel.saturation / 100, wheel.luminance / 100})
result.push_back(float(value));
return result;
}
CameraRawGradingSettings CameraRawGradingSettings::normalized() const
{
return {shadows.normalized(), midtones.normalized(), highlights.normalized(), global.normalized(), clamp(blending, 0, 100, 50),
clamp(balance, -100, 100, 0)};
}
std::vector<float> CameraRawSettings::foldingColor(std::vector<float> composed) const
{
std::vector<float> table = composed.empty() ? CameraRawTables::identity() : std::move(composed);
const CameraRawCurveSettings shape = curve.normalized();
if (shape.adjusts()) {
const std::vector<float> tone = shape.toneTable(), red = shape.channelTable(shape.red), green = shape.channelTable(shape.green),
blue = shape.channelTable(shape.blue);
camera_raw_table_curves(table.data(), CameraRawTables::grid, tone.data(), red.data(), green.data(), blue.data());
}
std::vector<CameraRawStage> stages = CameraRawTables::mixerStages(mixer.normalized());
const std::vector<CameraRawStage> graded = CameraRawTables::gradingStages(grading.normalized());
stages.insert(stages.end(), graded.begin(), graded.end());
camera_raw_compose_onto(table.data(), CameraRawTables::grid, stages.data(), int(stages.size()), CameraRawTables::size);
return table;
}
void CameraRawSettings::applyCurveColor(uchar *pixels, int width, int height, qsizetype stride, int visualize) const
{
const CameraRawCurveSettings shape = curve.normalized();
const CameraRawMixerSettings mixing = mixer.normalized();
const CameraRawGradingSettings grade = grading.normalized();
const std::vector<float> luma = shape.lumaTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green),
blueTable = shape.channelTable(shape.blue), mixerFloats = mixing.mixerFloats(), pointFloats = mixing.pointFloats(),
gradeFloats = grade.gradeFloats();
adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), luma.data(), redTable.data(), greenTable.data(),
// The eight colours and Color Grading come from Photoshop's tables.
const std::vector<float> tone = shape.toneTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green),
blueTable = shape.channelTable(shape.blue), mixerFloats(24, 0), pointFloats = mixing.pointFloats(), gradeFloats(12, 0);
const std::vector<float> mixerTable = CameraRawTables::compose(CameraRawTables::mixerStages(mixing));
const std::vector<float> gradeTable = CameraRawTables::compose(CameraRawTables::gradingStages(grade));
adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), tone.data(), redTable.data(), greenTable.data(),
blueTable.data(), shape.refineSaturation / 100, mixerFloats.data(), int(mixing.points.size()), pointFloats.data(),
gradeFloats.data(), grade.blending / 100, grade.balance / 100, visualize);
gradeFloats.data(), grade.blending / 100, grade.balance / 100, visualize, mixerTable.empty() ? nullptr : mixerTable.data(),
gradeTable.empty() ? nullptr : gradeTable.data(), CameraRawTables::grid);
}
+8 -4
View File
@@ -33,15 +33,21 @@ struct CameraRawCurveSettings {
static std::vector<CurvePoint> strongContrast();
static bool isLinear(const std::vector<CurvePoint> &points);
bool adjusts() const;
// Lifts or lowers the region a tone belongs to.
// Photoshop's parametric curve: gamma bends, smoothed as Curves.
double parametric(double tone) const;
std::vector<float> lumaTable() const;
// Photoshop's tables draw the parametric curve: dividers as measured.
bool hasMeasuredDividers() const;
// Applied to red, green and blue alike.
std::vector<float> toneTable() const;
std::vector<float> channelTable(const std::vector<CurvePoint> &points) const;
CameraRawCurveSettings nudged(CameraRawPointChannel channel, double tone, double delta) const;
// Swift's region(for:): the parametric amount a tone moves.
double &region(double tone);
CameraRawCurveSettings normalized() const;
friend bool operator==(const CameraRawCurveSettings &, const CameraRawCurveSettings &) = default;
private:
static double bend(double tone, double lower, double low, double upper, double high);
};
// One picked colour and how far its adjustment reaches.
@@ -71,7 +77,6 @@ struct CameraRawMixerSettings {
bool adjusts() const;
// How much each family shares a hue; neighbours overlap.
static std::array<double, 8> weights(double degrees);
std::vector<float> mixerFloats() const;
std::vector<float> pointFloats() const;
CameraRawMixerSettings normalized() const;
friend bool operator==(const CameraRawMixerSettings &, const CameraRawMixerSettings &) = default;
@@ -97,7 +102,6 @@ struct CameraRawGradingSettings {
double balance = 0;
std::array<CameraRawGradeWheel, 4> wheels() const;
bool adjusts() const;
std::vector<float> gradeFloats() const;
CameraRawGradingSettings normalized() const;
friend bool operator==(const CameraRawGradingSettings &, const CameraRawGradingSettings &) = default;
};
+3 -1
View File
@@ -1,5 +1,6 @@
#include "Document/CameraRawDetailOptics.h"
#include "Document/CameraRaw.h"
#include "Document/CameraRawTables.h"
#include "Document/ImageAdjustments.h"
#include <utility>
extern "C" {
@@ -77,7 +78,8 @@ void CameraRawSettings::applyDetailOptics(uchar *pixels, int width, int height,
if (lens.adjusts())
adjust_camera_raw_optics(pixels, w, h, s, lens.removeChromaticAberration ? 1 : 0, lens.enableLensProfile ? 1 : 0, lens.profileDistortion,
lens.profileVignetting, lens.distortionK(profileStrength), lens.purpleAmount, lens.purpleHueLow, lens.purpleHueHigh,
lens.greenAmount, lens.greenHueLow, lens.greenHueHigh, lens.vignetteAmount, lens.vignetteMidpoint, scale);
lens.greenAmount, lens.greenHueLow, lens.greenHueHigh, lens.vignetteAmount, lens.vignetteMidpoint, scale,
CameraRawTables::exposureTables(), CameraRawTables::size);
if (sharpening.adjusts())
adjust_camera_raw_detail(pixels, w, h, s, sharpening.sharpenAmount, sharpening.sharpenRadius, sharpening.sharpenDetail,
sharpening.sharpenMasking, sharpening.noiseLuminance, sharpening.noiseLuminanceDetail,
@@ -192,9 +192,9 @@ CameraRawCalibrationSettings CameraRawCalibrationSettings::normalized() const
void CameraRawSettings::applyCalibration(uchar *pixels, int width, int height, qsizetype stride) const
{
const CameraRawCalibrationSettings primaries = calibration.normalized();
if (!primaries.adjusts())
// The primaries come from Photoshop's tables; this draws Shadows Tint.
if (primaries.shadowTint == 0)
return;
adjust_camera_raw_calibration(pixels, size_t(width), size_t(height), size_t(stride), primaries.shadowTint, primaries.redHue,
primaries.redSaturation, primaries.greenHue, primaries.greenSaturation, primaries.blueHue,
primaries.blueSaturation, int(primaries.process) + 1);
adjust_camera_raw_calibration(pixels, size_t(width), size_t(height), size_t(stride), primaries.shadowTint, 0, 0, 0, 0, 0, 0,
int(primaries.process) + 1);
}
+326
View File
@@ -0,0 +1,326 @@
#include "Document/CameraRawTables.h"
#include "Document/BrushStroke.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QFile>
#include <QPainter>
#include <array>
#include <cmath>
#include <span>
#include <algorithm>
#include <stdexcept>
#include <lzma.h>
namespace CameraRawTables {
namespace {
constexpr size_t entries = size_t(size) * size * size * 3;
// White balance, five sliders, then three adaptive ones.
constexpr int exposureStart = 81, whitesStart = 102, blacksStart = 111, saturationStart = 120, vibranceStart = 129;
constexpr int contrastStart = 138, highlightsStart = 187, shadowsStart = 236;
// Then the Mixer, Calibration, the parametric curve, Color Grading.
constexpr int mixerStart = 285, calibrationStart = 405, parametricStart = 435, gradingStart = 455, gradingLuminanceStart = 743, count = 763;
constexpr std::array<double, 7> brightnesses{53, 80, 104, 130, 160, 190, 224};
constexpr std::array<double, 7> adaptiveValues{-100, -50, -25, 0, 25, 50, 100};
// An xz stream, as Foundation's `.lzma` writes it.
std::vector<uint8_t> unpacked(const QByteArray &packed, size_t expected)
{
std::vector<uint8_t> out(expected);
uint64_t memory = UINT64_MAX;
size_t read = 0, made = 0;
const lzma_ret status = lzma_stream_buffer_decode(&memory, 0, nullptr, reinterpret_cast<const uint8_t *>(packed.constData()), &read,
size_t(packed.size()), out.data(), &made, out.size());
if (status != LZMA_OK || made != expected || read != size_t(packed.size()))
throw std::logic_error("Camera Raw's tables do not unpack to their size");
return out;
}
// Each entry: a running difference from no change, stepped.
std::vector<uint8_t> load()
{
QFile file(QStringLiteral(":/CameraRawTables.bin"));
if (!file.open(QIODevice::ReadOnly))
throw std::logic_error("Camera Raw's tables are missing from the build");
const QByteArray bytes = file.readAll();
const auto field = [&](int at) { return int(uchar(bytes[at])) | int(uchar(bytes[at + 1])) << 8; };
if (bytes.size() <= 10 || !bytes.startsWith("CRT3") || field(4) != size || field(6) != count || field(8) <= 0)
throw std::logic_error("Camera Raw's tables have the wrong header");
const int step = field(8);
const std::vector<uint8_t> planes = unpacked(bytes.mid(10), count * entries);
std::vector<uint8_t> out(count * entries);
const size_t cells = size_t(size) * size * size;
int level[size];
for (int i = 0; i < size; ++i)
level[i] = int(std::lround(i * 255.0 / (size - 1)));
for (size_t table = 0; table < count; ++table) {
for (size_t channel = 0; channel < 3; ++channel) {
const size_t base = (table * 3 + channel) * cells;
uint8_t running = 0;
for (size_t cell = 0; cell < cells; ++cell) {
running = uint8_t(running + planes[base + cell]);
const size_t axis = channel == 0 ? cell / (size * size) : channel == 1 ? (cell / size) % size : cell % size;
out[table * entries + cell * 3 + channel] = uint8_t(std::clamp(level[axis] + int(int8_t(running)) * step, 0, 255));
}
}
}
qCInfo(lcRendering) << "Camera Raw's tables loaded:" << count << "tables";
return out;
}
const uint8_t *table(int index)
{
return tables().data() + size_t(index) * entries;
}
struct Between {
int index;
double fraction;
};
struct Part {
const uint8_t *table;
double weight;
};
// Up to eight tables a stage; null means no change.
CameraRawStage blend(const std::vector<Part> &parts)
{
CameraRawStage stage{};
for (size_t index = 0; index < std::min<size_t>(parts.size(), 8); ++index) {
stage.table[index] = parts[index].table;
stage.weight[index] = float(parts[index].weight);
}
return stage;
}
// The two tables beside `value`, and how far between.
Between between(double value, double low, double step, int tables)
{
const double position = std::min(double(tables - 1), std::max(0.0, (value - low) / step));
const int index = std::min(tables - 2, int(position));
return {index, position - index};
}
// The two of `points` (rising) beside `value`, and how far.
Between between(double value, std::span<const double> points)
{
const double held = std::min(points.back(), std::max(points.front(), value));
int index = 0;
while (index < int(points.size()) - 2 && held > points[index + 1])
++index;
return {index, (held - points[index]) / (points[index + 1] - points[index])};
}
// An adaptive slider: four tables round its value and brightness.
CameraRawStage adaptive(int start, double value, double brightness)
{
const Between row = between(brightness, brightnesses), column = between(value, adaptiveValues);
const double down = row.fraction, across = column.fraction;
const int width = int(adaptiveValues.size()), base = start + row.index * width + column.index;
return blend({{table(base), (1 - down) * (1 - across)}, {table(base + 1), (1 - down) * across}, {table(base + width), down * (1 - across)},
{table(base + width + 1), down * across}});
}
CameraRawStage stage(int start, double value, double low, double step, int tables)
{
const Between at = between(value, low, step, tables);
return blend({{table(start + at.index), 1 - at.fraction}, {table(start + at.index + 1), at.fraction}});
}
CameraRawStage fifty(int start, double value)
{
return stage(start, value, -100, 50, 5);
}
}
const std::vector<uint8_t> &tables()
{
static const std::vector<uint8_t> loaded = load();
return loaded;
}
CameraRawStage whiteBalance(double temperature, double tint)
{
const Between row = between(temperature, -100, 25, 9), column = between(tint, -100, 25, 9);
const double down = row.fraction, across = column.fraction;
const int base = row.index * 9 + column.index;
return blend({{table(base), (1 - down) * (1 - across)}, {table(base + 1), (1 - down) * across}, {table(base + 9), down * (1 - across)},
{table(base + 10), down * across}});
}
const uint8_t *exposureTables()
{
return table(exposureStart);
}
Brightness brightness(const uchar *pixels, int width, int height, qsizetype stride)
{
double out[3] = {0, 0, 0};
camera_raw_statistics(pixels, size_t(width), size_t(height), size_t(stride), out);
return {out[0] * 255, out[1] * 255, out[2] * 255};
}
Brightness brightness(const QImage &image)
{
try {
QImage drawn = BrushRaster::context(image.width(), image.height(), false);
QPainter painter(&drawn);
BrushRaster::draw(image, QRectF(0, 0, image.width(), image.height()), painter);
painter.end();
return brightness(drawn.constBits(), drawn.width(), drawn.height(), drawn.bytesPerLine());
} catch (const ExportError &error) {
qCWarning(lcRendering) << "Camera Raw could not measure the picture's brightness:" << error.what();
return {};
}
}
std::vector<CameraRawStage> stages(const CameraRawSettings &settings, const Brightness &brightness)
{
std::vector<CameraRawStage> out;
const CameraRawCalibrationSettings &calibration = settings.calibration;
const std::array<double, 6> primaries{calibration.redHue, calibration.redSaturation, calibration.greenHue,
calibration.greenSaturation, calibration.blueHue, calibration.blueSaturation};
for (int index = 0; index < 6; ++index)
if (primaries[size_t(index)] != 0)
out.push_back(fifty(calibrationStart + index * 5, primaries[size_t(index)]));
if (settings.exposure != 0)
out.push_back(stage(exposureStart, settings.exposure, -5, 0.5, 21));
if (settings.temperature != 0 || settings.tint != 0)
out.push_back(whiteBalance(settings.temperature, settings.tint));
if (settings.contrast != 0)
out.push_back(adaptive(contrastStart, settings.contrast, brightness.brightest));
if (settings.highlights != 0)
out.push_back(adaptive(highlightsStart, settings.highlights, brightness.logBrightest));
if (settings.shadows != 0)
out.push_back(adaptive(shadowsStart, settings.shadows, brightness.luminance));
if (settings.whites != 0)
out.push_back(stage(whitesStart, settings.whites, -100, 25, 9));
if (settings.blacks != 0)
out.push_back(stage(blacksStart, settings.blacks, -100, 25, 9));
if (settings.saturation != 0)
out.push_back(stage(saturationStart, settings.saturation, -100, 25, 9));
if (settings.vibrance != 0)
out.push_back(stage(vibranceStart, settings.vibrance, -100, 25, 9));
const CameraRawCurveSettings &curve = settings.curve;
if (curve.hasMeasuredDividers()) {
const std::array<double, 4> regions{curve.shadows, curve.darks, curve.lights, curve.highlights};
for (int index = 0; index < 4; ++index)
if (regions[size_t(index)] != 0)
out.push_back(fifty(parametricStart + index * 5, regions[size_t(index)]));
}
return out;
}
std::vector<CameraRawStage> gradingStages(const CameraRawGradingSettings &grading)
{
std::vector<CameraRawStage> out;
const std::array<CameraRawGradeWheel, 4> lumas{grading.shadows, grading.midtones, grading.highlights, grading.global};
for (int index = 0; index < 4; ++index)
if (lumas[size_t(index)].luminance != 0)
out.push_back(fifty(gradingLuminanceStart + index * 5, lumas[size_t(index)].luminance));
const double balance = grading.balance / 100;
const double highlightShare = std::max(0.0, balance < 0 ? 1 + 1.4 * balance : 1 + 2 * balance);
const double shadowShare = std::max(0.0, balance < 0 ? 1 - 1.2 * balance : 1 - 1.4 * balance);
constexpr std::array<double, 3> blendings{0, 50, 100};
constexpr std::array<double, 4> saturations{0, 25, 50, 100};
const Between blended = between(grading.blending, blendings);
// Blending 0 holds two wheels, 50 all four, 100 two.
const auto section = [](int blendIndex, int wheel) { return gradingStart + (blendIndex == 0 ? 0 : blendIndex == 1 ? 72 : 216) + wheel * 36; };
struct Wheel {
int index;
CameraRawGradeWheel color;
double share;
bool blends;
};
for (const Wheel &wheel : {Wheel{3, grading.global, 1, false}, Wheel{2, grading.midtones, 1, false}, Wheel{1, grading.highlights, highlightShare, true},
Wheel{0, grading.shadows, shadowShare, true}}) {
if (!(wheel.color.saturation * wheel.share > 0))
continue;
const double position = std::fmod(wheel.color.hue, 360) / 30;
const int first = int(position) % 12, second = (first + 1) % 12;
const double around = position - int(position);
const Between level = between(wheel.color.saturation * wheel.share, saturations);
std::vector<Part> parts;
const std::vector<std::pair<int, double>> blending = wheel.blends
? std::vector<std::pair<int, double>>{{blended.index, 1 - blended.fraction}, {blended.index + 1, blended.fraction}}
: std::vector<std::pair<int, double>>{{1, 1}};
for (const auto &[blendIndex, blendWeight] : blending) {
const int base = section(blendIndex, wheel.index);
for (const auto &[hue, hueWeight] : {std::pair{first, 1 - around}, std::pair{second, around}})
for (const auto &[saturation, saturationWeight] : {std::pair{level.index, 1 - level.fraction}, std::pair{level.index + 1, level.fraction}})
parts.push_back({saturation == 0 ? nullptr : table(base + hue * 3 + saturation - 1), blendWeight * hueWeight * saturationWeight});
}
out.push_back(blend(parts));
}
return out;
}
std::vector<CameraRawStage> mixerStages(const CameraRawMixerSettings &mixer)
{
std::vector<CameraRawStage> out;
const std::array<const std::array<double, 8> *, 3> kinds{&mixer.luminance, &mixer.saturation, &mixer.hue};
for (int kind = 0; kind < 3; ++kind)
for (int color = 0; color < 8; ++color)
if ((*kinds[size_t(kind)])[size_t(color)] != 0)
out.push_back(fifty(mixerStart + (kind * 8 + color) * 5, (*kinds[size_t(kind)])[size_t(color)]));
return out;
}
namespace {
std::vector<float> composed(const std::vector<CameraRawStage> &stages)
{
std::vector<float> out;
try {
out.resize(size_t(grid) * grid * grid * 3);
} catch (const std::bad_alloc &) {
throw ExportError(ExportError::Kind::render);
}
camera_raw_compose(out.data(), grid, stages.data(), int(stages.size()), size);
return out;
}
}
std::vector<float> identity()
{
return composed({});
}
std::vector<float> compose(const std::vector<CameraRawStage> &stages)
{
return stages.empty() ? std::vector<float>() : composed(stages);
}
std::optional<CameraRawSettings::Balance> neutralize(double red, double green, double blue)
{
if (!(std::min({red, green, blue}) > 1e-4))
return std::nullopt;
const auto cast = [&](double temperature, double tint) {
const CameraRawStage stage = whiteBalance(temperature, tint);
double color[3] = {red, green, blue};
camera_raw_stage_color(&stage, size, color);
return std::max({color[0], color[1], color[2]}) - std::min({color[0], color[1], color[2]});
};
CameraRawSettings::Balance best{0, 0};
double bestCast = cast(0, 0);
for (const auto &[step, reach] : {std::pair{10.0, 100.0}, {2.0, 10.0}, {0.5, 2.0}}) {
const CameraRawSettings::Balance center = best;
const int steps = int(std::lround(2 * reach / step));
for (int i = 0; i <= steps; ++i) {
const double temperature = center.temperature - reach + step * i;
for (int j = 0; j <= steps; ++j) {
const double tint = center.tint - reach + step * j;
if (std::abs(temperature) > 100 || std::abs(tint) > 100)
continue;
const double found = cast(temperature, tint);
if (found < bestCast) {
best = {temperature, tint};
bestCast = found;
}
}
}
}
return best;
}
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include "Document/CameraRaw.h"
extern "C" {
#include "AdjustPixels.h"
}
#include <optional>
#include <vector>
// Photoshop's Light and Color, measured as 17³ tables; some adapt.
namespace CameraRawTables {
constexpr int size = 17;
// The grid the stages compose onto, finer than the tables.
constexpr int grid = 33;
// Every table, red slowest, 3 bytes a colour; loaded once.
const std::vector<uint8_t> &tables();
// Temperature and Tint: the four grid tables around them.
CameraRawStage whiteBalance(double temperature, double tint);
// Exposure's 21 tables in turn, for the lens vignette.
const uint8_t *exposureTables();
Brightness brightness(const uchar *pixels, int width, int height, qsizetype stride);
Brightness brightness(const QImage &image);
// Camera Raw's order, sliders at zero left out.
std::vector<CameraRawStage> stages(const CameraRawSettings &settings, const Brightness &brightness);
// Color Grading's stages: luminance, global, midtones, highlights, shadows.
std::vector<CameraRawStage> gradingStages(const CameraRawGradingSettings &grading);
// The Mixer's stages: luminance, then saturation, then hue.
std::vector<CameraRawStage> mixerStages(const CameraRawMixerSettings &mixer);
// No change, on the composing grid.
std::vector<float> identity();
// The stages over the composing grid; empty without stages.
std::vector<float> compose(const std::vector<CameraRawStage> &stages);
// Temperature and Tint turning a straight sRGB colour gray.
std::optional<CameraRawSettings::Balance> neutralize(double red, double green, double blue);
}
+4
View File
@@ -0,0 +1,4 @@
// Swift's CanvasRotation extension: Image › Rotate Canvas 90°.
public:
// A quarter turn of everything; layers placed, not resampled.
void rotateCanvas(bool clockwise);
+51
View File
@@ -0,0 +1,51 @@
#include "Document/EditorSession.h"
#include <cmath>
LayerTransform LayerTransform::quarterTurned(bool clockwise, QSizeF canvas) const
{
LayerTransform result = *this;
// The middle moves where the turn takes it.
const QPointF middle = clockwise ? QPointF(canvas.height() - center().y(), center().x()) : QPointF(center().y(), canvas.width() - center().x());
result.origin = QPointF(middle.x() - size.width() / 2, middle.y() - size.height() / 2);
result.rotation = std::fmod(rotation + (clockwise ? 90 : -90), 360.0);
return result;
}
CanvasGuide CanvasGuide::quarterTurned(bool clockwise, QSizeF canvas) const
{
CanvasGuide guide = *this;
if (axis == Axis::vertical) {
guide.axis = Axis::horizontal;
if (!clockwise)
guide.position = canvas.width() - position;
} else {
guide.axis = Axis::vertical;
if (clockwise)
guide.position = canvas.height() - position;
}
return guide;
}
void EditorSession::rotateCanvas(bool clockwise)
{
commitTransform();
cancelCrop();
if (!canEditLayers())
return;
const QSizeF canvas = m_document->size();
beginEdit(clockwise ? QStringLiteral("Rotate Canvas 90° Clockwise") : QStringLiteral("Rotate Canvas 90° Counterclockwise"));
for (ImageLayer &layer : m_document->layers) {
layer.transform = layer.transform.quarterTurned(clockwise, canvas);
if (layer.mask && layer.mask->placement)
layer.mask->placement = layer.mask->placement->quarterTurned(clockwise, canvas);
}
std::swap(m_document->width, m_document->height);
for (CanvasGuide &guide : m_document->guides)
guide = guide.quarterTurned(clockwise, canvas);
if (m_document->selection) {
const QTransform turn = clockwise ? QTransform(0, 1, -1, 0, canvas.height(), 0) : QTransform(0, -1, 1, 0, 0, canvas.width());
m_document->selection->path = turn.map(m_document->selection->path);
}
viewport.fit(m_document->size());
endEdit();
}
+2 -1
View File
@@ -1,4 +1,5 @@
#include "Document/CanvasSize.h"
#include "Document/DocumentLimits.h"
#include <cmath>
QString rawValue(CanvasUnit unit)
@@ -24,7 +25,7 @@ CanvasSizeDraft::CanvasSizeDraft(qint64 width, qint64 height, double resolution)
bool CanvasSizeDraft::valid() const
{
// NaN and infinity fail both range tests.
return std::round(width) >= 1 && std::round(width) <= 30'000 && std::round(height) >= 1 && std::round(height) <= 30'000;
return std::round(width) >= 1 && std::round(width) <= DocumentLimits::maxSide && std::round(height) >= 1 && std::round(height) <= DocumentLimits::maxSide;
}
double CanvasSizeDraft::displayed(bool widthAxis) const
+2 -2
View File
@@ -6,5 +6,5 @@ public:
std::optional<QSizeF> cloneStrokeOffset(QPointF point) const;
// The source for a brush at `point`: the canvas's crosshair.
std::optional<QPointF> cloneSamplePoint(QPointF point) const;
// What a stroke copies from, document-sized, taken as it starts.
std::optional<QImage> cloneSample(const CanvasDocument &document) const;
// What `stroke` copies, `offset` away: its pixels or the canvas.
std::optional<BrushStroke::Clone> cloneSample(const CanvasDocument &document, const BrushStroke &stroke, QSizeF offset) const;
+9 -10
View File
@@ -1,7 +1,6 @@
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include "Rendering/LayerRenderer.h"
#include <cmath>
// Swift's CloneStamp extension: the source, its offset, the sample.
@@ -40,20 +39,20 @@ std::optional<QPointF> EditorSession::cloneSamplePoint(QPointF point) const
return point + QPointF(m_cloneOffset->width(), m_cloneOffset->height());
}
std::optional<QImage> EditorSession::cloneSample(const CanvasDocument &document) const
std::optional<BrushStroke::Clone> EditorSession::cloneSample(const CanvasDocument &document, const BrushStroke &stroke, QSizeF offset) const
{
try {
// A painted asset flattens here; the catch covers it too.
if (!m_cloneSettings.sampleAllLayers) {
if (!stroke.layer.asset)
return std::nullopt;
return BrushStroke::Clone{stroke.layer.asset->image(), stroke.gridRect(stroke.sourceRect, offset), true, {}};
}
QImage context = BrushRaster::context(document.width, document.height, false);
QPainter painter(&context);
// A painted asset flattens here; the catch covers it too.
if (m_cloneSettings.sampleAllLayers) {
drawLiveComposite(document, painter);
} else if (const std::optional<ImageLayer> layer = activeLayer(); layer && layer->asset) {
const LayerTransform transform = displayedTransform(*layer);
LayerRenderer::draw(layer->asset->image(), transform, transform.center(), painter, {});
}
drawLiveComposite(document, painter);
painter.end();
return context;
return BrushStroke::Clone{context, QRectF(-offset.width(), -offset.height(), context.width(), context.height()), false, {}};
} catch (const ExportError &error) {
qCWarning(lcApp) << "Clone Stamp cannot sample the canvas:" << error.what();
return std::nullopt;
+19
View File
@@ -18,10 +18,25 @@ public:
void openGradientMapColorPicker(bool highlights);
// The Gradient Map follows the picker's working colour.
void previewGradientMapColor();
// The picker on the open Vignette's colour, which follows it.
void openVignetteColorPicker();
void previewVignetteColor();
// The picker on Dither's dark or light colour.
void openDitherColorPicker(bool light);
void previewDitherColor();
// The picker on an effect of the open panel.
void openEffectColorPicker(LayerEffectKind kind);
// The effect follows the picker's working colour.
void previewEffectColor();
// The picker on a dialog's colour, which hears each change.
void openDialogColorPicker(const QString &title, const PaletteColor &color, std::function<void(const PaletteColor &)> change);
// A dialog covers the canvas: nothing to sample.
bool pickingForDialog() const;
void previewDialogColor() const;
// The open text draft previews the picker's working colour.
void previewTextColor();
// Paints the open text's selected letters, or all of it.
void setDraftTextColor(const PaletteColor &color);
// Swift writes the picker's hsb in place; here, the session.
void setColorPickerHSB(const PickerHSB &hsb);
void sampleIntoColorPicker(QPointF point);
@@ -29,4 +44,8 @@ public:
std::optional<PaletteColor> sampleCompositeColor(QPointF point) const;
private:
// Puts back the open text's colours from the picker's opening.
void restoreDraftTextColors(const LayerTextStyle &original);
void setGradientMapColor(const PaletteColor &color, bool highlights);
void setVignetteColor(const PaletteColor &color);
void setDitherColor(const PaletteColor &color, bool light);
+159 -29
View File
@@ -20,14 +20,8 @@ QString PaletteColor::hex() const
std::optional<PaletteColor> PaletteColor::fromHex(const QString &hex)
{
// Swift trims spaces and tabs, not line breaks.
const auto blank = [](QChar character) { return character.category() == QChar::Separator_Space || character == u'\t'; };
qsizetype start = 0, end = hex.size();
while (start < end && blank(hex.at(start)))
++start;
while (end > start && blank(hex.at(end - 1)))
--end;
QString text = hex.mid(start, end - start);
// Swift trims .whitespaces, never line breaks.
QString text = trimmedWhitespaces(hex);
if (text.startsWith(u'#'))
text.remove(0, 1);
if (text.size() == 3)
@@ -105,6 +99,12 @@ QString ColorPickerTarget::title() const
return highlights ? QStringLiteral("Color Picker (Gradient Map Highlights)") : QStringLiteral("Color Picker (Gradient Map Shadows)");
if (kind == Kind::effect)
return QStringLiteral("Color Picker (%1 Color)").arg(rawValue(effect));
if (kind == Kind::vignette)
return QStringLiteral("Color Picker (Vignette Color)");
if (kind == Kind::dither)
return light ? QStringLiteral("Color Picker (Light Color)") : QStringLiteral("Color Picker (Dark Color)");
if (kind == Kind::dialog)
return QStringLiteral("Color Picker (%1)").arg(dialogTitle);
return background ? QStringLiteral("Color Picker (Background Color)") : QStringLiteral("Color Picker (Foreground Color)");
}
@@ -154,13 +154,8 @@ void EditorSession::setPaletteColor(const PaletteColor &color, bool background)
} else {
setForegroundColor(color);
// Open text follows the swatch; drafts live under Type.
if (m_textDraft) {
changeTextStyle([&color](LayerTextStyle &style) {
style.red = color.red;
style.green = color.green;
style.blue = color.blue;
});
}
if (m_textDraft)
setDraftTextColor(color);
}
}
@@ -193,7 +188,11 @@ void EditorSession::openColorPicker(bool background)
{
if (!canEditPalette() || m_isMaskSelected)
return;
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::palette, background}, paletteColor(background));
ColorPickerState picker({ColorPickerTarget::Kind::palette, background}, paletteColor(background));
// Open text follows the foreground: it previews, Cancel restores.
if (!background && m_tool == NavigationTool::type && m_textDraft)
picker.editedText = ColorPickerState::EditedText{m_textDraft->id, m_textDraft->style};
m_colorPicker = picker;
notify();
}
@@ -201,7 +200,9 @@ PaletteColor EditorSession::typeColor() const
{
if (!m_textDraft)
return foregroundColor();
return {m_textDraft->style.red, m_textDraft->style.green, m_textDraft->style.blue};
// The first selected letter, else the one before the caret.
const TextSpan selection = m_textDraft->selection;
return m_textDraft->style.color(selection.length > 0 ? selection.location : std::max<qint64>(0, selection.location - 1));
}
void EditorSession::openTextColorPicker()
@@ -209,7 +210,10 @@ void EditorSession::openTextColorPicker()
if (!canEditPalette() || m_colorPicker || m_tool != NavigationTool::type)
return;
const std::optional<QUuid> draftID = m_textDraft ? std::optional(m_textDraft->id) : std::nullopt;
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::text, false, draftID}, typeColor());
ColorPickerState picker({ColorPickerTarget::Kind::text, false, draftID}, typeColor());
if (m_textDraft)
picker.editedText = ColorPickerState::EditedText{m_textDraft->id, m_textDraft->style};
m_colorPicker = picker;
notify();
}
@@ -223,28 +227,41 @@ void EditorSession::closeColorPicker(bool commit)
const std::optional<QUuid> draftID = m_textDraft ? std::optional(m_textDraft->id) : std::nullopt;
if (commit && picker.target.kind == ColorPickerTarget::Kind::palette && !m_isMaskSelected) {
setPaletteColor(color, picker.target.background);
} else if (commit && picker.target.kind == ColorPickerTarget::Kind::text && m_tool == NavigationTool::type && draftID == picker.target.draftID) {
} else if (!commit && picker.target.kind == ColorPickerTarget::Kind::palette && picker.editedText && m_tool == NavigationTool::type
&& draftID == picker.editedText->draftID) {
restoreDraftTextColors(picker.editedText->style);
} else if (picker.target.kind == ColorPickerTarget::Kind::text && m_tool == NavigationTool::type && draftID == picker.target.draftID) {
// The text previewed the working colour; Cancel puts it back.
const PaletteColor chosen = commit ? color : picker.original;
if (draftID) {
changeTextStyle([&color](LayerTextStyle &style) {
style.red = color.red;
style.green = color.green;
style.blue = color.blue;
});
} else {
m_textDefaults.red = color.red;
m_textDefaults.green = color.green;
m_textDefaults.blue = color.blue;
if (commit)
setDraftTextColor(chosen);
else if (picker.editedText)
restoreDraftTextColors(picker.editedText->style);
} else if (commit) {
m_textDefaults.red = chosen.red;
m_textDefaults.green = chosen.green;
m_textDefaults.blue = chosen.blue;
}
// The text colour is the foreground: the swatch follows.
if (!m_isMaskSelected)
if (commit && !m_isMaskSelected)
setForegroundColor(color);
} else if (picker.target.kind == ColorPickerTarget::Kind::gradientMap) {
// The end previewed the working colour; Cancel puts it back.
setGradientMapColor(commit ? color : picker.original, picker.target.highlights);
} else if (picker.target.kind == ColorPickerTarget::Kind::vignette) {
setVignetteColor(commit ? color : picker.original);
} else if (picker.target.kind == ColorPickerTarget::Kind::dither) {
setDitherColor(commit ? color : picker.original, picker.target.light);
} else if (picker.target.kind == ColorPickerTarget::Kind::dialog) {
picker.dialogChange(commit ? color : picker.original);
} else if (picker.target.kind == ColorPickerTarget::Kind::effect) {
const PaletteColor chosen = commit ? color : picker.original;
changeEffects([&](LayerEffects &effects) { effects.setColor(chosen, picker.target.effect); });
}
// Sampling took the keys from open text: they go back.
if (m_textDraft)
++m_canvasFocusRequest;
notify();
}
@@ -258,6 +275,48 @@ void EditorSession::openGradientMapColorPicker(bool highlights)
notify();
}
void EditorSession::openVignetteColorPicker()
{
// A commit keeps the project busy: no `committing` term.
if (!canEditPalette() || m_colorPicker || !m_filterEdit || m_filterEdit->kind != FilterKind::vignette)
return;
const AdjustmentColor value = m_filterEdit->settings.vignetteColor;
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::vignette}, PaletteColor{value.red, value.green, value.blue});
notify();
}
void EditorSession::openDitherColorPicker(bool light)
{
// A commit keeps the project busy: no `committing` term.
if (!canEditPalette() || m_colorPicker || !m_filterEdit || (m_filterEdit->kind != FilterKind::dither && m_filterEdit->kind != FilterKind::scanlines))
return;
const FilterSettings &settings = m_filterEdit->settings;
const bool scanlines = m_filterEdit->kind == FilterKind::scanlines;
const AdjustmentColor value = light ? (scanlines ? settings.scanlines.light : settings.dither.light) : (scanlines ? settings.scanlines.dark : settings.dither.dark);
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::dither, false, std::nullopt, false, LayerEffectKind{}, light},
PaletteColor{value.red, value.green, value.blue});
notify();
}
void EditorSession::previewDitherColor()
{
if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::dither)
setDitherColor(m_colorPicker->color(), m_colorPicker->target.light);
}
// Its picker lives only while Dither or Scanlines is open.
void EditorSession::setDitherColor(const PaletteColor &color, bool light)
{
FilterSettings settings = m_filterEdit.value().settings;
if (m_filterEdit->kind == FilterKind::scanlines)
(light ? settings.scanlines.light : settings.scanlines.dark) = AdjustmentColor(color);
else
(light ? settings.dither.light : settings.dither.dark) = AdjustmentColor(color);
if (settings == m_filterEdit->settings)
return;
updateFilter(settings, m_filterEdit->preview);
}
void EditorSession::openEffectColorPicker(LayerEffectKind kind)
{
if (!canEditPalette() || m_colorPicker || !m_effectsEditing)
@@ -266,6 +325,38 @@ void EditorSession::openEffectColorPicker(LayerEffectKind kind)
notify();
}
void EditorSession::previewTextColor()
{
if (!m_colorPicker)
return;
const ColorPickerTarget &target = m_colorPicker->target;
const std::optional<QUuid> draftID = target.kind == ColorPickerTarget::Kind::text ? target.draftID
: target.kind == ColorPickerTarget::Kind::palette && m_colorPicker->editedText
? std::optional(m_colorPicker->editedText->draftID)
: std::nullopt;
if (draftID && m_tool == NavigationTool::type && m_textDraft && m_textDraft->id == *draftID)
setDraftTextColor(m_colorPicker->color());
}
void EditorSession::setDraftTextColor(const PaletteColor &color)
{
if (!m_textDraft)
return;
const TextSpan selection = m_textDraft->selection;
changeTextStyle([&](LayerTextStyle &style) { style.setColor(color, selection); });
}
void EditorSession::restoreDraftTextColors(const LayerTextStyle &original)
{
changeTextStyle([&original](LayerTextStyle &style) {
style.red = original.red;
style.green = original.green;
style.blue = original.blue;
// Retyped since, the runs cannot come back letter for letter.
style.colorRuns = original.content == style.content ? original.colorRuns : std::nullopt;
});
}
void EditorSession::previewEffectColor()
{
if (!m_colorPicker || m_colorPicker->target.kind != ColorPickerTarget::Kind::effect)
@@ -281,6 +372,22 @@ void EditorSession::previewGradientMapColor()
setGradientMapColor(m_colorPicker->color(), m_colorPicker->target.highlights);
}
void EditorSession::previewVignetteColor()
{
if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::vignette)
setVignetteColor(m_colorPicker->color());
}
// Its picker lives only while its Vignette edit is open.
void EditorSession::setVignetteColor(const PaletteColor &color)
{
FilterSettings settings = m_filterEdit.value().settings;
settings.vignetteColor = AdjustmentColor(color);
if (settings == m_filterEdit->settings)
return;
updateFilter(settings, m_filterEdit->preview);
}
void EditorSession::setGradientMapColor(const PaletteColor &color, bool highlights)
{
// An adjustment's editor can close under its picker (9.5c).
@@ -336,3 +443,26 @@ std::optional<PaletteColor> EditorSession::sampleCompositeColor(QPointF point) c
return std::nullopt;
}
}
void EditorSession::openDialogColorPicker(const QString &title, const PaletteColor &color, std::function<void(const PaletteColor &)> change)
{
if (m_colorPicker)
return;
ColorPickerTarget target{ColorPickerTarget::Kind::dialog};
target.dialogTitle = title;
m_colorPicker.emplace(target, color);
m_colorPicker->dialogChange = std::move(change);
notify();
}
bool EditorSession::pickingForDialog() const
{
return m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::dialog;
}
// The dialog follows the working colour; the session keeps nothing.
void EditorSession::previewDialogColor() const
{
if (pickingForDialog())
m_colorPicker->dialogChange(m_colorPicker->color());
}
+16 -2
View File
@@ -1,7 +1,9 @@
#pragma once
#include "Document/TypeTool.h"
#include <QColor>
#include <QString>
#include <QUuid>
#include <functional>
#include <optional>
// Swift's PaletteColor: an sRGB colour without alpha.
@@ -35,9 +37,9 @@ struct PickerHSB {
enum class LayerEffectKind;
// The picker's targets: palette, text, effect, Gradient Map end.
// The picker's targets: palette, text, effect, a filter's colour.
struct ColorPickerTarget {
enum class Kind { palette, text, gradientMap, effect };
enum class Kind { palette, text, gradientMap, effect, vignette, dither, dialog };
Kind kind;
bool background = false;
std::optional<QUuid> draftID = std::nullopt;
@@ -45,6 +47,10 @@ struct ColorPickerTarget {
bool highlights = false;
// An effect's colour: which effect.
LayerEffectKind effect{};
// Dither's or Scanlines' Two Colors: light, else dark.
bool light = false;
// A dialog's own colour, such as Export JPEG's background.
QString dialogTitle{};
QString title() const;
friend bool operator==(const ColorPickerTarget &, const ColorPickerTarget &) = default;
};
@@ -57,5 +63,13 @@ struct ColorPickerState {
PickerHSB hsb;
// Swift's object identity: a new picker opens its panel anew.
QUuid id = QUuid::createUuid();
// The picker over open text: that text, its colours then.
struct EditedText {
QUuid draftID;
LayerTextStyle style;
};
std::optional<EditedText> editedText;
// A dialog's colour: told as it moves, then on closing.
std::function<void(const PaletteColor &)> dialogChange;
PaletteColor color() const { return hsb.rgb().quantized(); }
};
@@ -0,0 +1,15 @@
// Select › Color Range's panel; its selection shows until OK.
const std::optional<ColorRangeEdit> &colorRange() const { return m_colorRange; }
bool canSelectColorRange() const;
void beginColorRange();
// A canvas click: Shift adds, Alt takes away.
void sampleColorRange(QPointF point, bool shift, bool option);
// Matches off the UI thread; a newer change supersedes it.
void updateColorRange();
void commitColorRange();
void cancelColorRange();
// The sheet's and the canvas's writes, Swift's edit fields.
void setColorRangeSampleMode(HueSampleMode mode);
void setColorRangeHeld(std::optional<HueSampleMode> held);
void setColorRangeFuzziness(double fuzziness);
void setColorRangeInvert(bool invert);
+235
View File
@@ -0,0 +1,235 @@
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QtConcurrent>
#include <algorithm>
#include <cmath>
extern "C" {
#include "WandPixels.h"
}
// Swift's ColorRangeSelection.swift: the session's half.
namespace {
struct ColorRangeJob {
QImage image;
std::vector<uchar> include;
std::vector<uchar> exclude;
int fuzziness;
bool invert;
};
struct ColorRangeResult {
std::optional<QPainterPath> path;
QImage preview;
std::optional<QString> error;
};
// The straight colour under `point`, over 3 × 3 pixels.
std::optional<std::vector<uchar>> colorAt(const QImage &image, QPointF point)
{
if (!std::isfinite(point.x()) || !std::isfinite(point.y()))
return std::nullopt;
const double fx = std::floor(point.x()), fy = std::floor(point.y());
if (fx < 0 || fy < 0 || fx >= image.width() || fy >= image.height())
return std::nullopt;
const int x = int(fx), y = int(fy);
std::array<int, 4> sums{};
// Past the picture's edge, Swift's context holds clear pixels.
for (int row = y - 1; row <= y + 1; ++row)
for (int column = x - 1; column <= x + 1; ++column) {
if (row < 0 || column < 0 || row >= image.height() || column >= image.width())
continue;
const uchar *pixel = image.constScanLine(row) + column * 4;
for (int channel = 0; channel < 4; ++channel)
sums[size_t(channel)] += pixel[channel];
}
if (sums[3] <= 0)
return std::nullopt;
std::vector<uchar> color(3);
for (int channel = 0; channel < 3; ++channel)
color[size_t(channel)] = uchar(std::min(255, (sums[size_t(channel)] * 255 + sums[3] / 2) / sums[3]));
return color;
}
std::vector<uchar> colorRangeMask(const ColorRangeJob &job)
{
const int width = job.image.width(), height = job.image.height();
try {
std::vector<uchar> mask(size_t(width) * size_t(height));
color_range_mask(job.image.constBits(), size_t(width), size_t(height), size_t(job.image.bytesPerLine()), job.include.data(),
int(job.include.size() / 3), job.exclude.data(), int(job.exclude.size() / 3), job.fuzziness, job.invert ? 1 : 0,
mask.data());
return mask;
} catch (const std::bad_alloc &) {
throw ExportError(ExportError::Kind::render);
}
}
// The mask shrunk to the panel's preview, twice its size.
QImage preview(const std::vector<uchar> &mask, int width, int height)
{
const double scale = std::min(ColorRangeEdit::previewSize.width() / width, ColorRangeEdit::previewSize.height() / height) * 2;
const int w = std::max(1, int(width * scale)), h = std::max(1, int(height * scale));
QImage full(width, height, QImage::Format_Grayscale8);
if (full.isNull())
throw ExportError(ExportError::Kind::render);
for (int y = 0; y < height; ++y)
std::copy_n(mask.data() + size_t(y) * size_t(width), width, full.scanLine(y));
const QImage shrunk = full.scaled(w, h, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
if (shrunk.isNull())
throw ExportError(ExportError::Kind::render);
return shrunk;
}
ColorRangeResult colorRangeResult(const ColorRangeJob &job)
{
ColorRangeResult result;
try {
const std::vector<uchar> mask = colorRangeMask(job);
result.preview = preview(mask, job.image.width(), job.image.height());
if (std::any_of(mask.begin(), mask.end(), [](uchar value) { return value != 0; }))
result.path = MagicWand::outline(mask, job.image.width(), job.image.height());
} catch (const MagicWandError &error) {
result.error = QString::fromUtf8(error.what());
} catch (const ExportError &error) {
result.error = QString::fromUtf8(error.what());
}
return result;
}
}
bool EditorSession::canSelectColorRange() const
{
return canEditSelection();
}
void EditorSession::beginColorRange()
{
if (!canSelectColorRange())
return;
const std::optional<QImage> image = selectionSample(*m_document, true);
if (!image)
return;
m_colorRange.emplace(*image, m_document->selection);
notify();
}
void EditorSession::sampleColorRange(QPointF point, bool shift, bool option)
{
if (!m_colorRange)
return;
ColorRangeEdit &edit = m_colorRange.value();
const std::optional<std::vector<uchar>> color = colorAt(edit.image, point);
if (!color)
return;
switch (option ? HueSampleMode::remove : shift ? HueSampleMode::add : edit.sampleMode) {
case HueSampleMode::replace:
edit.include = *color;
edit.exclude.clear();
break;
case HueSampleMode::add:
edit.include.insert(edit.include.end(), color->begin(), color->end());
break;
case HueSampleMode::remove:
edit.exclude.insert(edit.exclude.end(), color->begin(), color->end());
break;
}
updateColorRange();
}
void EditorSession::updateColorRange()
{
if (!m_colorRange || !m_document)
return;
ColorRangeEdit &edit = m_colorRange.value();
const int generation = ++edit.generation;
if (!edit.hasColors()) {
m_document->selection = edit.original;
edit.preview = QImage();
notify();
return;
}
const ColorRangeJob job{edit.image, edit.include, edit.exclude, int(std::round(edit.fuzziness)), edit.invert};
// A watcher a change, as Swift starts a task each.
auto *watcher = new QFutureWatcher<ColorRangeResult>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, id = edit.id, generation, document = m_document->id] {
watcher->deleteLater();
const ColorRangeResult result = watcher->result();
if (!m_colorRange || m_colorRange->id != id || m_colorRange->generation != generation || !m_document || m_document->id != document)
return;
m_colorRange->error = result.error;
m_colorRange->preview = result.preview;
if (result.error) {
qCWarning(lcApp).noquote() << "Color Range could not select:" << *result.error;
} else {
m_document->selection = result.path ? std::optional(DocumentSelection{*result.path, m_selectionAntialiased}) : std::nullopt;
}
notify();
});
watcher->setFuture(QtConcurrent::run([job] { return colorRangeResult(job); }));
// The colours changed now; their match lands later.
notify();
}
void EditorSession::commitColorRange()
{
if (!m_colorRange)
return;
const std::optional<DocumentSelection> result = m_document->selection;
const ColorRangeEdit edit = std::exchange(m_colorRange, std::nullopt).value();
m_document->selection = edit.original;
resumeFileRequests();
if (edit.hasColors() && !edit.error) {
if (result)
setSelection(result, QStringLiteral("Color Range"));
else
deselect();
}
// Closing is news even when the selection stays.
notify();
}
void EditorSession::cancelColorRange()
{
if (!m_colorRange)
return;
m_document->selection = m_colorRange->original;
m_colorRange.reset();
resumeFileRequests();
notify();
}
void EditorSession::setColorRangeSampleMode(HueSampleMode mode)
{
if (!m_colorRange || m_colorRange->sampleMode == mode)
return;
m_colorRange->sampleMode = mode;
notify();
}
void EditorSession::setColorRangeHeld(std::optional<HueSampleMode> held)
{
if (!m_colorRange || m_colorRange->held == held)
return;
m_colorRange->held = held;
notify();
}
void EditorSession::setColorRangeFuzziness(double fuzziness)
{
// Swift's binding: whole, within the range, changed or nothing.
const double clamped = std::min(ColorRangeEdit::fuzzinessHigh, std::max(ColorRangeEdit::fuzzinessLow, std::round(fuzziness)));
if (!m_colorRange || m_colorRange->fuzziness == clamped)
return;
m_colorRange->fuzziness = clamped;
updateColorRange();
}
void EditorSession::setColorRangeInvert(bool invert)
{
if (!m_colorRange)
return;
m_colorRange->invert = invert;
updateColorRange();
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include "Document/HueSaturation.h"
#include "Document/Selection.h"
#include <QImage>
#include <QSizeF>
#include <QUuid>
#include <optional>
#include <vector>
// Select › Color Range: pixels near the clicked colours, anywhere.
class ColorRangeEdit {
public:
static constexpr double fuzzinessLow = 0, fuzzinessHigh = 200;
// The panel's preview fits in this, in points.
static constexpr QSizeF previewSize{292, 200};
ColorRangeEdit(QImage image, std::optional<DocumentSelection> original) : image(std::move(image)), original(std::move(original)) {}
double fuzziness = 40;
bool invert = false;
// The next click: start over, add or take away.
HueSampleMode sampleMode = HueSampleMode::replace;
// Shift (add) or Alt (take away) held, over the mode.
std::optional<HueSampleMode> held;
HueSampleMode effectiveMode() const { return held.value_or(sampleMode); }
// The selection in black and white; null until a colour.
QImage preview;
// Straight sRGB colours, three bytes each.
std::vector<uchar> include;
std::vector<uchar> exclude;
std::optional<QString> error;
// The composite at document size: what colours are matched against.
QImage image;
std::optional<DocumentSelection> original;
int generation = 0;
// Swift's object identity: a result lands on its own edit.
QUuid id = QUuid::createUuid();
bool hasColors() const { return !include.empty(); }
};
+7
View File
@@ -2,7 +2,14 @@
public:
SnapGuides transformSnapTargets(const QSet<QUuid> &moving) const;
LayerTransform snappedMove(const LayerTransform &draft, const QSet<QUuid> &moving, double tolerance);
// A resize handle's pointer, nudged so moved edges snap.
QPointF snappedResizePoint(QPointF point, const TransformDrag &drag, bool proportional, const QSet<QUuid> &moving,
double tolerance, const std::function<LayerTransform(QPointF)> &update);
SnapGuides cropSnapTargets() const;
// A drawn corner, each axis to a target within reach.
QPointF snappedPoint(QPointF point, double tolerance);
// A moved selection's edges or middle; a locked axis stays.
QSizeF snappedSelectionOffset(QSizeF offset, double tolerance, bool horizontal = true, bool vertical = true);
// The Crop tool's frame: the canvas until one is drawn.
std::optional<QRectF> visibleCropRect() const;
std::optional<double> cropRatio() const;
+94 -2
View File
@@ -1,4 +1,5 @@
#include "Document/Crop.h"
#include "Document/DocumentLimits.h"
#include "Document/Distort.h"
#include "Document/EditorSession.h"
#include "IO/CanvasResizer.h"
@@ -19,7 +20,7 @@ QRectF CropGeometry::snapped(const QRectF &rect)
bool CropGeometry::valid(const QRectF &rect)
{
const QRectF standard = rect.normalized();
return standard.width() >= 1 && standard.width() <= 30'000 && standard.height() >= 1 && standard.height() <= 30'000
return standard.width() >= 1 && standard.width() <= DocumentLimits::maxSide && standard.height() >= 1 && standard.height() <= DocumentLimits::maxSide
&& std::abs(standard.left()) <= 1'000'000 && std::abs(standard.top()) <= 1'000'000;
}
@@ -153,7 +154,7 @@ SnapGuides EditorSession::transformSnapTargets(const QSet<QUuid> &moving) const
// `draft` nudged onto a nearby edge or middle, within `tolerance`.
LayerTransform EditorSession::snappedMove(const LayerTransform &draft, const QSet<QUuid> &moving, double tolerance)
{
if (!m_snappingEnabled) {
if (!snappingEnabled()) {
snapGuides = {};
return draft;
}
@@ -167,12 +168,99 @@ LayerTransform EditorSession::snappedMove(const LayerTransform &draft, const QSe
return snapped;
}
// A resize handle's point, nudged onto targets; upright only.
QPointF EditorSession::snappedResizePoint(QPointF point, const TransformDrag &drag, bool proportional, const QSet<QUuid> &moving,
double tolerance, const std::function<LayerTransform(QPointF)> &update)
{
if (!snappingEnabled() || drag.mode.kind != TransformDrag::Kind::resize || drag.original.radians() != 0) {
snapGuides = {};
return point;
}
const QPointF handle = LayerTransform::handles[size_t(drag.mode.index)];
const SnapGuides targets = transformSnapTargets(moving);
// The dragged handle's place tells its edge apart.
const QPointF at = drag.original.point(handle) + point - drag.start;
const auto edge = [at](const LayerTransform &transform, bool horizontal) {
const QRectF box(transform.origin, transform.size);
if (horizontal)
return std::abs(box.left() - at.x()) <= std::abs(box.right() - at.x()) ? box.left() : box.right();
return std::abs(box.top() - at.y()) <= std::abs(box.bottom() - at.y()) ? box.top() : box.bottom();
};
struct Snap {
bool horizontal;
double target;
};
const LayerTransform draft = update(point);
std::vector<Snap> snaps;
if (handle.x() != 0.5)
if (const std::optional<double> x = nearest(edge(draft, true), targets.xs, tolerance))
snaps.push_back({true, *x});
if (handle.y() != 0.5)
if (const std::optional<double> y = nearest(edge(draft, false), targets.ys, tolerance))
snaps.push_back({false, *y});
// Proportional, the nearer edge snaps; the ratio moves the other.
if (proportional && snaps.size() == 2) {
const auto distance = [&](const Snap &snap) { return std::abs(snap.target - edge(draft, snap.horizontal)); };
snaps = {distance(snaps[1]) < distance(snaps[0]) ? snaps[1] : snaps[0]};
}
// Edges follow the pointer linearly: one pixel measures the step.
QPointF result = point;
for (const Snap &snap : snaps) {
const double before = edge(update(result), snap.horizontal);
const QPointF nudged = result + (snap.horizontal ? QPointF(1, 0) : QPointF(0, 1));
const double perPixel = edge(update(nudged), snap.horizontal) - before;
if (std::abs(perPixel) <= 0.01)
continue;
const double shift = (snap.target - before) / perPixel;
result += snap.horizontal ? QPointF(shift, 0) : QPointF(0, shift);
}
snapGuides = {};
for (const Snap &snap : snaps)
(snap.horizontal ? snapGuides.xs : snapGuides.ys).push_back(snap.target);
return result;
}
// View > Snap To's targets, without the centres.
SnapGuides EditorSession::cropSnapTargets() const
{
return alignmentSnapTargets({}, false);
}
QPointF EditorSession::snappedPoint(QPointF point, double tolerance)
{
if (!snappingEnabled()) {
snapGuides = {};
return point;
}
const SnapGuides targets = cropSnapTargets();
// The first of the nearest, as Swift's `min`.
const auto nearest = [tolerance](double value, const std::vector<double> &lines) {
std::optional<double> best;
for (const double line : lines)
if (std::abs(line - value) <= tolerance && (!best || std::abs(line - value) < std::abs(*best - value)))
best = line;
return best;
};
const std::optional<double> x = nearest(point.x(), targets.xs), y = nearest(point.y(), targets.ys);
snapGuides = {x ? std::vector<double>{*x} : std::vector<double>{}, y ? std::vector<double>{*y} : std::vector<double>{}};
return QPointF(x.value_or(point.x()), y.value_or(point.y()));
}
QSizeF EditorSession::snappedSelectionOffset(QSizeF offset, double tolerance, bool horizontal, bool vertical)
{
if (!snappingEnabled() || !m_selectionMoveOrigin) {
snapGuides = {};
return offset;
}
const QSizeF whole(std::round(offset.width()), std::round(offset.height()));
const QRectF box = m_selectionMoveOrigin->path.boundingRect().translated(whole.width(), whole.height());
const SnapGuides targets = cropSnapTargets();
const TransformSnap::Offset snap =
TransformSnap::offset(box, horizontal ? targets.xs : std::vector<double>{}, vertical ? targets.ys : std::vector<double>{}, tolerance);
snapGuides = {snap.x ? std::vector<double>{*snap.x} : std::vector<double>{}, snap.y ? std::vector<double>{*snap.y} : std::vector<double>{}};
return whole + snap.offset;
}
std::optional<QRectF> EditorSession::visibleCropRect() const
{
if (m_tool != NavigationTool::crop || !m_document)
@@ -188,8 +276,12 @@ std::optional<double> EditorSession::cropRatio() const
return 1;
if (m_cropRatioChoice == QStringLiteral("4:3"))
return 4.0 / 3;
if (m_cropRatioChoice == QStringLiteral("3:4"))
return 3.0 / 4;
if (m_cropRatioChoice == QStringLiteral("16:9"))
return 16.0 / 9;
if (m_cropRatioChoice == QStringLiteral("9:16"))
return 9.0 / 16;
return std::nullopt;
}
+3 -2
View File
@@ -1,4 +1,5 @@
#include "Document/Distort.h"
#include "Document/DocumentLimits.h"
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "Document/LayerEffects+Renderer.h"
@@ -174,7 +175,7 @@ DistortWarp::Warped DistortWarp::warp(const QImage &image, const LayerTransform
if (!isUsable(corners))
throw ProjectError(ProjectError::Kind::invalid);
const QRectF bounds = shapeBounds(corners);
if (bounds.width() < 1 || bounds.height() < 1 || bounds.width() > 30'000 || bounds.height() > 30'000 || bounds.width() * bounds.height() > 100'000'000)
if (bounds.width() < 1 || bounds.height() < 1 || bounds.width() > DocumentLimits::maxSide || bounds.height() > DocumentLimits::maxSide || bounds.width() * bounds.height() > DocumentLimits::maxSurfacePixels)
throw ProjectError(ProjectError::Kind::tooLarge);
const LayerTransform placed{.origin = bounds.topLeft(), .size = bounds.size(), .sampling = transform.sampling};
// A uniform 1 × 1 mask already covers any shape.
@@ -221,7 +222,7 @@ Corners DistortWarp::carried(const LayerTransform &placement, const LayerTransfo
{
QTransform toDocument;
toDocument.translate(transform.center().x(), transform.center().y());
toDocument.rotateRadians(transform.radians());
toDocument.rotate(transform.degrees());
toDocument.scale(transform.size.width(), transform.size.height());
toDocument.translate(-0.5, -0.5);
const QTransform toUnit = toDocument.inverted();
+258
View File
@@ -0,0 +1,258 @@
#include "Document/Dither.h"
#include "Document/BrushStroke.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "Rendering/PoolMap.h"
#include <QFontMetricsF>
#include <QPainter>
#include <QPainterPath>
#include <QTextBoundaryFinder>
#include <algorithm>
#include <cmath>
#include <numeric>
#include <set>
extern "C" {
#include "DitherPixels.h"
}
// The kernel's bands, where Swift's kernel calls dispatch_apply.
extern "C" void dither_bands(size_t bands, void (*band)(void *context, size_t index), void *context)
{
std::vector<size_t> indices(bands);
std::iota(indices.begin(), indices.end(), size_t(0));
PoolMap::blocking(indices, [band, context](size_t index) { band(context, index); });
}
// Swift's Dither.swift: the settings, the chunky pixels, the glyphs.
namespace {
// Swift's Characters: the text's graphemes.
QStringList graphemes(const QString &text)
{
QStringList found;
QTextBoundaryFinder finder(QTextBoundaryFinder::Grapheme, text);
qsizetype start = 0;
while (finder.toNextBoundary() >= 0) {
found << text.mid(start, finder.position() - start);
start = finder.position();
}
return found;
}
// Swift's Character.isNewline.
bool isNewline(const QString &grapheme)
{
static const QString breaks = QStringLiteral("\n\r\u000B\u000C\u0085

");
return grapheme == QStringLiteral("\r\n") || (grapheme.size() == 1 && breaks.contains(grapheme.front()));
}
std::array<uint8_t, 3> bytes(const AdjustmentColor &color)
{
return {uint8_t(std::lround(color.red * 255)), uint8_t(std::lround(color.green * 255)), uint8_t(std::lround(color.blue * 255))};
}
}
QString rawValue(DitherStyle style)
{
switch (style) {
case DitherStyle::atkinson: return QStringLiteral("Atkinson (Classic Mac)");
case DitherStyle::floydSteinberg: return QStringLiteral("Floyd–Steinberg");
case DitherStyle::bayer2: return QStringLiteral("Bayer 2 × 2");
case DitherStyle::bayer4: return QStringLiteral("Bayer 4 × 4");
case DitherStyle::bayer8: return QStringLiteral("Bayer 8 × 8");
case DitherStyle::dots: return QStringLiteral("Halftone Dots");
case DitherStyle::lines: return QStringLiteral("Halftone Lines");
case DitherStyle::diamonds: return QStringLiteral("Halftone Diamonds");
case DitherStyle::patterns: return QStringLiteral("Mac Patterns");
case DitherStyle::ascii: return QStringLiteral("ASCII");
}
throw std::logic_error("unknown dither style");
}
int ditherGroup(DitherStyle style)
{
const int index = int(style);
return index < 2 ? 0 : index < 5 ? 1 : index < 8 ? 2 : 3;
}
bool diffuses(DitherStyle style)
{
return ditherGroup(style) == 0;
}
bool hasTones(DitherStyle style)
{
return ditherGroup(style) < 2;
}
bool isHalftone(DitherStyle style)
{
return ditherGroup(style) == 2;
}
bool drawsMarks(DitherStyle style)
{
return !hasTones(style);
}
bool usesPixelSize(DitherStyle style)
{
return style != DitherStyle::ascii;
}
QString rawValue(DitherPixelShape shape)
{
return shape == DitherPixelShape::square ? QStringLiteral("Square") : QStringLiteral("Dot");
}
QString rawValue(DitherColors colors)
{
switch (colors) {
case DitherColors::blackWhite: return QStringLiteral("Black & White");
case DitherColors::twoColors: return QStringLiteral("Two Colors");
case DitherColors::original: return QStringLiteral("Original");
}
throw std::logic_error("unknown dither colours");
}
DitherSettings DitherSettings::normalized() const
{
using ImageAdjustmentPixels::clamp;
DitherSettings result = *this;
result.pixelSize = std::round(clamp(pixelSize, pixelSizeLow, pixelSizeHigh, 2));
result.cellSize = std::round(clamp(cellSize, cellSizeLow, cellSizeHigh, 8));
result.textSize = std::round(clamp(textSize, textSizeLow, textSizeHigh, 14));
result.angle = clamp(angle, -90, 90, 45);
result.levels = std::round(clamp(levels, levelsLow, levelsHigh, 2));
result.diffusion = clamp(diffusion, 0, 100, 100);
result.density = clamp(density, -100, 100, 0);
result.contrast = clamp(contrast, -100, 100, 0);
result.dark = dark.clamped();
result.light = light.clamped();
// Line breaks go; 64 characters at most.
QStringList kept;
for (const QString &grapheme : graphemes(characters)) {
if (!isNewline(grapheme) && kept.size() < 64)
kept << grapheme;
}
result.characters = kept.join(QString());
return result;
}
QImage DitherSettings::apply(const QImage &image) const
{
const DitherSettings settings = normalized();
// ASCII draws full size: chunks would break its characters.
const int block = usesPixelSize(settings.style) ? int(settings.pixelSize) : 1;
const QImage source = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
if (source.isNull())
throw ExportError(ExportError::Kind::render);
const int width = source.width(), height = source.height();
QImage working = source;
if (block > 1) {
// Chunky pixels: each block's area averaged, then dithered small.
working = BrushRaster::context((width + block - 1) / block, (height + block - 1) / block, false);
const int area = block * block;
for (int y = 0; y < working.height(); ++y) {
uchar *out = working.scanLine(y);
for (int x = 0; x < working.width(); ++x) {
std::array<int, 4> sum{};
for (int sy = y * block; sy < std::min(height, (y + 1) * block); ++sy) {
const uchar *row = source.constScanLine(sy);
for (int sx = x * block; sx < std::min(width, (x + 1) * block); ++sx) {
for (int channel = 0; channel < 4; ++channel)
sum[size_t(channel)] += row[sx * 4 + channel];
}
}
for (int channel = 0; channel < 4; ++channel)
out[x * 4 + channel] = uchar((sum[size_t(channel)] + area / 2) / area);
}
}
}
const QImage dithered = settings.dither(working);
if (block <= 1)
return dithered;
// Blown back up without smoothing.
QImage full = BrushRaster::context(width, height, false);
for (int y = 0; y < height; ++y) {
const uchar *row = dithered.constScanLine(y / block);
uchar *out = full.scanLine(y);
for (int x = 0; x < width; ++x)
std::copy_n(row + (x / block) * 4, 4, out + x * 4);
}
if (settings.pixelShape == DitherPixelShape::dot) {
// The gaps are the dark colour: black, or picked.
const std::array<uint8_t, 3> gap = settings.colors == DitherColors::twoColors ? bytes(settings.dark) : std::array<uint8_t, 3>{0, 0, 0};
dither_dots(full.bits(), size_t(width), size_t(height), size_t(full.bytesPerLine()), block, gap.data());
}
return full;
}
QImage DitherSettings::dither(const QImage &image) const
{
const int cell = int(cellSize);
const Glyphs glyphs = style == DitherStyle::ascii ? DitherSettings::glyphs(characters.isEmpty() ? defaultCharacters() : characters, int(textSize)) : Glyphs();
const bool two = colors == DitherColors::twoColors;
const std::array<uint8_t, 3> darkColor = two ? bytes(dark) : std::array<uint8_t, 3>{0, 0, 0};
const std::array<uint8_t, 3> lightColor = two ? bytes(light) : std::array<uint8_t, 3>{255, 255, 255};
bool failed = false;
const QImage result = ImageAdjustmentPixels::run(image, [&](uchar *pixels, int width, int height, qsizetype stride) {
DitherParams params{.style = int(style), .levels = int(levels), .diffusion = float(diffusion / 100), .density = float(density / 100),
.contrast = float(contrast / 100), .cell = cell, .angle = float(angle * M_PI / 180), .lightOnDark = lightOnDark ? 1 : 0,
.originalColors = colors == DitherColors::original ? 1 : 0, .dark = {darkColor[0], darkColor[1], darkColor[2]},
.light = {lightColor[0], lightColor[1], lightColor[2]}, .glyphWidth = glyphs.width, .glyphHeight = glyphs.height, .glyphs = glyphs.maps.data(),
.glyphCoverage = glyphs.coverage.data(), .glyphCount = int(glyphs.coverage.size())};
failed = dither_apply(pixels, size_t(width), size_t(height), size_t(stride), &params) == 0;
});
if (failed)
throw ExportError(ExportError::Kind::render);
return result;
}
DitherSettings::Glyphs DitherSettings::glyphs(const QString &characters, int lineHeight)
{
// Swift's bold system monospace, at a whole pixel size.
QFont font(QStringLiteral("monospace"));
font.setWeight(QFont::Bold);
font.setPixelSize(std::max(1, int(std::lround(lineHeight / 1.2))));
const QFontMetricsF metrics(font);
const int height = lineHeight, width = std::max(1, int(std::lround(metrics.horizontalAdvance(QStringLiteral("M")))));
// One baseline, mid-line, as a terminal sets it.
const double baseline = height - std::round((height - (metrics.ascent() + metrics.descent())) / 2 + metrics.descent());
std::vector<std::pair<std::vector<uint8_t>, float>> drawn;
// Swift's Set<Character>: canonically equivalent graphemes are one.
std::set<QString> seen;
for (const QString &character : graphemes(characters)) {
if (!seen.insert(character.normalized(QString::NormalizationForm_C)).second)
continue;
QImage map(width, height, QImage::Format_Grayscale8);
if (map.isNull())
throw ExportError(ExportError::Kind::render);
map.fill(0);
QPainterPath path;
path.addText(0, 0, font, character);
// Overlapping outlines stay inked, as text rendering fills them.
path.setFillRule(Qt::WindingFill);
{
// Centred on its advance, on the shared baseline.
QPainter painter(&map);
painter.setRenderHint(QPainter::Antialiasing);
painter.translate(std::round((width - metrics.horizontalAdvance(character)) / 2), baseline);
painter.fillPath(path, Qt::white);
}
std::vector<uint8_t> bytes(size_t(width * height));
long sum = 0;
for (int y = 0; y < height; ++y) {
std::copy_n(map.constScanLine(y), width, bytes.begin() + y * width);
for (int x = 0; x < width; ++x)
sum += map.constScanLine(y)[x];
}
drawn.emplace_back(std::move(bytes), float(sum) / float(255 * width * height));
}
std::stable_sort(drawn.begin(), drawn.end(), [](const auto &a, const auto &b) { return a.second < b.second; });
Glyphs result{.maps = {}, .coverage = {}, .width = width, .height = height};
for (const auto &[map, coverage] : drawn) {
result.maps.insert(result.maps.end(), map.begin(), map.end());
result.coverage.push_back(coverage);
}
return result;
}
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include "Document/ImageAdjustments.h"
#include <QImage>
#include <QString>
#include <array>
#include <vector>
// Filter › Dither's looks, in the panel's order and DitherPixels.h's.
enum class DitherStyle { atkinson, floydSteinberg, bayer2, bayer4, bayer8, dots, lines, diamonds, patterns, ascii };
inline constexpr std::array allDitherStyles{DitherStyle::atkinson, DitherStyle::floydSteinberg, DitherStyle::bayer2, DitherStyle::bayer4,
DitherStyle::bayer8, DitherStyle::dots, DitherStyle::lines, DitherStyle::diamonds,
DitherStyle::patterns, DitherStyle::ascii};
QString rawValue(DitherStyle style);
// Swift's groups: diffusion, ordered, halftone, marks.
int ditherGroup(DitherStyle style);
// Each pixel's rounding error passes to its neighbours.
bool diffuses(DitherStyle style);
// Diffusion and ordered styles have tones; the rest draw marks.
bool hasTones(DitherStyle style);
bool isHalftone(DitherStyle style);
bool drawsMarks(DitherStyle style);
// ASCII draws at full resolution, not chunky.
bool usesPixelSize(DitherStyle style);
// A chunky pixel: a square, or a dot on dark.
enum class DitherPixelShape { square, dot };
inline constexpr std::array allDitherPixelShapes{DitherPixelShape::square, DitherPixelShape::dot};
QString rawValue(DitherPixelShape shape);
enum class DitherColors { blackWhite, twoColors, original };
inline constexpr std::array allDitherColors{DitherColors::blackWhite, DitherColors::twoColors, DitherColors::original};
QString rawValue(DitherColors colors);
// Swift's DitherSettings: the filter's look, in layer pixels.
struct DitherSettings {
static constexpr double pixelSizeLow = 1, pixelSizeHigh = 32;
static constexpr double cellSizeLow = 4, cellSizeHigh = 64;
static constexpr double textSizeLow = 6, textSizeHigh = 64;
static constexpr double levelsLow = 2, levelsHigh = 8;
static QString defaultCharacters() { return QStringLiteral(" .:-=+*#%@"); }
DitherStyle style = DitherStyle::atkinson;
// Each dithered pixel covers this many layer pixels a side.
double pixelSize = 2;
DitherPixelShape pixelShape = DitherPixelShape::square;
// Halftone cells, in dithered pixels.
double cellSize = 8;
// ASCII's line height in layer pixels; letters about 0.6 wide.
double textSize = 14;
// The halftone screen's angle, in degrees.
double angle = 45;
// Tones per channel; 2 is 1-bit.
double levels = 2;
// How much error passes on, 0–100%.
double diffusion = 100;
// −100…100: more ink or less, flatter or punchier.
double density = 0;
double contrast = 0;
DitherColors colors = DitherColors::blackWhite;
AdjustmentColor dark = AdjustmentColor(0, 0, 0);
AdjustmentColor light = AdjustmentColor(1, 1, 1);
// Marks stand for the light tones, lit on the dark.
bool lightOnDark = true;
// ASCII's characters, sorted by their ink.
QString characters = defaultCharacters();
DitherSettings normalized() const;
QImage apply(const QImage &image) const;
friend bool operator==(const DitherSettings &, const DitherSettings &) = default;
// Each distinct character's coverage map, least ink first.
struct Glyphs {
std::vector<uint8_t> maps;
std::vector<float> coverage;
int width = 1;
int height = 1;
};
// A monospaced cell: a line tall, a letter wide.
static Glyphs glyphs(const QString &characters, int lineHeight);
private:
QImage dither(const QImage &image) const;
};
+4
View File
@@ -26,6 +26,10 @@ public:
bool isModified() const { return m_revision != m_savedRevision; }
int undoCount() const { return int(m_past.size()); }
void markSaved() { m_savedRevision = m_revision; }
// The document now, for a save that finishes later.
QUuid currentRevision() const { return m_revision; }
// A save of `saved` finished; later edits stay modified.
void markSaved(QUuid saved) { m_savedRevision = saved; }
void reset();
// Takes the steps a staged copy of this history recorded.
void adopt(DocumentHistory &&staged) noexcept;
+31
View File
@@ -0,0 +1,31 @@
#include "Document/DocumentLimits.h"
#include <QLocale>
#include <algorithm>
#include <unistd.h>
// Swift's physicalMemory / 16, read once as a static let.
qint64 DocumentLimits::documentPixelBudget()
{
static const qint64 budget = budgetFor(qint64(sysconf(_SC_PHYS_PAGES)) * qint64(sysconf(_SC_PAGE_SIZE)));
return budget;
}
qint64 DocumentLimits::budgetFor(qint64 memory)
{
return std::min<qint64>(800'000'000, std::max(maxSurfacePixels, memory / 16));
}
qint64 DocumentLimits::maxSurfaceMegapixels()
{
return maxSurfacePixels / 1'000'000;
}
qint64 DocumentLimits::documentBudgetMegapixels()
{
return documentPixelBudget() / 1'000'000;
}
QString DocumentLimits::maxSideText()
{
return QLocale(QLocale::English).toString(maxSide);
}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <QString>
#include <QtGlobal>
// Swift's DocumentLimits: one surface, and a whole document's raster.
namespace DocumentLimits {
// Longest side of any canvas, layer, mask or generated surface.
inline constexpr int maxSide = 30'000;
// Largest single surface; both ceilings stay under maxSide squared.
inline constexpr qint64 maxSurfacePixels = 200'000'000;
// All layers and masks: a quarter of memory, capped.
qint64 documentPixelBudget();
// The budget for this much memory, in bytes.
qint64 budgetFor(qint64 memory);
qint64 maxSurfaceMegapixels();
qint64 documentBudgetMegapixels();
// The side limit as Swift's `formatted()` prints it: 30,000.
QString maxSideText();
}
+67 -33
View File
@@ -1,4 +1,5 @@
#include "Document/BrushStroke.h"
#include "Document/DocumentLimits.h"
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
@@ -36,12 +37,33 @@ bool EditorSession::canPaint() const
&& (m_isMaskSelected || !layer->adjustment);
}
std::optional<QString> EditorSession::paintRefusal() const
{
const std::optional<ImageLayer> layer = activeLayer();
if (!canEditLayers() || !layer || canPaint())
return std::nullopt;
if (m_selectedLayerIDs.size() > 1)
return QStringLiteral("Several layers are selected. Select just one to paint on it.");
if (layer->isGroup && !m_isMaskSelected)
return QStringLiteral("“%1” is a folder, which has no pixels of its own. Paint on a layer inside it, or on the folder’s mask.").arg(layer->name);
if (!m_document->effectiveVisibleIDs().contains(layer->id))
return QStringLiteral("“%1” is hidden, or inside a hidden folder. Show it to paint on it.").arg(layer->name);
if (m_isMaskSelected && !(layer->mask && layer->mask->isEnabled))
return QStringLiteral("The layer mask is turned off. Shift-click its thumbnail to turn it on, then paint.");
if (!m_isMaskSelected && layer->adjustment)
return QStringLiteral("“%1” is an adjustment layer, with no pixels to paint. Paint on its mask instead.").arg(layer->name);
const std::optional<DocumentSelection> current = selection();
if (current && current->isEmpty())
return QStringLiteral("Nothing is selected, so there’s nowhere to paint. Choose Select › Deselect (Ctrl+D) to paint anywhere.");
return std::nullopt;
}
// A tiled raster edit of pixels or mask, within budget.
std::unique_ptr<BrushStroke> EditorSession::makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings) const
std::unique_ptr<BrushStroke> EditorSession::makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings, bool growsMask) const
{
if (!m_document)
throw ProjectError(ProjectError::Kind::tooLarge);
auto stroke = std::make_unique<BrushStroke>(layer, m_isMaskSelected, settings, m_document->size());
auto stroke = std::make_unique<BrushStroke>(layer, m_isMaskSelected, settings, m_document->size(), growsMask);
qint64 used = 0;
for (const ImageLayer &other : m_document->layers) {
if (other.id == layer.id)
@@ -50,7 +72,7 @@ std::unique_ptr<BrushStroke> EditorSession::makeRasterEdit(const ImageLayer &lay
if (image)
used += qint64(image->size().width()) * image->size().height();
}
stroke->pixelLimit = 100'000'000 - used;
stroke->pixelLimit = DocumentLimits::documentPixelBudget() - used;
const std::optional<DocumentSelection> current = selection();
if (current)
stroke->selectionClip = current->clip(m_document->size());
@@ -60,7 +82,7 @@ std::unique_ptr<BrushStroke> EditorSession::makeRasterEdit(const ImageLayer &lay
if (other.id != layer.id && other.mask)
maskPixels += qint64(other.mask->asset.size().width()) * other.mask->asset.size().height();
}
stroke->pixelLimit = std::min<qint64>(stroke->pixelLimit, 100'000'000 - maskPixels);
stroke->pixelLimit = std::min<qint64>(stroke->pixelLimit, DocumentLimits::documentPixelBudget() - maskPixels);
}
return stroke;
}
@@ -73,28 +95,20 @@ void EditorSession::beginBrush(QPointF point)
}
// Spot Healing and Clone Stamp rework pixels, never a mask.
const bool paintsMasks = m_tool == NavigationTool::brush || m_tool == NavigationTool::blur;
if ((!paintsMasks && (!isBrushTool(m_tool) || m_isMaskSelected)) || !canPaint())
if (!paintsMasks && (!isBrushTool(m_tool) || m_isMaskSelected))
return;
if (!canPaint()) {
setBrushError(paintRefusal());
return;
}
const ImageLayer layer = activeLayer().value();
std::optional<BrushStroke::Clone> clone;
std::optional<QSizeF> sourceOffset;
if (m_tool == NavigationTool::cloneStamp) {
const std::optional<QSizeF> offset = cloneStrokeOffset(point);
if (!offset) {
sourceOffset = cloneStrokeOffset(point);
if (!sourceOffset) {
setBrushError(QStringLiteral("Alt-click where Clone Stamp should copy from first."));
return;
}
const std::optional<QImage> image = cloneSample(m_document.value());
if (!image)
return;
m_cloneOffset = offset;
clone = BrushStroke::Clone{*image, *offset};
}
// Blur paints a softened copy of the layer, in place.
if (m_tool == NavigationTool::blur) {
const std::optional<QImage> image = blurSample(m_document.value(), m_isMaskSelected);
if (!image)
return;
clone = BrushStroke::Clone{*image, QSizeF(0, 0)};
}
finishOpacityEdit();
try {
@@ -107,9 +121,21 @@ void EditorSession::beginBrush(QPointF point)
settings.green = settings.red;
settings.blue = settings.red;
}
m_brushStroke = makeRasterEdit(layer, settings);
m_brushStroke->clone = clone;
m_brushStroke->isBlur = m_tool == NavigationTool::blur;
std::unique_ptr<BrushStroke> stroke = makeRasterEdit(layer, settings, m_tool == NavigationTool::brush);
if (sourceOffset) {
stroke->setClone(cloneSample(m_document.value(), *stroke, *sourceOffset));
if (!stroke->clone())
return;
m_cloneOffset = sourceOffset;
}
// Blur paints a softened copy of the layer, in place.
if (m_tool == NavigationTool::blur) {
stroke->setClone(blurSample(*stroke));
if (!stroke->clone())
return;
}
stroke->isBlur = m_tool == NavigationTool::blur;
m_brushStroke = std::move(stroke);
m_brushStroke->append(point);
m_brushAnchor = point;
m_brushPointer = point;
@@ -224,6 +250,16 @@ void EditorSession::finishBrush()
finishBrushImmediately();
}
// Grown past its layer, or placed already: keeps its place.
LayerMask EditorSession::placedMask(const LayerMask &mask, const ImportedImage &asset, const QRectF &bounds, const LayerTransform &transform,
const BrushStroke &stroke)
{
LayerMask painted = mask.replacing(asset);
if (mask.placement || bounds != stroke.sourceRect)
painted.placement = transform;
return painted;
}
// Immutable tiles land at once, undo entry included.
void EditorSession::commitPaintSnapshot(const BrushStroke &stroke)
{
@@ -243,12 +279,12 @@ void EditorSession::commitPaintSnapshot(const BrushStroke &stroke)
beginEdit(stroke.editName.value_or(stroke.isMask ? QStringLiteral("Paint Mask")
: stroke.settings.erasing ? QStringLiteral("Erase")
: stroke.isBlur ? QStringLiteral("Blur")
: stroke.clone ? QStringLiteral("Clone Stamp")
: stroke.clone() ? QStringLiteral("Clone Stamp")
: stroke.settings.healing ? QStringLiteral("Spot Healing")
: QStringLiteral("Brush Stroke")));
ImageLayer &layer = m_document->layers[index];
if (stroke.isMask) {
layer.mask = current.mask ? current.mask->replacing(result.asset) : LayerMask(result.asset);
layer.mask = current.mask ? placedMask(*current.mask, result.asset, result.bounds, result.transform, stroke) : LayerMask(result.asset);
} else {
layer.asset = result.asset;
layer.transform = result.transform;
@@ -264,14 +300,10 @@ void EditorSession::commitPaintSnapshot(const BrushStroke &stroke)
// A raster edit assembled off the UI thread; one step.
void EditorSession::commitRasterEdit(std::shared_ptr<const BrushStroke> stroke, const QString &name, std::function<void()> alsoApply, std::function<void()> done)
{
// The caller resumes from the event loop, like await.
const auto finish = [this, done] {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
};
if (!stroke->committedTransform().isValid()) {
setBrushError(QString::fromUtf8(ProjectError(ProjectError::Kind::tooLarge).what()));
finish();
if (done)
done();
return;
}
setIsProjectBusy(true);
@@ -295,8 +327,9 @@ void EditorSession::finishRasterCommit()
setIsProjectBusy(false);
++m_brushRevision;
notify();
// Swift's caller resumes in the same job, nothing between.
if (pending.done)
QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection);
pending.done();
};
if (made.failure) {
setBrushError(made.failure);
@@ -326,7 +359,8 @@ void EditorSession::finishRasterCommit()
beginEdit(pending.name);
ImageLayer &layer = m_document->layers[index];
if (stroke.isMask) {
layer.mask = current.mask ? current.mask->replacing(result.asset) : LayerMask(result.asset);
const QRectF bounds = result.pixelBounds.translated(stroke.committedBounds().topLeft());
layer.mask = current.mask ? placedMask(*current.mask, result.asset, bounds, transform, stroke) : LayerMask(result.asset);
} else {
layer.asset = result.asset;
layer.transform = transform;
+6 -1
View File
@@ -1,7 +1,9 @@
// Swift's EditorSession+Brush extension: a stroke from press to commit.
public:
bool canPaint() const;
std::unique_ptr<BrushStroke> makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings = BrushSettings()) const;
// Why the target refuses a stroke; none while busy.
std::optional<QString> paintRefusal() const;
std::unique_ptr<BrushStroke> makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings = BrushSettings(), bool growsMask = false) const;
void beginBrush(QPointF point);
void continueBrush(QPointF point);
// Where a Shift-click's line starts; nil on another target.
@@ -22,5 +24,8 @@ public:
private:
void finishRasterCommit();
// A mask painted: grown or placed, it keeps its place.
static LayerMask placedMask(const LayerMask &mask, const ImportedImage &asset, const QRectF &bounds, const LayerTransform &transform,
const BrushStroke &stroke);
// Where Smoothing lets the brush go; none while slack.
std::optional<QPointF> smoothed(QPointF point);
+65 -17
View File
@@ -1,4 +1,5 @@
#include "Document/EditorSession.h"
#include "Document/DocumentLimits.h"
#include "Document/ProjectWorkspace.h"
#include "Document/PixelAdjust.h"
#include "IO/PSD/PSDReader.h"
@@ -8,8 +9,8 @@
bool EditorSession::canStartProjectOperation() const
{
return !m_selectionAmountOperation && !m_textDraft && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_warpStroke && !m_showsNewDocument
&& !m_showsImporter && !m_renamingLayerID && !m_importError && !m_levels && !m_adjustmentEditingID;
return !m_selectionAmountOperation && !m_colorRange && !m_textDraft && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_warpStroke
&& !m_showsNewDocument && !m_showsImporter && !m_renamingLayerID && !m_importError && !m_levels && !m_adjustmentEditingID;
}
void EditorSession::waitForFileRequest(std::function<void()> ready)
@@ -99,23 +100,24 @@ void EditorSession::decodeNext()
drainImports();
return;
}
qint64 usedPixels = 0;
if (m_document) {
for (const ImageLayer &layer : m_document->layers) {
if (layer.asset)
usedPixels += qint64(layer.asset->size().width()) * layer.asset->size().height();
}
}
const QUrl url = request.urls[m_importIndex];
if (url.isLocalFile() && RawImporter::matches(url.toLocalFile())) {
decodeRaw(url.toLocalFile(), 100'000'000 - usedPixels);
decodeRaw(url.toLocalFile(), remainingPixels());
return;
}
// An SVG goes by its suffix, before Photoshop's bytes.
if (url.isLocalFile() && ImageImporter::isSVG(url.toLocalFile())) {
decode(url, false);
return;
}
if (url.isLocalFile() && PSDReader::matches(url.toLocalFile())) {
beginPSDReading(QStringLiteral("Open “%1”?").arg(url.fileName()), QStringLiteral("Import"));
m_photoshopReader.setFuture(QtConcurrent::run([path = url.toLocalFile(), remaining = 100'000'000 - usedPixels]() -> PhotoshopRead {
m_photoshopReader.setFuture(QtConcurrent::run([path = url.toLocalFile(), remaining = remainingPixels()]() -> PhotoshopRead {
try {
PSDDocument document = ImageImporter::loadPhotoshop(path, remaining);
// No layer records: Photoshop wrote the merged image alone.
if (document.layers.empty())
return {std::nullopt, {}, QString(), true};
std::map<QUuid, ImportedImage> assets = ImageImporter::photoshopAssets(document);
return {std::move(document), std::move(assets), QString()};
} catch (const ImageImportError &error) {
@@ -128,13 +130,38 @@ void EditorSession::decodeNext()
}));
return;
}
m_decoder.setFuture(QtConcurrent::run([url, remaining = 100'000'000 - usedPixels]() -> Decoded {
decode(url, false);
}
qint64 EditorSession::remainingPixels() const
{
qint64 usedPixels = 0;
if (m_document) {
for (const ImageLayer &layer : m_document->layers) {
if (layer.asset)
usedPixels += qint64(layer.asset->size().width()) * layer.asset->size().height();
}
}
return DocumentLimits::documentPixelBudget() - usedPixels;
}
// `flattened`: a layerless Photoshop file, read as its merged image.
void EditorSession::decode(const QUrl &url, bool flattened)
{
const std::optional<QSizeF> fitting = m_document ? std::optional(m_document->size()) : std::nullopt;
m_decoder.setFuture(QtConcurrent::run([url, flattened, fitting, remaining = remainingPixels()]() -> Decoded {
try {
if (!url.isLocalFile())
throw ImageImportError(ImageImportError::Kind::unsupported);
return {ImageImporter::decode(url.toLocalFile(), remaining), QString()};
if (ImageImporter::isSVG(url.toLocalFile()))
return {ImageImporter::decodeSVG(url.toLocalFile(), fitting, remaining), QString()};
return {ImageImporter::decode(url.toLocalFile(), remaining, flattened), QString()};
} catch (const ImageImportError &error) {
return {std::nullopt, url.fileName() + ": " + error.what()};
} catch (const PSDError &error) {
return {std::nullopt, url.fileName() + ": " + error.what()};
} catch (const ExportError &error) {
return {std::nullopt, url.fileName() + ": " + error.what()};
}
}));
}
@@ -150,7 +177,7 @@ void EditorSession::decodeRaw(const QString &path, qint64 remaining)
};
if (!size)
return refuse(ImageImportError::Kind::unreadable);
if (size->width() > 30'000 || size->height() > 30'000 || qint64(size->width()) * size->height() > remaining)
if (size->width() > DocumentLimits::maxSide || size->height() > DocumentLimits::maxSide || qint64(size->width()) * size->height() > remaining)
return refuse(ImageImportError::Kind::tooLarge);
developRaw(path, [this, path, name](std::optional<RawDevelopSettings> settings) {
if (!settings) {
@@ -181,13 +208,25 @@ void EditorSession::developRaw(const QString &path, std::function<void(std::opti
void EditorSession::finishRawDevelop(std::optional<RawDevelopSettings> settings)
{
// Importing, the sheet waits for the develop; Cancel closes it.
if (settings && m_rawAnswer) {
m_rawImporting = true;
notify();
} else {
endRawDevelop();
}
// The import resumes from the event loop, as a task.
if (const auto answer = std::exchange(m_rawAnswer, {}))
QMetaObject::invokeMethod(this, [answer, settings] { answer(settings); }, Qt::QueuedConnection);
}
void EditorSession::endRawDevelop()
{
m_rawImporting = false;
m_rawDevelop = std::nullopt;
notify();
// Frees the preview's decode on a worker, behind any preview.
(void)QtConcurrent::run([] { RawImporter::Queue::shared().release(); });
// The import resumes from the event loop, as a task.
if (const auto answer = std::exchange(m_rawAnswer, {}))
QMetaObject::invokeMethod(this, [answer, settings] { answer(settings); }, Qt::QueuedConnection);
}
void EditorSession::finishDecode()
@@ -197,6 +236,9 @@ void EditorSession::finishDecode()
insert(*decoded.asset, m_importPoint);
else
m_importFailures << decoded.failure;
// Swift's defer: the develop sheet goes once the frame lands.
if (m_rawImporting)
endRawDevelop();
m_importIndex += 1;
decodeNext();
}
@@ -209,6 +251,12 @@ void EditorSession::finishPhotoshopRead()
m_importIndex += 1;
decodeNext();
};
// Swift ends the reading first, then reads the merged image.
if (read.layerless) {
endPSDReading();
decode(url, true);
return;
}
// Every image has its asset: making layers cannot fail.
const std::optional<PSDImport> imported = read.document ? std::optional(PSDDocumentBuilder::makeImport(*read.document, read.assets)) : std::nullopt;
if (!imported) {
+27
View File
@@ -0,0 +1,27 @@
// The import queue: files, Photoshop and RAW.
public:
// Files decode off the UI thread; `done` follows this request.
void importImages(const QList<QUrl> &urls, std::optional<QPointF> point = std::nullopt, std::function<void()> done = {});
// The sheet shows while a Photoshop file is read.
void beginPSDReading(const QString &title, const QString &confirmTitle);
void finishPSDReading(const std::vector<PSDConversion> &conversions, std::function<void(bool)> answer);
void endPSDReading();
void finishConversion(bool confirmed);
void insertPhotoshop(const PSDImport &imported, const QString &named, std::optional<QPointF> centeredAt = std::nullopt);
// The RAW file the develop sheet shows, while it shows.
const std::optional<RawDevelopRequest> &rawDevelop() const { return m_rawDevelop; }
void decodeRaw(const QString &path, qint64 remaining);
void developRaw(const QString &path, std::function<void(std::optional<RawDevelopSettings>)> answer);
void finishRawDevelop(std::optional<RawDevelopSettings> settings);
// Import pressed: the sheet stays until the layer is in.
bool rawImporting() const { return m_rawImporting; }
// Closes the sheet once the RAW is in, or cancelled.
void endRawDevelop();
private:
void drainImports();
void decodeNext();
qint64 remainingPixels() const;
void decode(const QUrl &url, bool flattened);
void finishDecode();
void finishPhotoshopRead();
+4
View File
@@ -36,6 +36,8 @@ struct SessionJobs {
std::optional<PSDDocument> document;
std::map<QUuid, ImportedImage> assets;
QString failure;
// No layer records: a background alone, read merged next.
bool layerless = false;
};
struct Baked {
std::map<QUuid, ImportedImage> images;
@@ -102,6 +104,8 @@ struct SessionJobs {
FilterSettings settings;
// Camera Raw's scope of the grade itself.
std::optional<CameraRawScope> scope = std::nullopt;
// The grown layer it was made from: it shows there.
std::optional<LayerTransform> placement = std::nullopt;
};
struct FilterMade {
std::optional<ImportedImage> asset;
+16 -9
View File
@@ -1,4 +1,5 @@
#include "Document/EditorSession+Model.h"
#include "Document/DocumentLimits.h"
#include "IO/ProjectStore.h"
#include <algorithm>
@@ -23,7 +24,8 @@ ImageLayer::ImageLayer(const ProjectLayerRecord &record, std::optional<ImportedI
: id(record.id), asset(std::move(asset)), transform(record.transform), name(record.name), isVisible(record.isVisible),
parentID(record.parentID), isGroup(record.isGroup == true), opacity(record.opacity.value_or(1)),
blendMode(record.blendMode.value_or(LayerBlendMode::normal)), maskSourceID(record.maskSourceID), mask(std::move(mask)),
adjustment(record.adjustment), effects(record.effects)
adjustment(record.adjustment), shape(LayerShape::loaded(record.shape, this->asset)), effects(record.effects),
text(LayerText::loaded(record.text, this->asset))
{
}
@@ -58,7 +60,7 @@ namespace {
// Swift's CharacterSet.whitespaces: category Zs and tab.
bool isWhitespace(QChar character)
{
return character == u'\t' || character.category() == QChar::Separator_Space;
return character == u'\t' || character == QChar(0x200B) || character.category() == QChar::Separator_Space;
}
// Swift's Int(String): optional sign, then ASCII digits only.
@@ -77,15 +79,20 @@ std::optional<int> wholeNumber(QStringView text)
}
}
QString trimmedWhitespaces(const QString &text)
{
qsizetype first = 0, last = text.size();
while (first < last && isWhitespace(text[first]))
++first;
while (last > first && isWhitespace(text[last - 1]))
--last;
return text.mid(first, last - first);
}
std::optional<int> CanvasDocument::validDimension(const QString &value)
{
qsizetype first = 0, last = value.size();
while (first < last && isWhitespace(value[first]))
++first;
while (last > first && isWhitespace(value[last - 1]))
--last;
const std::optional<int> number = wholeNumber(QStringView(value).mid(first, last - first));
if (!number || *number < 1 || *number > 30'000)
const std::optional<int> number = wholeNumber(trimmedWhitespaces(value));
if (!number || *number < 1 || *number > DocumentLimits::maxSide)
return std::nullopt;
return number;
}
Loaded 100 of 622 files, more files were not shown because too many files have changed in this diff. Show more