diff --git a/AGENTS.md b/AGENTS.md index 19443f0..531720c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,8 +113,8 @@ Platform adaptations, each forced by a missing Qt analog: - 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 ` or `Show `) 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 ` 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 counts), `RemoveBackgroundTests`, the sheet in `FilterControlsTests`, the kind in `FilterModelTests` and both menu tests. - 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 `onChange`), the frame's size as whole pixels (Swift's `Int`, truncated) grouped through the English locale like the status bar (`monospacedDigit` has no Qt 6.4 twin), and Cancel and Apply Crop resting without a frame; the bar rests while busy shows or without a document. `ContentView` shows it for the Crop tool and the "Couldn’t crop" alert over `cropError`. Left out as dead: `renderBounds`' tool test (a frame lives under the Crop tool alone: every other way to another tool cancels it or asks `canEditLayers`, which a frame refuses) and the strips' `max(0, …)` in `cropResizeRegions` (a strip it would clip lies under the corners' regions, which take the point first). Equivalent mutants: `contains` on the visible frame for the drawn one (while a frame is drawn they are one), the tolerance's `max(…, 0.0001)` (the zoom's floor keeps points per pixel above it), `cropMove`'s answer after a lost release and after a step (the rest of the move only refreshes the cursor), the strips' ends (under the corners), the frame's caps and joins (its corners lie under the grips) `drawCrop`'s `setBrush(Qt::NoBrush)` (the painter arrives with none) and `takeResult` for `result()` in the landing (a copy asks only more memory, and its failure is caught the same). Tests: `CropTests` (Swift's six session and geometry tests; symmetric drags, snapping by side, bounds, ratios), `CropSessionTests` (a failed crop, refusals and the busy project, the signals, every hook, a flipped frame, one step that ends redo), `CropFixtures.h` (`committed`), `CropCanvasTests` (Swift's `cropDraggingRedrawsOnlyTheOverlay`; drags, Alt and Ctrl mid-drag, keys and lost releases, cursors, the dimming, a frame past the canvas), `CropControlsTests`, `CropFailureTests` (a binary of its own whose `main` shares the main heap among threads, `M_ARENA_MAX` 1 before any thread starts, and maps large asks, so a worker's allocations meet `AddressSpaceLimit`: a worker in an arena of its own grows its reserved heap past the limit; the snapshot, the worker and the landing each run out in turn), the bar and the alert in `ContentViewTests`. -- 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 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 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 options that never became ready, and encoding is deterministic), and the destructor's flag (it only stops a worker whose result nobody reads). +- 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 `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 `DialogColorSwatch` on the app's picker (13.4a; 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 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 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 options that never became ready, and encoding is deterministic), and the destructor's flag (it only stops a worker whose result nobody reads). - 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-` 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. @@ -159,6 +159,7 @@ Platform adaptations, each forced by a missing Qt analog: - 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` 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 sliders' ends and a linear interior, the rows' order and help, a commit's busy line, the menus and characters) pin the code, with chunky pixels against an independent box average under diffusion, exact Bayer counts for Density and Contrast, the halftone angle, custom, blank and missing alphabets, the glyphs' size, smoothing, centring and canonical duplicates, the live picker preview, both swatches, the filter's OK with a picker open, each numeric row and the Characters field on leaving, and `CompositorMenusTests` the Filter menu. - 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 (JPEG Background)`); 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 `JPEGExportSheetTests` 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). - 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 "), 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 `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`, 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. diff --git a/CMakeLists.txt b/CMakeLists.txt index 12f68b1..a8c246c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -109,6 +109,7 @@ add_library(compositor_core STATIC src/UI/CanvasThumbnail.cpp src/UI/ColorPaletteControls.cpp src/UI/ColorPickerSheet.cpp + src/UI/ColorPickerSheet+Dialog.cpp src/UI/CompositorMenus.cpp src/UI/LayerAppearanceControls.cpp src/UI/LayerIcons.cpp diff --git a/TASKS.md b/TASKS.md index 3f42345..5453e7d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -421,7 +421,8 @@ Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`) - 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. -- [ ] 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. +- [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. - [ ] 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.5 Part two: the marquee, shapes and a moved selection snap to View › Snap To (`d6e3e93`, `8b1369a`); View › Grid Settings… with Restore Defaults (`1c819d0`, `db5eafa`). - [ ] 13.6 Part three: Ctrl+Z while typing takes back what was typed (`ee68ba9`); font previews in the Type bar (`1bd1df4`). diff --git a/src/Document/ColorPalette+Session.h b/src/Document/ColorPalette+Session.h index 08b3f08..3f25338 100644 --- a/src/Document/ColorPalette+Session.h +++ b/src/Document/ColorPalette+Session.h @@ -28,6 +28,11 @@ public: 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. diff --git a/src/Document/ColorPalette.cpp b/src/Document/ColorPalette.cpp index df923ec..f4ae289 100644 --- a/src/Document/ColorPalette.cpp +++ b/src/Document/ColorPalette.cpp @@ -109,6 +109,8 @@ QString ColorPickerTarget::title() const return QStringLiteral("Color Picker (Vignette Color)"); if (kind == Kind::dither) return light ? QStringLiteral("Color Picker (Dither Light Color)") : QStringLiteral("Color Picker (Dither 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)"); } @@ -257,6 +259,8 @@ void EditorSession::closeColorPicker(bool commit) 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); }); @@ -440,3 +444,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()); +} diff --git a/src/Document/ColorPalette.h b/src/Document/ColorPalette.h index d327ede..059dd0a 100644 --- a/src/Document/ColorPalette.h +++ b/src/Document/ColorPalette.h @@ -3,6 +3,7 @@ #include <QColor> #include <QString> #include <QUuid> +#include <functional> #include <optional> // Swift's PaletteColor: an sRGB colour without alpha. @@ -38,7 +39,7 @@ enum class LayerEffectKind; // The picker's targets: palette, text, effect, a filter's colour. struct ColorPickerTarget { - enum class Kind { palette, text, gradientMap, effect, vignette, dither }; + enum class Kind { palette, text, gradientMap, effect, vignette, dither, dialog }; Kind kind; bool background = false; std::optional<QUuid> draftID = std::nullopt; @@ -48,6 +49,8 @@ struct ColorPickerTarget { LayerEffectKind effect{}; // Dither's Two Colors: the light one, else the 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; }; @@ -66,5 +69,7 @@ struct ColorPickerState { 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(); } }; diff --git a/src/IO/ProjectController+Export.cpp b/src/IO/ProjectController+Export.cpp index c065dd6..c432868 100644 --- a/src/IO/ProjectController+Export.cpp +++ b/src/IO/ProjectController+Export.cpp @@ -140,7 +140,7 @@ void ProjectController::exportJPEG(std::function<void()> done) } QDialog *dialog = sheet(QStringLiteral("Export JPEG")); auto chosen = std::make_shared<std::optional<QByteArray>>(); - dialog->layout()->addWidget(new JPEGExportSheet(std::move(*rendered.raster), [dialog, chosen](std::optional<QByteArray> data) { + dialog->layout()->addWidget(new JPEGExportSheet(std::move(*rendered.raster), session, [dialog, chosen](std::optional<QByteArray> data) { *chosen = std::move(data); dialog->done(*chosen ? QDialog::Accepted : QDialog::Rejected); }, dialog)); diff --git a/src/IO/ProjectController+Resize.cpp b/src/IO/ProjectController+Resize.cpp index 5b47f70..53aa49a 100644 --- a/src/IO/ProjectController+Resize.cpp +++ b/src/IO/ProjectController+Resize.cpp @@ -31,7 +31,7 @@ void ProjectController::canvasSize(std::function<void()> done) } QDialog *dialog = sheet(QStringLiteral("Canvas Size")); auto chosen = std::make_shared<std::optional<CanvasSizeOptions>>(); - dialog->layout()->addWidget(new CanvasSizeSheet(session.document().value(), session.foregroundColor(), session.backgroundColor(), + dialog->layout()->addWidget(new CanvasSizeSheet(session.document().value(), session, [dialog, chosen](std::optional<CanvasSizeOptions> options) { *chosen = options; dialog->done(options ? QDialog::Accepted : QDialog::Rejected); diff --git a/src/Rendering/EditorCanvas+Picking.cpp b/src/Rendering/EditorCanvas+Picking.cpp index 423ef49..675b57e 100644 --- a/src/Rendering/EditorCanvas+Picking.cpp +++ b/src/Rendering/EditorCanvas+Picking.cpp @@ -14,7 +14,8 @@ bool CanvasView::palettePicking() const bool CanvasView::picking() const { const std::optional<FilterEdit> &edit = m_session.filterEdit(); - return palettePicking() || m_session.colorPicker() || m_session.hueSampleMode() || (m_session.levels() && m_session.levels()->sampleMode) || m_session.colorRange() + return palettePicking() || (m_session.colorPicker() && !m_session.pickingForDialog()) || m_session.hueSampleMode() + || (m_session.levels() && m_session.levels()->sampleMode) || m_session.colorRange() || (edit && (edit->rawPanel.samplesWhiteBalance || edit->rawPanel.samplesPointColor || edit->rawPanel.samplesDefringe || edit->rawPanel.drawingGeometryGuide)); diff --git a/src/UI/CanvasSizeSheet.cpp b/src/UI/CanvasSizeSheet.cpp index 212e6dd..87d8b70 100644 --- a/src/UI/CanvasSizeSheet.cpp +++ b/src/UI/CanvasSizeSheet.cpp @@ -2,12 +2,11 @@ #include "Document/DocumentLimits.h" #include "UI/KeyboardShortcuts.h" #include "UI/ByteCounts.h" -#include "UI/ColorPaletteControls.h" +#include "UI/ColorPickerSheet+Dialog.h" #include "UI/ColorPickerSheet.h" #include "UI/NumericScrub.h" #include <QButtonGroup> #include <QCheckBox> -#include <QColorDialog> #include <QComboBox> #include <QFrame> #include <QGridLayout> @@ -109,13 +108,14 @@ protected: }; } -CanvasSizeSheet::CanvasSizeSheet(const CanvasDocument &document, PaletteColor foreground, PaletteColor background, - std::function<void(std::optional<CanvasSizeOptions>)> finish, QWidget *parent) - : QWidget(parent), m_foreground(foreground), m_background(background), m_finish(std::move(finish)), +CanvasSizeSheet::CanvasSizeSheet(const CanvasDocument &document, EditorSession &session, std::function<void(std::optional<CanvasSizeOptions>)> finish, + QWidget *parent) + : QWidget(parent), m_session(session), m_foreground(session.foregroundColor()), m_background(session.backgroundColor()), m_finish(std::move(finish)), m_draft(document.width, document.height, document.resolution), m_width(dimension(true)), m_height(dimension(false)), m_note(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)), m_anchors(new QButtonGroup(this)), m_anchorName(text(QString(), 12, QFont::Bold, QPalette::WindowText, this)), m_extension(new QComboBox(this)), - m_customRow(new QWidget(this)), m_customSwatch(new SwatchButton([this] { return m_custom; }, 3, 0, 0.5, m_customRow)), + m_customRow(new QWidget(this)), m_customSwatch(new DialogColorSwatch( + QStringLiteral("Extension Color"), [this] { return m_custom; }, [this](const PaletteColor &custom) { m_custom = custom; }, session, m_customRow)), m_ok(new QPushButton(QStringLiteral("OK"), this)) { setFixedWidth(450); @@ -224,14 +224,13 @@ CanvasSizeSheet::CanvasSizeSheet(const CanvasDocument &document, PaletteColor fo extension->addWidget(extensionLabel); extension->addWidget(m_extension, 1); column->addLayout(extension); - // Swift's ColorPicker: its label and a well, no opacity. + // Its label and the app's picker on a swatch. m_customRow->setObjectName(QStringLiteral("extensionColorRow")); m_customSwatch->setObjectName(QStringLiteral("extensionColor")); - m_customSwatch->setFixedSize(36, 18); - m_customSwatch->setAccessibleName(QStringLiteral("Extension color")); - connect(m_customSwatch, &QAbstractButton::clicked, this, &CanvasSizeSheet::pickCustomColor); + m_customSwatch->setToolTip(QStringLiteral("Color for the added canvas")); auto *customLayout = new QHBoxLayout(m_customRow); customLayout->setContentsMargins(0, 0, 0, 0); + customLayout->setSpacing(8); customLayout->addWidget(new QLabel(QStringLiteral("Extension color"), m_customRow)); customLayout->addWidget(m_customSwatch); customLayout->addStretch(1); @@ -240,13 +239,18 @@ CanvasSizeSheet::CanvasSizeSheet(const CanvasDocument &document, PaletteColor fo auto *cancel = new QPushButton(QStringLiteral("Cancel"), this); cancel->setObjectName(QStringLiteral("canvasCancel")); cancel->setAutoDefault(false); - connect(cancel, &QPushButton::clicked, this, [this] { m_finish(std::nullopt); }); + connect(cancel, &QPushButton::clicked, this, [this] { + DialogColorSwatch::closePicker(m_session); + m_finish(std::nullopt); + }); m_ok->setObjectName(QStringLiteral("canvasOK")); m_ok->setDefault(true); // Swift's configuredNativeShortcut: Return and Escape, as remapped. NativeShortcut::bind(*this, m_ok, cancel); // OK rests while the draft is invalid: no guard needed. connect(m_ok, &QPushButton::clicked, this, [this] { + // An open picker's colour counts: it closes first. + DialogColorSwatch::closePicker(m_session); m_finish(CanvasSizeOptions{.width = qint64(std::round(m_draft.width)), .height = qint64(std::round(m_draft.height)), .anchor = m_anchor, .fill = fill()}); }); @@ -300,20 +304,6 @@ std::optional<CanvasExtensionColor> CanvasSizeSheet::fill() const } } -// The desktop's colour dialog, SwiftUI's colour panel, without alpha. -void CanvasSizeSheet::pickCustomColor() -{ - auto *dialog = new QColorDialog(m_custom.color(), this); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->setWindowTitle(QStringLiteral("Extension color")); - connect(dialog, &QColorDialog::colorSelected, this, [this](const QColor &picked) { - const QColor srgb = picked.toRgb(); - m_custom = PaletteColor{srgb.redF(), srgb.greenF(), srgb.blueF()}; - m_customSwatch->update(); - }); - dialog->open(); -} - // Units, Relative and Lock change only through their own controls. void CanvasSizeSheet::synchronize() { @@ -347,4 +337,5 @@ void CanvasSizeSheet::synchronize() CanvasSizeSheet::~CanvasSizeSheet() { releaseFocus(*this); + DialogColorSwatch::closePicker(m_session); } diff --git a/src/UI/CanvasSizeSheet.h b/src/UI/CanvasSizeSheet.h index 0baa60b..cd89318 100644 --- a/src/UI/CanvasSizeSheet.h +++ b/src/UI/CanvasSizeSheet.h @@ -12,14 +12,15 @@ class QButtonGroup; class QComboBox; class QLabel; class QPushButton; -class SwatchButton; +class DialogColorSwatch; +class EditorSession; // Swift's CanvasSizeSheet: the new size, the point kept, the extension. class CanvasSizeSheet : public QWidget { Q_OBJECT public: - CanvasSizeSheet(const CanvasDocument &document, PaletteColor foreground, PaletteColor background, - std::function<void(std::optional<CanvasSizeOptions>)> finish, QWidget *parent = nullptr); + CanvasSizeSheet(const CanvasDocument &document, EditorSession &session, std::function<void(std::optional<CanvasSizeOptions>)> finish, + QWidget *parent = nullptr); ~CanvasSizeSheet() override; private: @@ -27,9 +28,9 @@ private: // Swift's scrubbable Width or Height title. NumericScrub *scrub(QLabel *title, bool widthAxis); std::optional<CanvasExtensionColor> fill() const; - void pickCustomColor(); void synchronize(); + EditorSession &m_session; const PaletteColor m_foreground; const PaletteColor m_background; const std::function<void(std::optional<CanvasSizeOptions>)> m_finish; @@ -45,6 +46,6 @@ private: QLabel *const m_anchorName; QComboBox *const m_extension; QWidget *const m_customRow; - SwatchButton *const m_customSwatch; + DialogColorSwatch *const m_customSwatch; QPushButton *const m_ok; }; diff --git a/src/UI/ColorPickerSheet+Dialog.cpp b/src/UI/ColorPickerSheet+Dialog.cpp new file mode 100644 index 0000000..b33d5dd --- /dev/null +++ b/src/UI/ColorPickerSheet+Dialog.cpp @@ -0,0 +1,37 @@ +#include "UI/ColorPickerSheet+Dialog.h" +#include <QHideEvent> + +// The brush's swatch: 34 by 18, a white ring. +DialogColorSwatch::DialogColorSwatch(const QString &title, std::function<PaletteColor()> colour, std::function<void(const PaletteColor &)> change, + EditorSession &session, QWidget *parent) + : SwatchButton(colour, 4, 1, 1, parent), m_session(session) +{ + setFixedSize(34, 18); + setAccessibleName(title); + connect(this, &QAbstractButton::clicked, this, [this, title, colour, change, &session] { + session.openDialogColorPicker(title, colour(), [this, change](const PaletteColor &picked) { + change(picked); + update(); + }); + }); + connect(&session, &EditorSession::changed, this, [this, &session] { + const std::optional<PaletteColor> now = session.colorPicker() ? std::optional(session.colorPicker()->color()) : std::nullopt; + if (now == m_seen) + return; + m_seen = now; + session.previewDialogColor(); + }); +} + +void DialogColorSwatch::hideEvent(QHideEvent *event) +{ + if (!event->spontaneous()) + closePicker(m_session); + SwatchButton::hideEvent(event); +} + +void DialogColorSwatch::closePicker(EditorSession &session) +{ + if (session.pickingForDialog()) + session.closeColorPicker(true); +} diff --git a/src/UI/ColorPickerSheet+Dialog.h b/src/UI/ColorPickerSheet+Dialog.h new file mode 100644 index 0000000..4e2e2f7 --- /dev/null +++ b/src/UI/ColorPickerSheet+Dialog.h @@ -0,0 +1,22 @@ +#pragma once +#include "Document/EditorSession.h" +#include "UI/ColorPaletteControls.h" + +// Swift's DialogColorSwatch: a dialog's colour in the app's picker. +class DialogColorSwatch : public SwatchButton { + Q_OBJECT +public: + DialogColorSwatch(const QString &title, std::function<PaletteColor()> colour, std::function<void(const PaletteColor &)> change, EditorSession &session, + QWidget *parent); + // Puts the picker away with the dialog, keeping its colour. + static void closePicker(EditorSession &session); + +protected: + // Swift's onDisappear: the picker goes with the swatch. + void hideEvent(QHideEvent *event) override; + +private: + EditorSession &m_session; + // Swift's onChange: the picker's colour as last seen. + std::optional<PaletteColor> m_seen; +}; diff --git a/src/UI/ColorPickerSheet.cpp b/src/UI/ColorPickerSheet.cpp index 39bd2c6..febe0f3 100644 --- a/src/UI/ColorPickerSheet.cpp +++ b/src/UI/ColorPickerSheet.cpp @@ -235,19 +235,23 @@ ColorPickerSheet::ColorPickerSheet(EditorSession &session, std::function<void(bo m_hex->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); fields->addWidget(label(QStringLiteral("#"), this), 3, 0); fields->addWidget(m_hex, 3, 1, Qt::AlignLeft); - auto *hint = new QLabel(QStringLiteral("Click the canvas to sample"), this); - QFont small = hint->font(); - small.setPixelSize(10); - hint->setFont(small); - hint->setForegroundRole(QPalette::PlaceholderText); auto *column = new QVBoxLayout; column->setSpacing(0); column->addLayout(top); column->addStretch(); column->addSpacing(12); column->addLayout(fields); - column->addSpacing(8); - column->addWidget(hint); + // A dialog covers the canvas: nothing to sample. + if (!m_session.pickingForDialog()) { + auto *hint = new QLabel(QStringLiteral("Click the canvas to sample"), this); + hint->setObjectName(QStringLiteral("pickerHint")); + QFont small = hint->font(); + small.setPixelSize(10); + hint->setFont(small); + hint->setForegroundRole(QPalette::PlaceholderText); + column->addSpacing(8); + column->addWidget(hint); + } auto *side = new QWidget(this); side->setFixedSize(180, int(fieldSize)); side->setLayout(column); diff --git a/src/UI/FloatingPanel.cpp b/src/UI/FloatingPanel.cpp index 6c0d9a4..1a558ed 100644 --- a/src/UI/FloatingPanel.cpp +++ b/src/UI/FloatingPanel.cpp @@ -65,8 +65,12 @@ void FloatingPanel::show(const QString &title, QWidget *content, QWidget *dock) showDocked(title, content, *dock); return; } + // A modal sheet blocks its window's panels, not its own. + QWidget *parent = QApplication::activeModalWidget() ? QApplication::activeModalWidget() : m_owner.window(); + if (m_panel && m_panel->parentWidget() != parent) + m_panel->setParent(parent, m_panel->windowFlags()); if (!m_panel) { - m_panel = new PanelWindow(*this, m_owner.window()); + m_panel = new PanelWindow(*this, parent); m_panel->setObjectName(m_name); } const bool wasVisible = m_panel->isVisible(); diff --git a/src/UI/JPEGExportSheet.cpp b/src/UI/JPEGExportSheet.cpp index 4be9be5..85dea71 100644 --- a/src/UI/JPEGExportSheet.cpp +++ b/src/UI/JPEGExportSheet.cpp @@ -1,8 +1,7 @@ #include "UI/JPEGExportSheet.h" #include "UI/KeyboardShortcuts.h" #include "UI/ByteCounts.h" -#include "UI/ColorPaletteControls.h" -#include <QColorDialog> +#include "UI/ColorPickerSheet+Dialog.h" #include <QFutureWatcher> #include <QHBoxLayout> #include <QLabel> @@ -83,10 +82,12 @@ protected: } }; -JPEGExportSheet::JPEGExportSheet(ExportRaster raster, std::function<void(std::optional<QByteArray>)> finish, QWidget *parent) - : QWidget(parent), m_raster(std::move(raster)), m_finish(std::move(finish)), m_wait(new QTimer(this)), m_preview(new Preview(this)), +JPEGExportSheet::JPEGExportSheet(ExportRaster raster, EditorSession &session, std::function<void(std::optional<QByteArray>)> finish, QWidget *parent) + : QWidget(parent), m_raster(std::move(raster)), m_session(session), m_finish(std::move(finish)), m_wait(new QTimer(this)), m_preview(new Preview(this)), m_spinner(new QProgressBar(m_preview)), m_quality(new QSlider(Qt::Horizontal, this)), - m_percent(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)), m_matteSwatch(new SwatchButton([this] { return PaletteColor{m_options.red, m_options.green, m_options.blue}; }, 3, 0, 0.5, this)), + m_percent(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)), m_matteSwatch(new DialogColorSwatch( + QStringLiteral("JPEG Background"), [this] { return PaletteColor{m_options.red, m_options.green, m_options.blue}; }, + [this](const PaletteColor &matte) { setMatte(matte); }, session, this)), m_failure(text(QString(), 13, QFont::Normal, QPalette::BrightText, this)), m_bytes(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)), m_note(text(QString(), 13, QFont::Normal, QPalette::PlaceholderText, this)), m_export(new QPushButton(QStringLiteral("Export…"), this)) { @@ -122,13 +123,12 @@ JPEGExportSheet::JPEGExportSheet(ExportRaster raster, std::function<void(std::op quality->addWidget(m_percent); column->addLayout(quality); - // Swift's ColorPicker: its label and a well, no opacity. + // Its label and the app's picker on a swatch. auto *matte = new QHBoxLayout; + matte->setSpacing(8); matte->addWidget(new QLabel(QStringLiteral("Background for transparency"), this)); m_matteSwatch->setObjectName(QStringLiteral("jpegMatte")); - m_matteSwatch->setFixedSize(36, 18); - m_matteSwatch->setAccessibleName(QStringLiteral("Background for transparency")); - connect(m_matteSwatch, &QAbstractButton::clicked, this, &JPEGExportSheet::pickMatte); + m_matteSwatch->setToolTip(QStringLiteral("Color that fills transparent areas")); matte->addWidget(m_matteSwatch); matte->addStretch(1); column->addLayout(matte); @@ -146,13 +146,17 @@ JPEGExportSheet::JPEGExportSheet(ExportRaster raster, std::function<void(std::op auto *cancel = new QPushButton(QStringLiteral("Cancel"), this); cancel->setObjectName(QStringLiteral("jpegCancel")); cancel->setAutoDefault(false); - connect(cancel, &QPushButton::clicked, this, [this] { m_finish(std::nullopt); }); + connect(cancel, &QPushButton::clicked, this, [this] { + DialogColorSwatch::closePicker(m_session); + m_finish(std::nullopt); + }); m_export->setObjectName(QStringLiteral("jpegExport")); m_export->setDefault(true); // Swift's configuredNativeShortcut: Return and Escape, as remapped. NativeShortcut::bind(*this, m_export, cancel); // Export rests until a result is ready. connect(m_export, &QPushButton::clicked, this, [this] { + DialogColorSwatch::closePicker(m_session); QSettings().setValue(qualityKey, m_options.quality); m_finish(m_result.value().data); }); @@ -172,6 +176,7 @@ JPEGExportSheet::JPEGExportSheet(ExportRaster raster, std::function<void(std::op JPEGExportSheet::~JPEGExportSheet() { m_cancelled->store(true); + DialogColorSwatch::closePicker(m_session); } // Swift's task(id:): the last request stops; this waits 200 ms. @@ -215,26 +220,17 @@ void JPEGExportSheet::encode() })); } -// The desktop's colour dialog, SwiftUI's colour panel, without alpha. -void JPEGExportSheet::pickMatte() +void JPEGExportSheet::setMatte(const PaletteColor &matte) { - auto *dialog = new QColorDialog(QColor::fromRgbF(float(m_options.red), float(m_options.green), float(m_options.blue)), this); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->setWindowTitle(QStringLiteral("Background for transparency")); - connect(dialog, &QColorDialog::colorSelected, this, [this](const QColor &picked) { - const QColor srgb = picked.toRgb(); - JPEGOptions next = m_options; - next.red = srgb.redF(); - next.green = srgb.greenF(); - next.blue = srgb.blueF(); - // Swift's onChange: the same colour asks nothing. - if (next == m_options) - return; - m_options = next; - m_matteSwatch->update(); - request(); - }); - dialog->open(); + JPEGOptions next = m_options; + next.red = matte.red; + next.green = matte.green; + next.blue = matte.blue; + // Equal options ask nothing, as Swift's task id. + if (next == m_options) + return; + m_options = next; + request(); } void JPEGExportSheet::synchronize() diff --git a/src/UI/JPEGExportSheet.h b/src/UI/JPEGExportSheet.h index 82a08d0..ace72d3 100644 --- a/src/UI/JPEGExportSheet.h +++ b/src/UI/JPEGExportSheet.h @@ -12,7 +12,8 @@ class QProgressBar; class QPushButton; class QSlider; class QTimer; -class SwatchButton; +class DialogColorSwatch; +class EditorSession; // Swift's JPEGExportSheet: quality, matte and the encoded preview. class JPEGExportSheet : public QWidget { @@ -21,17 +22,18 @@ public: // The last export's quality, where the next one starts. static const QString qualityKey; - JPEGExportSheet(ExportRaster raster, std::function<void(std::optional<QByteArray>)> finish, QWidget *parent = nullptr); + JPEGExportSheet(ExportRaster raster, EditorSession &session, std::function<void(std::optional<QByteArray>)> finish, QWidget *parent = nullptr); ~JPEGExportSheet() override; private: class Preview; void request(); void encode(); - void pickMatte(); + void setMatte(const PaletteColor &matte); void synchronize(); const ExportRaster m_raster; + EditorSession &m_session; const std::function<void(std::optional<QByteArray>)> m_finish; JPEGOptions m_options; std::optional<JPEGResult> m_result; @@ -44,7 +46,7 @@ private: QProgressBar *const m_spinner; QSlider *const m_quality; QLabel *const m_percent; - SwatchButton *const m_matteSwatch; + DialogColorSwatch *const m_matteSwatch; QLabel *const m_failure; QLabel *const m_bytes; QLabel *const m_note; diff --git a/tests/CanvasSizeSheetTests.cpp b/tests/CanvasSizeSheetTests.cpp index 496abbe..c1d1e95 100644 --- a/tests/CanvasSizeSheetTests.cpp +++ b/tests/CanvasSizeSheetTests.cpp @@ -1,8 +1,9 @@ #include "UI/CanvasSizeSheet.h" #include "UI/ColorPaletteControls.h" +#include "UI/ColorPickerSheet+Dialog.h" #include "UI/ColorPickerSheet.h" #include <QCheckBox> -#include <QColorDialog> +#include <QHBoxLayout> #include <QComboBox> #include <QLabel> #include <QPushButton> @@ -19,7 +20,7 @@ private slots: void fieldsSetTheDraftInItsUnits(); void anInvalidSizeRestsOK(); void theAnchorAndTheExtensionGoWithOK(); - void aCustomColorComesFromTheColorDialog(); + void aCustomColorComesFromTheAppsPicker(); void theAnchorsDrawTheirCircles(); void aFieldKeepsItsTypingAndItsNumber(); void aWheelOverUnitsCommitsTheTypingFirst(); @@ -34,12 +35,22 @@ CanvasDocument document(qint64 width, qint64 height) return canvas; } +// A session whose palette holds these two colours. +EditorSession &painted(EditorSession &session, PaletteColor foreground, PaletteColor background) +{ + session.setPaletteColor(foreground, false); + session.setPaletteColor(background, true); + return session; +} + struct Sheet { std::optional<std::optional<CanvasSizeOptions>> answer; + EditorSession session; CanvasSizeSheet sheet; explicit Sheet(qint64 width = 1200, qint64 height = 800, PaletteColor foreground = PaletteColor::black(), PaletteColor background = PaletteColor::white()) - : sheet(document(width, height), foreground, background, [this](std::optional<CanvasSizeOptions> options) { answer = options; }) + : sheet(document(width, height), painted(session, foreground, background), + [this](std::optional<CanvasSizeOptions> options) { answer = options; }) { sheet.show(); } @@ -187,47 +198,51 @@ void CanvasSizeSheetTests::theAnchorAndTheExtensionGoWithOK() } } -void CanvasSizeSheetTests::aCustomColorComesFromTheColorDialog() +void CanvasSizeSheetTests::aCustomColorComesFromTheAppsPicker() { Sheet shown; auto &extension = shown.find<QComboBox>("canvasExtension"); extension.setCurrentIndex(5); emit extension.activated(5); QVERIFY(shown.find<QWidget>("extensionColorRow").isVisible()); - auto &swatch = shown.find<SwatchButton>("extensionColor"); - QCOMPARE(swatch.accessibleName(), QString("Extension color")); + QCOMPARE(qobject_cast<QHBoxLayout *>(shown.find<QWidget>("extensionColorRow").layout())->spacing(), 8); + auto &swatch = shown.find<DialogColorSwatch>("extensionColor"); + QVERIFY(swatch.accessibleName() == QString("Extension Color") && swatch.toolTip() == QString("Color for the added canvas")); swatch.click(); - auto *dialog = shown.sheet.findChild<QColorDialog *>(); - QVERIFY(dialog && dialog->isVisible()); - QVERIFY(!dialog->testOption(QColorDialog::ShowAlphaChannel)); - QCOMPARE(dialog->currentColor(), QColor(Qt::white)); - dialog->setCurrentColor(QColor(0, 128, 255)); - // The swatch repaints with the colour chosen. - int painted = 0; - struct Spy : QObject { - int &count; - explicit Spy(int &count) : count(count) {} - bool eventFilter(QObject *, QEvent *event) override - { - count += event->type() == QEvent::Paint; - return false; - } - } spy(painted); - swatch.installEventFilter(&spy); - QApplication::processEvents(); - const int before = painted; - dialog->accept(); - QTRY_VERIFY(painted > before); - swatch.removeEventFilter(&spy); + QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (Extension Color)")); + QVERIFY(shown.session.colorPicker().value().original == PaletteColor::white()); + // OK takes the open picker's colour, closing it first. + const PaletteColor sky{0, 128 / 255.0, 1}; + shown.session.setColorPickerHSB(PickerHSB(sky)); + QCOMPARE(swatch.grab().toImage().pixelColor(17, 9), QColor(0, 128, 255)); shown.find<QPushButton>("canvasOK").click(); + QVERIFY(!shown.session.colorPicker()); const CanvasExtensionColor fill = shown.answer.value().value().fill.value(); - QCOMPARE(fill.red, 0.0); - QCOMPARE(fill.green, QColor(0, 128, 255).greenF()); - QCOMPARE(fill.blue, 1.0); - // Chosen again, the dialog opens on the colour kept. + QVERIFY(fill.red == 0 && fill.green == 128 / 255.0 && fill.blue == 1); + // Chosen again, the picker opens on the colour kept. swatch.click(); - QTRY_VERIFY(shown.sheet.findChild<QColorDialog *>() && shown.sheet.findChild<QColorDialog *>()->isVisible()); - QCOMPARE(shown.sheet.findChild<QColorDialog *>()->currentColor(), QColor(0, 128, 255)); + QVERIFY(shown.session.colorPicker().value().original == sky); + shown.session.setColorPickerHSB(PickerHSB(PaletteColor{1, 0, 0})); + shown.session.closeColorPicker(false); + QCOMPARE(swatch.grab().toImage().pixelColor(17, 9), QColor(0, 128, 255)); + // Another extension hides the swatch: the picker goes, colour kept. + swatch.click(); + shown.session.setColorPickerHSB(PickerHSB(PaletteColor{1, 0, 0})); + extension.setCurrentIndex(0); + emit extension.activated(0); + QVERIFY(!shown.session.colorPicker()); + extension.setCurrentIndex(5); + emit extension.activated(5); + QCOMPARE(swatch.grab().toImage().pixelColor(17, 9), QColor(255, 0, 0)); + // Cancel and the sheet going close it too. + swatch.click(); + shown.find<QPushButton>("canvasCancel").click(); + QVERIFY(!shown.session.colorPicker() && !shown.answer.value()); + EditorSession session; + auto gone = std::make_unique<CanvasSizeSheet>(document(10, 10), session, [](std::optional<CanvasSizeOptions>) {}); + gone->findChild<DialogColorSwatch *>()->click(); + gone.reset(); + QVERIFY(!session.colorPicker()); } void CanvasSizeSheetTests::theAnchorsDrawTheirCircles() @@ -291,7 +306,8 @@ void CanvasSizeSheetTests::halfwayNumbersRoundToEven() { CanvasDocument canvas{17, 19}; canvas.resolution = 16; - CanvasSizeSheet sheet(canvas, PaletteColor::black(), PaletteColor::white(), [](std::optional<CanvasSizeOptions>) {}); + EditorSession session; + CanvasSizeSheet sheet(canvas, session, [](std::optional<CanvasSizeOptions>) {}); auto &units = *sheet.findChild<QComboBox *>(QStringLiteral("canvasUnits")); const int inches = units.findText(QStringLiteral("Inches")); units.setCurrentIndex(inches); diff --git a/tests/DialogColorPickerTests.cpp b/tests/DialogColorPickerTests.cpp new file mode 100644 index 0000000..df533db --- /dev/null +++ b/tests/DialogColorPickerTests.cpp @@ -0,0 +1,171 @@ +#include "SelectionCanvasFixtures.h" +#include "UI/ColorPaletteControls.h" +#include "UI/ColorPickerSheet+Dialog.h" +#include "UI/ColorPickerSheet.h" +#include <QDialog> +#include <QLabel> +#include <QVBoxLayout> + +// Swift 1.3.5's dialog colours: the app's picker on a swatch. +class DialogColorPickerTests : public QObject { + Q_OBJECT +private slots: + void initTestCase() { QStandardPaths::setTestModeEnabled(true); } + void thePickerTellsTheDialogAsItMoves(); + void theSwatchOpensItFollowsItAndClosesIt(); + void thePanelOpensOverAModalSheet(); + void aDialogsPickerSamplesNothing(); +}; + +namespace { +const PaletteColor blue{0, 0, 1}; + +struct Heard { + std::vector<PaletteColor> colours; + std::function<void(const PaletteColor &)> listener() + { + return [this](const PaletteColor &colour) { colours.push_back(colour); }; + } +}; +} + +void DialogColorPickerTests::thePickerTellsTheDialogAsItMoves() +{ + EditorSession session; + Heard heard; + QSignalSpy changed(&session, &EditorSession::changed); + session.openDialogColorPicker(QStringLiteral("JPEG Background"), PaletteColor::white(), heard.listener()); + QCOMPARE(changed.count(), 1); + const ColorPickerState &picker = session.colorPicker().value(); + QVERIFY(picker.target.kind == ColorPickerTarget::Kind::dialog && picker.original == PaletteColor::white()); + QCOMPARE(picker.target.title(), QStringLiteral("Color Picker (JPEG Background)")); + QVERIFY(session.pickingForDialog()); + // One picker at a time: a second ask changes nothing. + Heard other; + session.openDialogColorPicker(QStringLiteral("Extension Color"), blue, other.listener()); + QVERIFY(changed.count() == 1 && session.colorPicker().value().target.dialogTitle == QStringLiteral("JPEG Background")); + // The working colour reaches the dialog, the session silent. + session.setColorPickerHSB(PickerHSB(blue)); + changed.clear(); + session.previewDialogColor(); + QVERIFY(changed.isEmpty() && heard.colours == std::vector<PaletteColor>{blue}); + // Cancel tells the original; OK the chosen colour. + session.closeColorPicker(false); + QVERIFY(!session.pickingForDialog() && heard.colours.back() == PaletteColor::white() && changed.count() == 1); + session.openDialogColorPicker(QStringLiteral("JPEG Background"), PaletteColor::white(), heard.listener()); + session.setColorPickerHSB(PickerHSB(blue)); + session.closeColorPicker(true); + QVERIFY(heard.colours.back() == blue && other.colours.empty()); + // Not a dialog's picker: nothing to tell, nothing told. + session.openColorPicker(false); + QVERIFY(session.colorPicker() && !session.pickingForDialog()); + const size_t told = heard.colours.size(); + session.previewDialogColor(); + QCOMPARE(heard.colours.size(), told); + // The picker's sheet asks no sample for a dialog. + QVERIFY(ColorPickerSheet(session, [](bool) {}).findChild<QLabel *>(QStringLiteral("pickerHint"))); + session.closeColorPicker(false); + session.openDialogColorPicker(QStringLiteral("JPEG Background"), PaletteColor::white(), heard.listener()); + QVERIFY(!ColorPickerSheet(session, [](bool) {}).findChild<QLabel *>(QStringLiteral("pickerHint"))); +} + +void DialogColorPickerTests::theSwatchOpensItFollowsItAndClosesIt() +{ + EditorSession session; + PaletteColor kept = PaletteColor::white(); + QWidget host; + auto *swatch = new DialogColorSwatch(QStringLiteral("Extension Color"), [&kept] { return kept; }, [&kept](const PaletteColor &colour) { kept = colour; }, + session, &host); + host.show(); + QVERIFY(QTest::qWaitForWindowExposed(&host)); + QVERIFY(swatch->size() == QSize(34, 18) && swatch->accessibleName() == QStringLiteral("Extension Color")); + // The brush's look: colour, white ring, black rim. + QImage drawn = swatch->grab().toImage(); + QVERIFY(drawn.pixelColor(0, 9).value() < 40 && drawn.pixelColor(1, 9) == QColor(Qt::white) && drawn.pixelColor(17, 9) == QColor(Qt::white)); + swatch->click(); + QVERIFY(session.pickingForDialog() && session.colorPicker().value().original == PaletteColor::white()); + // The dialog follows the working colour; the swatch repaints. + QCoreApplication::processEvents(); + PaintSpy painted(*swatch); + session.setColorPickerHSB(PickerHSB(blue)); + QVERIFY(kept == blue); + QTRY_VERIFY(painted.count > 0); + drawn = swatch->grab().toImage(); + QCOMPARE(drawn.pixelColor(17, 9), QColor(Qt::blue)); + // Rim, white ring, colour; a corner rounded by four. + QVERIFY(drawn.pixelColor(0, 9) == QColor(Qt::black) && drawn.pixelColor(1, 9) == QColor(Qt::white) && drawn.pixelColor(2, 9) == QColor(Qt::blue)); + QVERIFY(drawn.pixelColor(0, 0).red() > 220 && drawn.pixelColor(1, 1).red() < 20); + // A palette picker leaves the swatch alone. + session.closeColorPicker(true); + session.openColorPicker(true); + session.setColorPickerHSB(PickerHSB(PaletteColor::black())); + DialogColorSwatch::closePicker(session); + QVERIFY(kept == blue && session.colorPicker() && !session.pickingForDialog()); + session.closeColorPicker(false); + // Hidden, the swatch closes its picker, keeping the colour. + swatch->click(); + session.setColorPickerHSB(PickerHSB(PaletteColor{1, 0, 0})); + // A minimized window hides it spontaneously: the picker stays. + host.showMinimized(); + QCoreApplication::processEvents(); + QVERIFY(session.pickingForDialog()); + host.showNormal(); + swatch->hide(); + QVERIFY(!session.colorPicker() && kept == (PaletteColor{1, 0, 0})); +} + +void DialogColorPickerTests::thePanelOpensOverAModalSheet() +{ + EditorSession session; + QWidget window; + window.setLayout(new QVBoxLayout); + window.layout()->addWidget(new ColorPaletteControls(session, &window)); + window.show(); + QVERIFY(QTest::qWaitForWindowActive(&window)); + const auto panel = [] { + for (QWidget *widget : QApplication::topLevelWidgets()) + if (widget->objectName() == ColorPickerPanelController::identifier() && widget->isVisible()) + return widget; + return static_cast<QWidget *>(nullptr); + }; + session.openColorPicker(false); + QTRY_VERIFY(panel()); + QCOMPARE(panel()->parentWidget(), &window); + session.closeColorPicker(false); + QTRY_VERIFY(!panel()); + // Window-modal, as the controller's sheets: the panel moves under it. + auto *sheet = new QDialog(&window); + sheet->setWindowModality(Qt::WindowModal); + sheet->open(); + QTRY_COMPARE(QApplication::activeModalWidget(), sheet); + PaletteColor kept = PaletteColor::white(); + session.openDialogColorPicker(QStringLiteral("JPEG Background"), kept, [&kept](const PaletteColor &colour) { kept = colour; }); + QTRY_VERIFY(panel()); + QCOMPARE(panel()->parentWidget(), sheet); + QCOMPARE(panel()->windowTitle(), QStringLiteral("Color Picker (JPEG Background)")); + QTRY_COMPARE(QApplication::activeWindow(), panel()); + // It dies with the sheet; the next is the window's. + session.closeColorPicker(true); + QTRY_VERIFY(!panel()); + delete sheet; + session.openColorPicker(false); + QTRY_VERIFY(panel()); + QCOMPARE(panel()->parentWidget(), &window); + session.closeColorPicker(false); +} + +void DialogColorPickerTests::aDialogsPickerSamplesNothing() +{ + Canvas shown; + const QCursor dropper = CanvasView::eyedropperCursor(shown.canvas->devicePixelRatio()); + shown.session.openDialogColorPicker(QStringLiteral("JPEG Background"), PaletteColor::white(), [](const PaletteColor &) {}); + shown.canvas->synchronizeDisplay(); + QVERIFY(!shown.shows(dropper)); + shown.session.closeColorPicker(false); + shown.session.openColorPicker(false); + shown.canvas->synchronizeDisplay(); + QVERIFY(shown.shows(dropper)); +} + +QTEST_MAIN(DialogColorPickerTests) +#include "DialogColorPickerTests.moc" diff --git a/tests/JPEGExportSheetTests.cpp b/tests/JPEGExportSheetTests.cpp index 445ef71..5078983 100644 --- a/tests/JPEGExportSheetTests.cpp +++ b/tests/JPEGExportSheetTests.cpp @@ -1,7 +1,8 @@ #include "UI/ByteCounts.h" #include "UI/ColorPaletteControls.h" #include "UI/JPEGExportSheet.h" -#include <QColorDialog> +#include "UI/ColorPickerSheet+Dialog.h" +#include <QHBoxLayout> #include <QLabel> #include <QProgressBar> #include <QPushButton> @@ -19,7 +20,7 @@ private slots: void theSheetShowsTheRasterAndWaitsForItsPreview(); void aChangeSupersedesThePreview(); void aSupersededEncodingNeverLands(); - void theMatteComesFromTheColorDialog(); + void theMatteComesFromTheAppsPicker(); void exportKeepsTheQualityAndCancelDoesNot(); void theSavedQualityIsReadAsSwiftReadsIt(); void anEncodingThatFailsIsShown(); @@ -39,9 +40,10 @@ ExportRaster halfRed(int width = 64, int height = 32) struct Sheet { std::optional<std::optional<QByteArray>> answer; + EditorSession session; JPEGExportSheet sheet; explicit Sheet(ExportRaster raster = halfRed()) - : sheet(std::move(raster), [this](std::optional<QByteArray> data) { answer = std::move(data); }) + : sheet(std::move(raster), session, [this](std::optional<QByteArray> data) { answer = std::move(data); }) { sheet.show(); } @@ -162,36 +164,49 @@ void JPEGExportSheetTests::aSupersededEncodingNeverLands() QCOMPARE(shown.find<QLabel>("jpegPercent").text(), QString("50%")); } -void JPEGExportSheetTests::theMatteComesFromTheColorDialog() +void JPEGExportSheetTests::theMatteComesFromTheAppsPicker() { QSettings().remove(JPEGExportSheet::qualityKey); Sheet shown; QTRY_VERIFY(shown.ready()); - shown.find<SwatchButton>("jpegMatte").click(); - auto *dialog = shown.sheet.findChild<QColorDialog *>(); - QVERIFY(dialog && dialog->isVisible()); - QVERIFY(!dialog->testOption(QColorDialog::ShowAlphaChannel)); - QCOMPARE(dialog->currentColor(), QColor(Qt::white)); + auto &matte = shown.find<DialogColorSwatch>("jpegMatte"); + QVERIFY(matte.accessibleName() == QString("JPEG Background") && matte.toolTip() == QString("Color that fills transparent areas")); + QCOMPARE(qobject_cast<QHBoxLayout *>(shown.sheet.layout()->itemAt(3)->layout())->spacing(), 8); + matte.click(); + QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (JPEG Background)")); + QVERIFY(shown.session.colorPicker().value().original == PaletteColor::white()); // The same colour asks for nothing. - dialog->setCurrentColor(Qt::white); - dialog->accept(); + shown.session.closeColorPicker(true); QVERIFY(shown.ready() && !shown.find<QTimer>("jpegWait").isActive()); - shown.find<SwatchButton>("jpegMatte").click(); - dialog = shown.sheet.findChild<QColorDialog *>(); - dialog->setCurrentColor(QColor(0, 0, 255)); - // The well repaints with its colour. - PaintCount painted(shown.find<SwatchButton>("jpegMatte")); - dialog->accept(); - QTRY_VERIFY(painted.count > 0); + // The working colour reaches the preview; Cancel takes it back. + matte.click(); + shown.session.setColorPickerHSB(PickerHSB(PaletteColor{0, 0, 1})); QVERIFY(!shown.ready()); - QCOMPARE(shown.find<SwatchButton>("jpegMatte").grab().toImage().pixelColor(18, 9), QColor(0, 0, 255)); + QCOMPARE(matte.grab().toImage().pixelColor(17, 9), QColor(0, 0, 255)); + shown.session.closeColorPicker(false); + QCOMPARE(matte.grab().toImage().pixelColor(17, 9), QColor(Qt::white)); + QTRY_VERIFY(shown.ready()); + // Export closes an open picker, keeping its colour. + matte.click(); + shown.session.setColorPickerHSB(PickerHSB(PaletteColor{0, 0, 1})); QTRY_VERIFY(shown.ready()); shown.find<QPushButton>("jpegExport").click(); + QVERIFY(!shown.session.colorPicker()); const QImage written = QImage::fromData(shown.answer.value().value(), "jpeg"); const QColor left = written.pixelColor(8, 16), right = written.pixelColor(56, 16); QVERIFY(left.blue() > 240 && left.red() < 16 && left.green() < 16); QVERIFY(right.red() > 240 && right.blue() < 16); QCOMPARE(shown.answer.value().value(), ImageExporter::jpeg(halfRed(), {.red = 0, .green = 0, .blue = 1}).data); + // Cancel and the sheet going close it too. + Sheet cancelled; + cancelled.find<DialogColorSwatch>("jpegMatte").click(); + cancelled.find<QPushButton>("jpegCancel").click(); + QVERIFY(!cancelled.session.colorPicker() && !cancelled.answer.value()); + EditorSession session; + auto gone = std::make_unique<JPEGExportSheet>(halfRed(), session, [](std::optional<QByteArray>) {}); + gone->findChild<DialogColorSwatch *>()->click(); + gone.reset(); + QVERIFY(!session.colorPicker()); } void JPEGExportSheetTests::exportKeepsTheQualityAndCancelDoesNot() diff --git a/tests/ProjectResizeTests.cpp b/tests/ProjectResizeTests.cpp index 13ac882..841d891 100644 --- a/tests/ProjectResizeTests.cpp +++ b/tests/ProjectResizeTests.cpp @@ -1,7 +1,10 @@ #include "DialogDesk.h" #include "IO/ProjectController.h" #include "UI/CanvasSizeSheet.h" +#include "UI/ColorPaletteControls.h" +#include "UI/ColorPickerSheet+Dialog.h" #include "UI/ColorPickerSheet.h" +#include <QComboBox> #include "UI/ImageSizeSheet.h" #include <QDialog> #include <QPushButton> @@ -15,6 +18,7 @@ private slots: void aCancelledOrRefusedSheetChangesNothing(); void theImageSizeSheetScalesThePixelsAsOneStep(); void aResizeThatFailsIsExplained(); + void theSheetsColourOpensTheAppsPickerOverIt(); }; namespace { @@ -159,5 +163,36 @@ void ProjectResizeTests::aResizeThatFailsIsExplained() QVERIFY(alerts.seen.last().startsWith("alert|2|Couldn’t change canvas size|")); } +void ProjectResizeTests::theSheetsColourOpensTheAppsPickerOverIt() +{ + Desk desk; + new ColorPaletteControls(desk.session, &desk.window); + desk.controller.canvasSize(desk.finished()); + auto &extension = *desk.dialog().findChild<QComboBox *>(QStringLiteral("canvasExtension")); + extension.setCurrentIndex(5); + emit extension.activated(5); + desk.dialog().findChild<DialogColorSwatch *>()->click(); + // The panel sits over the sheet and takes the keys. + QWidget *panel = nullptr; + QTRY_VERIFY((panel = desk.dialog().findChild<QDialog *>(ColorPickerPanelController::identifier())) && panel->isVisible()); + QTRY_COMPARE(QApplication::activeWindow(), panel); + desk.session.setColorPickerHSB(PickerHSB(PaletteColor{0, 0, 1})); + // Escape there cancels the picker; the sheet stays. + QTest::keyClick(panel, Qt::Key_Escape); + QTRY_VERIFY(!desk.session.colorPicker() && !panel->isVisible()); + QVERIFY(desk.dialog().isVisible() && !desk.done); + desk.dialog().findChild<DialogColorSwatch *>()->click(); + QTRY_VERIFY(panel->isVisible()); + desk.session.setColorPickerHSB(PickerHSB(PaletteColor{0, 0, 1})); + panel->findChild<QPushButton *>(QStringLiteral("pickerOK"))->click(); + QVERIFY(!desk.session.colorPicker()); + desk.type("canvasWidth", "200"); + QTRY_VERIFY(desk.done); + // The added canvas takes the colour picked. + const ImageLayer bottom = desk.session.document().value().layers.front(); + QCOMPARE(bottom.asset.value().image().pixelColor(0, 25), QColor(0, 0, 255)); + QVERIFY(!desk.session.isProjectBusy()); +} + QTEST_MAIN(ProjectResizeTests) #include "ProjectResizeTests.moc" diff --git a/tests/RemappedKeysTests.cpp b/tests/RemappedKeysTests.cpp index b1369cc..b11edf0 100644 --- a/tests/RemappedKeysTests.cpp +++ b/tests/RemappedKeysTests.cpp @@ -226,10 +226,10 @@ void RemappedKeysTests::everySheetBindsSwiftsKeys() red.fill(Qt::red); session.insert(ImportedImage(red, red, "Red")); const CanvasDocument canvas = session.document().value(); - QCOMPARE(keys(CanvasSizeSheet(canvas, PaletteColor::black(), PaletteColor::white(), [](std::optional<CanvasSizeOptions>) {})), both); + QCOMPARE(keys(CanvasSizeSheet(canvas, session, [](std::optional<CanvasSizeOptions>) {})), both); QCOMPARE(keys(ImageSizeSheet(canvas, [](std::optional<ImageSizeOptions>) {})), both); QCOMPARE(keys(TrimSheet([](std::optional<TrimOptions>) {})), both); - QCOMPARE(keys(JPEGExportSheet(ExportRaster{red, 72}, [](std::optional<QByteArray>) {})), both); + QCOMPARE(keys(JPEGExportSheet(ExportRaster{red, 72}, session, [](std::optional<QByteArray>) {})), both); QCOMPARE(keys(ColorPickerSheet(session, [](bool) {})), both); QCOMPARE(keys(NewCanvasSheet(session, [](int, int) {}, [] {})), QSet<QString>{"Ctrl+K"}); QCOMPARE(keys(TransformInspector(session)), both); diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 9b923ce..1c28efd 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -178,6 +178,7 @@ compositor_test(TextFontControlsTests) compositor_test(ColorRangeTests) compositor_test(ColorRangeCanvasTests) compositor_test(ColorRangeSheetTests) +compositor_test(DialogColorPickerTests) # It calls the colour-range kernel itself. target_include_directories(ColorRangeTests PRIVATE ${OMAPHOTO_KERNELS}) compositor_test(DitherTests)