14.4: Image › Rotate Canvas 90° Clockwise and Counterclockwise; quarter turns on whole pixels copy exactly

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 03:55:25 -04:00
1 parent 28cbe856e6
commit d8bb7b38b2
21 files changed
+419 -54

No files matched your search

+3 -2
View File
@@ -117,7 +117,7 @@ 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 <Kind>` or `Show <Kind>`) toggles the effect in one step and rests while `canEditLayers` does not hold; the name (11 pixels, `WindowText`, `PlaceholderText` while hidden) ends in an ellipsis when narrow, as `.byTruncatingTail`; the row's tooltip is Swift's with Alt for Option, its accessible name `<Kind> effect`, and an accessible interface of Swift's group role chooses the effect on the press action (`accessibilityPerformPress`). The rows are made anew only when the kinds change, else reconfigured in place; the old ones are hidden and deleted later, because a row's own double click may cancel a new effect's panel on the same layer, whose Cancel removes that effect and rebuilds the rows under the press, where Swift's reference counting keeps the row alive. A chosen effect's row shows `Highlight` at 30%, and while one is chosen the cell's selection hides, as Swift's table selection does. Presses follow Swift's `mouseDown` order: Alt without Ctrl on the strip (the bottom 10 points of the taller row, over its last effect; Swift's `min(10, height / 3)` is dead, rows being at least 52) clips through `clickRow`; on an effect, Alt without Ctrl arms a copy while layers can be edited, the release choosing the effect and a move past `startDragDistance` with Alt still held starting a `QDrag` of `UUID:Kind` under `effectType` with the list's token, offering Copy alone under the duplicate cursor, as the row's Alt drag does; any other left press chooses the effect, a double click editing it (Swift's `clickCount > 1`); the eye's Alt presses are the row's, ignored in its filter so they go on to it (Swift's `hitTest`). An effect dropped from the same list (Swift compares the source's session) lands on the row under the pointer, filled, as a copy, where `canCopyEffect` allows. A left press below every row lets go of the chosen effect and, without Ctrl or Shift, of every layer, as Swift's table with `allowsEmptySelection` does (a gap the list of 4.6 left). Known difference: the two points between rows take no press, where Swift's rows take their spacing. Swift has no tests for the list's effects: `LayerEffectRowTests` pins them, with `QDrag::exec` defined in its binary as in `LayerDragGestureTests`.
- Remove Background (9.7): Apple's Vision has no Linux twin, so `Document/SubjectRemoval` runs U²-Net (Qin et al., Apache-2.0, its text in `licenses/U-2-Net.txt`) on ONNX Runtime (MIT), as rembg converted it (MIT): `u2net.onnx`, 175,997,641 bytes, SHA-256 `8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491`, the MD5 rembg pins. Chosen over `u2netp` (poorer edges), `silueta` (no clear source) and IS-Net's `isnet-general-use` (sharper, but the DIS repository puts only its code and metric under Apache-2.0 and its DIS5K training set under terms of its own, so the weights' terms are unclear); 90 ms a mask on 32 cores, measured. The model is found with `QStandardPaths::locate(GenericDataLocation, "omaphoto/u2net.onnx")`, so a package installs it as `share/omaphoto/u2net.onnx` and the dev image carries it in `/usr/local/share`. `Dockerfile.dev` fetches it and Microsoft's ONNX Runtime 1.21.0 build (Ubuntu 24.04 packages none; Ubuntu 25.04 on and Arch ship theirs with the same CMake config), both by pinned checksum; that build's CMake config goes to `/usr/local/lib/cmake`, which Ubuntu's CMake searches, and its library to `lib64`, where the config looks for it; CMake links `onnxruntime::onnxruntime`. One `Ort::Session` serves the process, loaded on first use; a load that throws leaves it unloaded, so the next use tries again. Every model step passes through `vision`, where ONNX Runtime's exceptions become `SubjectRemovalError(model)` and a failed allocation (the input buffer, the runtime wrapper's own vectors) becomes `ExportError(render)`, so none passes a worker's catch. `SubjectRemovalError` twins `SubjectRemoval.Failure`: `noSubject` with Swift's words, and a `model` kind for a missing model (naming every folder looked in) or ONNX Runtime's own message, where Swift passes on Vision's error. The input is the paper's and rembg's: the image premultiplied (clear reads as black), scaled to 320 by 320, divided by its brightest byte, then ImageNet's mean and spread; the first output is stretched from its lowest to its highest value, truncated to bytes as rembg does, and scaled to the layer's grid. An output with no value at or above one half is Swift's `noSubject` (flat images give under 0.03, measured). Known difference: U²-Net's mask is made at 320 points, so Basic's edges are softer than Vision's; Advanced's Refine pulls them onto the layer's own. The raw mask of the last image is kept by `cacheKey`, Swift's `MaskCache`. `Document/GuidedMatte` twins Swift's file: `box` keeps Swift's float sums, the column pass running a row at a time; `filter` frees what it no longer needs; `levels` scales through premultiplied RGBA (Qt's smooth scaling of Grayscale8 strays by a level shrinking and up to ten enlarging, probed) and reads gray over black through `qGray`; `refine` draws back up with `SmoothTransformation`, and the model's mask is enlarged through premultiplied RGBA too. `refined` does Core Image's Shift Edge (a Gaussian with the edge repeated through `PixelAdjust::gaussianBlur`, then Swift's clamp-and-scale cut) and Contrast in float over bytes, rounded once; the blur rounds to bytes first, a level at most. Contrast runs unconditionally, being the identity at 0. The preview (`run`) refines a copy 1400 long at most and multiplies each premultiplied byte by the mask into pixels of its own, copied before the loop so a copy that finds no memory is `ExportError(render)` (a shared image would copy inside the loop and come back null); the commit (`subjectMask`) refines at full size, multiplies in a mask of the layer's own grid (another grid is a `logic_error`, never read past) (disabled or not, which is then shown, as Swift's), blends through a selection over that mask or over white, and writes the mask in one `Remove Background` step with the mask chosen; its worker flattens painted pixels and masks. A fix beyond Swift: a placed mask's pixels were dropped but its placement kept, so the new mask, made in the layer's grid, sat at the old placement; the twin clears the placement. The filter sheet shows Swift's words, Quality as two touching checkable buttons (the label hidden, its accessible name) and Advanced's Refine, Contrast and Shift Edge rows with Swift's help, made once and shown while Advanced is chosen, as Swift's `if`. Equivalent mutants: the cache's key (only the time differs) and the commit worker's catch of `SubjectRemovalError` (after its preview succeeded, only ONNX Runtime failing mid-run could throw there). Tests: `GuidedMatteTests` (the box against plain sums, the filter against He's steps in doubles, overshoot included), `SubjectRemovalTests` (Swift's `visionRequestRunsOnAnImage` among them, on a red disc the model finds), `SubjectRemovalModelTests` (a binary of its own: a broken model, a missing one, then the real one), `SubjectRemovalFailureTests` (a starved preview and a starved model input, under `AddressSpaceLimit`, glibc's mmap threshold fixed so large requests map memory the limit counLine truncated
- Crop (10.1): `Document/Crop.h/.cpp` twin Swift's file, `Crop+Session.h` its session half. `CropGeometry` is a namespace; `CropDrag::Mode` is a kind and an index, as `TransformDrag::Mode` twins an enum with payloads; `CropSnap`'s `nearest` and Swift's local `shift` are file helpers; `cropSnapTargets` returns `SnapGuides`, Swift's tuple. `valid` and `commitCrop` read the frame normalised, as `CGRect`'s `width` and `minX` read it standardised; `valid`'s `isFinite` terms are left out, since the ranges refuse NaN and infinities. `cropRect`, `cropRatioChoice` and `cropError` are getters and setters that notify on a change. `commitCrop` takes a completion callback, posted on every path; the snapshot is taken and the worker started before anything changes, the resize runs through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, the worker catching `ProjectError` and `ExportError` (`CanvasResizer::resize` turns a `std::bad_alloc` of its records and maps into `ExportError(render)`, as 9.7's `vision` does), and the result is moved out (`takeResult`) and installed through `applyDocumentSize`, one `Crop` step; memory that runs out while the snapshot is taken or the result lands sets `cropError` to the render error and changes nothing else, so the project is never left busy. Only a crop that landed drops its frame (Swift drops it first); a failure sets `cropError` to the error's text and keeps it, and the project is freed after either, Swift's `defer`. Hooks: `selectTool` cancels on a change of tool and opens the Crop tool on the whole canvas, Free, when no frame is drawn; `restore`, `beginTransform`, `importImages`, `beginLevels`, `beginFilter` and `ProjectController::begin` cancel; `installProject` and `clearProject` drop the frame and announce it with their own signal; `canEditLayers` reads `cropRect`, `canUseHistory` does not, as in Swift. The canvas's gestures live in `EditorCanvas+Crop.cpp`. A press on a grip resizes (the first region holding the point, corners first, Swift's `first(where:)`), one inside a drawn frame that is not the whole canvas moves it, any other drops the frame and draws anew; `CGRect.contains` leaves out the far edges, so the file's own `contains` does too, where `QRectF::contains` takes them. The snap is built at the press, 8 view points (`cropSnapDistance`); Alt keeps the middle still, Ctrl leaves the edges unsnapped (Swift's Control, as the Move tool's snapping reads it), and either key changing mid-drag reshapes the frame at the last hover point, as Swift's monitor reads the mouse. Swift's `?? CGRect(origin: pixel, size: .zero)` and its `if let cropSnap` are dead (the Crop tool always shows a frame; the snap is built with the drag) and are checked reads. A drag goes on under the left button alone, with the Crop tool, a free project and a document, as Swift's `mouseDragged`; a move without the left button ends it, since Qt can lose a release. `endCropDrag` is Swift's `cropDrag = nil` with its `didSet`: the release, a lost focus, Escape (cancel) and Return or Enter (commit) end the drag and free its cursor. Wheels and pinches rest mid-drag. The drag keeps the cursor its press showed in `m_dragCursor` (Swift's `.current`, with the cursor rects disabled), taken before a new frame drops the old one. Cursors: picking first, then the open hand under Space, then over a grip Qt's resize shape for Swift's frame position (top left and bottom right `SizeFDiagCursor`, top right and bottom left `SizeBDiagCursor`, top and bottom `SizeVerCursor`, left and right `SizeHorCursor`; Qt's shapes say no inward or outward), elsewhere the crosshair. `TransformOverlay` gains `cropViewRect`, `cropResizeRegions` (a `CropRegion` struct for Swift's tuple) and `drawCrop`: the canvas outside the frame under 60% black (a `QPainterPath`'s own odd-even rule), a white frame, the thirds at 40% white in one path so their crossings paint once, and 8-point white grips with black rims, antialiased, with CoreGraphics' butt caps and miter joins. Known difference: at 1× QPainter strokes one-point lines through its cosmetic stroker, which draws no joins, so the outer corner pixels of the frame and the grips stay partly clear, as the text box's (8.9c). The dimming spans the canvas, so a frame that appears or goes repaints the canvas; a frame that changes repaints the old and the new frame with their grips (`drawnRect`), since outside both the dimming stays. That is the twin's `cropDraggingRedrawsOnlyTheOverlay`: Qt has no layers, so the union of the frames repaints, pixels included, never the canvas. Under the Crop tool `renderBounds` takes the frame's union, so a frame past the canvas shows the checkerboard it would take in and redraws the canvas, as in Swift. `CropControls` is the tool's `ToolHeaderBar`: its row spaced 14 as Swift's `HStack`, Swift's labelled picker a "Ratio" `QLabel` (its combo box's buddy) and a `QComboBox` sharing 170 points, a choice that changes the ratio calling `changeCropRatio` (Swift's Line truncated
- Canvas and image size (10.2): `flipCanvas` sits in `LayerFlip.cpp`, as Swift's extension: after `commitTransform` and `cancelCrop` it asks `canEditLayers`, mirrors every layer's transform and every placed mask across the canvas's middle and the selection's outline through a `QTransform` (the path keeps its fill rule), one `Flip Canvas Horizontal` or `Flip Canvas Vertical` step; Swift's `finishOpacityEdit` is left out, since `commitTransform` has run it. `UI/CanvasSizeSheet` is Swift's form in a 450-point column: the title at 17 pixels, bold, the current size grouped through the English locale, the memory as Foundation's `ByteCountFormatter` counts it for memory (binary units: bytes, whole KB, MB to one place, GB to two, trailing zeros dropped; `UI/ByteCounts` since 10.3), a `Mid` divider, Units as a labelled `QComboBox`, Width and Height as `PickerField`s (a readable number applies on Return or on leaving, other text goes back, Swift's value binding; printed as Swift's `.number` to three places, none trailing, never grouped, as every field of the twin, halves rounding to even as Foundation's formatter rounds them), the Relative and Lock toggles (locking takes the width's proportions at once, Swift's `onChange`), the New line or the invalid note in `BrightText`, the anchor grid (nine 25-point buttons in an exclusive `QButtonGroup`, each a circle filled in the accent when chosen for Swift's `circle` and `circle.fill`, with Swift's help as the tooltip and accessible name and "Selected" as the accessible description, Swift's accessibility value), the anchor's name and note, the Canvas extension picker, and for Custom 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`.
- Canvas and image size (10.2): `flipCanvas` sits in `LayerFlip.cpp`, as Swift's extension: after `commitTransform` and `cancelCrop` it asks `canEditLayers`, mirrors every layer's transform and every placed mask across the canvas's middle and the selection's outline through a `QTransform` (the path keeps its fill rule), one `Flip Canvas Horizontal` or `Flip Canvas Vertical` step; Swift's `finishOpacityEdit` is left out, since `commitTransform` has run it. `UI/CanvasSizeSheet` is Swift's form in a 450-point column: the title at 17 pixels, bold, the current size grouped through the English locale, the memory as Foundation's `ByteCountFormatter` counts it for memory (binary units: bytes, whole KB, MB to one place, GB to two, trailing zeros dropped; `UI/ByteCounts` since 10.3), a `Mid` divider, Units as a labelled `QComboBox`, Width and Height as `PickerField`s (a readable number applies on Return or on leaving, other text goes back, Swift's value binding; printed as Swift's `.number` to three places, none trailing, never grouped, as every field of the twin, halves rounding to even as Foundation's formatter rounds them), the Relative and Lock toggles (locking takes the width's proportions at once, Swift's `onChange`), the New line or the invalid note in `BrightText`, the anchor grid (nine 25-point buttons in an exclusive `QButtonGroup`, each a circle filled in the accent when chosen for Swift's `circle` and `circle.fill`, with Swift's help as the tooltip and accessible name and "Selected" as the accessible description, Swift's accessibility value), the anchor's name and note, the Canvas extension picker, and for Custom a label and a `DialogColorSwatch` on the app's picker (13.4a); then Cancel and the default OK, which rests while the draft is invalid, so Swift's guard in its action is left out. Units, Relative and Lock change only through their own controls, so nothing writes them back, and so does the image sheet's Resample. A wheel over Units changes the unit without the focus, so both sheets take the focus to Units first: a size still being typed commits in the unit it meant. `UI/ImageSizeSheet` is Swift's sheet: Pixels and Percent only while resampling, Width and Height converting by unit (Swift's binding refuses a number that is not positive; without resampling a size sets the resolution), Lock, disabled without resampling, the resolution with its unit (in inches or centimetres a new resolution scales the pixels, Swift's `onChange`), Resample (turned off, the pixels go back, the lock closes and Pixels or Percent becomes Inches), Sampling while resampling, the explanation, the result or the invalid note, Cancel and the default Resize. The controller's `canvasSize` and `imageSize` live in `IO/ProjectController+Resize.cpp` (with 10.3's exports the controller's file would pass 500 lines) and take a completion callback: refused without a window, a document or `begin`, as Swift's guards; the sheet is a window-modal `QDialog` over the window (`sheet`, Swift's `beginSheet`), whose Escape and close button cancel; the project stays busy until the sheet answers and the resize lands, or its alert is dismissed, Swift's `defer`. `resizeProject` takes the snapshot and starts the worker before anything changes, the worker catching `std::runtime_error` (both of the resizers' errors), and moves the result out and lands it through `applyDocumentSize` or `applyImageSize` (staged since 10.1); memory that runs out while the snapshot is taken or the result lands is explained as the render error. The alert opens from the handler, once the unwinding has freed what failed. The IO file includes the sheets, as Swift's controller hosts them. The Image menu gains Canvas Size… (Ctrl+Alt+C) and Image Size… (Ctrl+Alt+I), enabled with a document while the controller can start, and Flip Canvas Horizontal and Vertical, enabled by `canEditLayers`. Tests: `CanvasSizeSheetTests`, `ImageSizeSheetTests`, `ProjectResizeTests` (the sheets over a window, typed and answered by keys, a cancel, a refusal, a failure's alert), `ProjectResizeFailureTests` (a binary of its own, `main` as `CropFailureTests`': the image resizer, the snapshot, the worker and the landing each run out), the canvas flips in `LayerFlipTests`, the entries in `ImageMenuTests` (moved out of `CompositorMenusTests` at 500 lines, 14.4).
- Exports (10.3): `IO/ProjectController+Export.cpp` holds `exportPNG` and `exportJPEG`, 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 `JPEGPreview` on gray 0.12 (13.4b; while updating, a 64-point plate of the window colour at 85%, Swift's regular material, behind a 32-point busy `QProgressBar`), Quality as a slider in hundredths with its percent (`lround`, Swift's `rounded()`) 45 points wide and right-aligned, the matte as a label and a `DialogColorSwatch` on the app's picker (13.4a; the same colour asks nothing, as Swift's `onChange`), then one row of the raster's size grouped with "px · sRGB", the error in `BrightText`, or the file's size, or "Updating…", and Cancel and the default Export…, enabled only while a ready result stands. Swift's `.task(id: options)` is `request`: it sets the last request's flag, clears the error and restarts `jpegWait`, 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_erLine truncated
- File and picture drops (10.4): `IO/ImageFileDrop` twins Swift's enum as a namespace. `importProviders` takes the drop's `QMimeData`, where Swift takes item providers: each local file URL is a file, in order; a URL that is no file counts as unreadable, unless no file came at all and the drop holds a picture (a browser's or a screenshot's), whose bytes are copied to `Dropped-<UUID>` in the temporary folder, as Swift's `temporaryFile` copies a promised file, the format chosen in Swift's order (PNG, JPEG, HEIC, TIFF, then any `image/` type) and the suffix from Qt's MIME database. The files go to the workspace's `receive` or, without a workspace, to the session's `importImages`; once that has run, a drop with anything unreadable adds Swift's words to the session's import error, after the importer's own ("from the file manager" where Swift says "from Finder"). Swift's `projects:` parameter has no caller and is left out. `holdsImages` is Swift's drop types but the layer row's: a local file or a picture. `receiveProviders` copies layer rows as before and hands anything else to `importProviders` one provider at a time (`providers`: a URL each, or, when no file came, one item holding the preferred picture alone, so a lazy drag is asked for nothing else, as Swift asks for one format; its web addresses are left behind, since alone they read as unreadable either way), each after the last has run, for the tab then in front, whose session hears what could not be read and stays alive until its import has run; so an import error holds the next file until it is dismissed, Swift's gate between providers. A copied picture that cannot be written whole (its flush checked, since `QFile` buffers) is removed and counts as unreadable; a failed flush fails `QFile::remove` too, so the copy is closed and unlinked by path. An open alert shows its message as it stands (`showAlert` updates it), as SwiftUI's alert re-reads it. The canvas, the tab buttons and the New canvas button take files and pictures as they take rows (a canvas without a workspace under Swift's session guards, Levels, busy, the new-canvas sheet, the importer and a rename, importing into its own session); Alt refuses only a row, as Swift's `validateDrop`. Tests: `ImageFileDropTests`.
- Slider snapping (10.5): `UI/SliderSnap` twins Swift's enum as a namespace. Swift swaps `NSSliderCell.startTracking` so that a press on the track sets the value whose knob is centred there before tracking begins; the twin's `install` puts one application event filter in place, once, as Swift's lazy static swaps once: a left press on an enabled horizontal `QSlider` with a range, outside its knob and with travel for it, sets the slider down, then sets the value under the press (`QStyle::sliderValueFromPosition` over the groove less the knob, the knob's half-width off, flipped for an inverted slider), and presses where the knob now is, the original press consumed, so Qt drags the knob from there even when the slider's owner rounds the value and moves it (a filter's field); a press made with another button held is left to Qt, which refuses it. So editing begins before the value, Swift's order (the opacity slider's drag opens its one step first), and a press on the knob drags it from where it is. QSlider keeps its style option protected, so the filter builds one as `initStyleOption` does. Qt's own absolute set (`SH_Slider_AbsoluteSetButtons`) would set the value before the press and recentre a pressed knob, so it is not used, but it is the test's oracle for where the knob lands. `main` installs it after the theme. Tests: `SliderSnapTests` (Swift's `clickingTheTrackSnapsTheKnobAndStillEditsTheValue`: the value under the press at once, editing begun first, a drag going on, the release; the knob, other buttons, a disabled, vertical, empty, inverted and narrow slider). Equivalent mutant: the groove's own offset, zero under Fusion, the app's one style. The call in `main` is not under test, as the theme's construction there is not.
@@ -171,7 +171,7 @@ Platform adaptations, each forced by a missing Qt analog:
- New Canvas presets and the Quick Look preview (13.7, Swift 1.3.6–1.3.7: `aaf3dc0`, `14aad18`, `4868465`): `NewCanvasSheet` twins `aaf3dc0`: the subtitle goes; beside "New canvas" a 28-point `QToolButton` (`presetSizes`, an instant popup) draws Swift's three 2.5-point dots in `WindowText`, flush with the fields' right edge, its own `paintEvent` drawing no menu arrow; Swift's tooltip and the accessible name "Preset sizes". `CanvasPreset` (in `NewCanvasSheet.h`, as Swift's file holds it) keeps Swift's `groups` and `all`; the menu is Custom, then each group after a separator, checkable `QAction`s in an exclusive `QActionGroup`, each holding its index in `all` (Custom −1). `checkPreset` checks the first preset whose pixels the fields make (since 14.3; the fields' exact text until then), else Custom, on every edit of either field, as Swift's binding reads it; choosing a preset fills both fields, in the unit in use; Custom fills nothing and checks the match again, since Qt checks a triggered action where Swift's picker sets nil and re-reads. Known difference: Swift's label is clickable ten points past the dots into the padding; the twin's button ends at the content's edge. The Quick Look preview twins `14aad18` and `4868465`: `ImageExporter::quickLookImages` returns nothing past 50 megapixels (logged as info), else renders the snapshot and `scaledJPEG`s it: the long side at most 1024, sides rounded, a pixel at least, `QImage::scaled` with `SmoothTransformation` (area averaging when shrinking) for CoreGraphics' high quality, drawn on white into `RGB888`, JPEG quality 80, 72 dpi as `CGImageDestination` writes; a render or scale that fails logs a warning on `omaphoto.io` and saves without one, Swift's `try?`. `QuickLookImages` (Swift's, in `ImageExporter.swift`) is declared in `ProjectStore.h`, since the exporter includes the store. `ProjectStore::save` takes it optionally: with one, the staged folder gains `QuickLook/Preview.jpg`, written and synced with its folder before the project's folder is synced and swapped in; without one the swapped-in project has none. Loading reads only the manifest and `images/`, so the folder is ignored, and `ProjectDigest` reads neither, so writing or deleting it is no external change (a test rewrites and deletes it under the digest). The controller's save worker makes the preview and hands it to the store. `docs/writing-comp-files.md` gains Swift's line, OmaPhoto's name. Equivalent mutant: the heading's stretch (the title label takes the room). Untested: the staged `QuickLook` folder's `mkdir` failure (a fresh staged folder; the write after it would fail with its own message), and the null checks after scaling and allocating the flattened image (an allocation failure); without them a null scale would save a white preview. Tests: `NewCanvasSheetTests` (the dots filled and never outlined, `presetsAreSwiftsGroups`, `aPresetFillsTheFieldsAndTheMatchIsChecked`, `theMoreButtonDrawsThreeDotsAndOpensTheMenu`), `QuickLookPreviewTests` (the store's folder and its absence, load ignoring it, the digest unchanged when the folder is rewritten or deleted, the JPEG byte for byte against smooth scaling on white at quality 80, sizes and rounding, the 50-megapixel bound, a failed render, a controller save), and the sync order in `ProjectReplaceTests`.
- Tabs reorder and overflow (13.14, Swift `1faf7a0`): `UI/ProjectTabLayout.h/.cpp` twin Swift's file: `projectTabSpacing`, `ProjectTabSlot`, `ProjectTabPillSlot`, `ProjectTabOverflow` (`contentWidth()` for Swift's computed property), `projectTabOverflowLabel` and `projectTabOverflow`, widths a `std::map` read through `at` (every tab has one), the pill's width a `std::function`. Left out as dead: `span`'s empty guard (never asked of no tabs) and `contentWidth`'s pill fallback (a pill always has a tab after it). `ProjectWorkspace::moveTab` is Swift's, clamped, and emits `changed()` only when a tab moved. `ProjectTabStrip` is now a plain `QWidget` that takes the toolbar's free width (the spacer after it went; its size hint is every tab in a row, its minimum none) and places its children by hand from `projectTabOverflow` over its own width: the overflow pill at x 0 and the visible tabs at y 3, 28 high, each as wide as its button's size hint (so the unsaved dot's extra width counts, as before), hidden tabs hidden; it lays out again on `resizeEvent`, on the workspace's signal and on each tab's session signal (a title or a dot changes widths; a hidden button posts no layout request). The QScrollArea, its offset and `showFront` are gone, as Swift's scroller and reveal are; the selected tab is always shown by the pinning. `OverflowTabsPill` (a `QToolButton`, `projectTabsOverflow`) paints an inactive tab's capsule, the label "N more tabs" at 12 pixels medium and a drawn `chevron.down`, the label and chevron at half opacity while the workspace cannot switch (the capsule stays, as Swift's `.opacity` sits before its background); tooltip and accessible name are the label; its width is Swift's `projectTabOverflowPillWidth` (the label's ceiling plus 11, 4, 10, 11). Its press opens a `QMenu` (`projectTabsOverflowMenu`, deleted on close) four points below it, as `popUp(positioning:)` does, listing the hidden tabs in order, "• " before an unsaved one, a choice selecting; while the workspace cannot switch no menu opens. The reorder twins Swift's: `ProjectTabButton` watches its title button's left press, move and release in global coordinates (Swift's `.global` space) and reports `TabDragPhase::changed` once the press has moved three points and `ended` on the release; the strip's `Reorder` is `TabReorderState` (others, widths, compacted x, start, origin, translation, target), `nearestSlot`, `renderX`, `makeReorder` and `commitReorder` as Swift's (a neighbour closed mid-drag has no place, as Swift's `if let`s). A drag begins only while the workspace can switch and across at least three points, selects its tab first and raises it over the others (Swift's `zIndex`); it commits on release only if the workspace can still switch; the others ease to their places through a 150 ms `QPropertyAnimation` of `pos` (`OutQuad` for SwiftUI's `easeOut`), the dragged tab following the pointer at once, and every tab eases home when it ends. Qt can lose a release (a modal alert opened mid-drag, such as the Revert question `select` may raise, blocks the strip's window): Swift's `dragTimer` is `tabReleaseWatch`, a 100 ms `QTimer` running while a drag lasts, which ends the drag uncommitted once `QGuiApplication::mouseButtons()` holds no button, so the tabs ease home; a drag whose tab closes is dropped on the workspace's signal too. Swift's `guard reorder?.id == id` stays. Known difference: Swift's empty title bar beside the tabs drags the window (`TitleBarDragView`); Qt's toolbar is no title bar, so the strip's empty part does nothing. `NewTabDropSlot` has no twin, as before. Equivalent mutant: reporting `ended` on every release, a press that never moved three points included (with no drag begun, `ended` only lays the strip out again; Swift's `onEnded` fires once the gesture moved three points, begun or not, as the twin's does). Tests: `ProjectTabLayoutTests` (Swift's six, the bounds at exactly fitting and a point below, the pill's spacing included, a lone wide pinned tab, the pill's width asked by count), `ProjectWorkspaceTests::moveTabReordersWithoutTouchingSelectionOrDocuments` (Swift's, plus its signal), `ProjectTabDragTests` (following the pointer, clamping, the gap, the slide's duration, curve and end, stacking, landing left and right and in place, a small or upward move as a click, a busy project at the press and at the drop, a tab closed mid-drag, a release lost under a modal alert ending the drag while a held one lasts past the timer, and another tab pressed before the timer looks moving nothing (the drags go through the window, so the application tracks the buttons), a three-point move and a move under another button, the overflow pill's label, place and width, the menu's entries, place and choice, pinning, the busy pill), and `ProjectWorkspaceViewTests` (overflow in the window; the strip ends before the zoom buttons). `ProjectTabStripTests::onlyANewFrontTabScrollsIntoView` went with the scrolling.
- Ungroup Layers and the mask pointer (13.15, Swift `1318f1e`, `6c3b9a5`, `6955a6f`): `EditorSession::canUngroupLayers` and `ungroupLayers` sit in `LayerGroups.cpp` (declared in `LayerGroups+Session.h`), as Swift's extension: the folder's direct children, their parent now the folder's own, take its place in the list in their order; `releaseDetachedClipping` then frees clips that no longer sit on their base; `finishOpacityEdit` ends a drag first; one `Ungroup Layers` step selects the children with the lowest active (none for an empty folder) and forgets the folder's collapse. Swift's `LayerHierarchy.validate` guard is dead and left out: unwrapping a folder only shortens parent chains, so nothing it refuses can appear; its `document` guard is `canEditLayers`'. The Layer menu gains Ungroup Layers (Ctrl+Shift+G) after Group Selected Layers, enabled by `canUngroupLayers`, and `ShortcutDefinition::all` Swift's "Ungroup Layers" entry (g, Cmd+Shift), so it can be remapped; the list's context menu shows it after Group Selected Layers for a right-clicked folder alone (Swift reads the clicked row, not the active layer), enabled by `canUngroupLayers`. `NativeLayerList::showMaskCursor` twins Swift's `showMaskCursor`: the duplicate pointer drawn over an eye in a 7.5 by 5.5 box at the hot spot plus (15, 18), haloed by sixteen white copies one point out, the image widened to hold it (29 by 32 at 1×), the hot spot the pointer's; Swift's `eye.fill` is drawn as paths (an almond of two quadratic curves, a ring cut round a filled pupil). Over a mask thumbnail with Alt, `cursorFor` shows it whatever `canEditLayers` says, as Swift's now does (13.12 brings the Alt-click that shows the mask alone). Equivalent mutants: the eye's black fill drawn after the arrows (it never meets an arrow pixel; the halo's order is pinned), and the context entry enabled by `canEditLayers` (the right-click makes the folder active, so `canUngroupLayers` says the same). Tests: `GroupSessionTests` (Swift's three ungroup tests; collapse forgotten, signals, a plain layer refused silently, a nested and an empty folder, an opacity drag ended, busy), `LayerContextMenuTests::aFoldersMenuOffersUngroup`, the menu entry and its gate in `CompositorMenusTests`, the shortcut count, Swift's chord and a remap of the entry in `KeyboardShortcutsTests`, the eye pointer's pixels, hot spot and the arrows over the eye in `LayerDragGestureTests`.
- Compositor 1.4, part one (13.8: `8c0417b`, `fe7a83d`, `af3b568`, `3ccc125`, `d0135c8`, `dcf1c66`). A sharper canvas: `LayerRenderer::interpolation` takes Swift's `upright` (a layer whose `radians()` is 0, drawn within a thousandth of pixel for pixel, takes no filter), from `LayerRenderer::draw` and `TiledLayerRenderer`'s frames alike. The twin's adjustment surfaces already held device pixels (9.5b) and its adjustments already ran at the surface's scale (12.8c), so 1.4's surface and `adjustmentScale` changes were held; what was missing is ported: `AdjustmentSurface::draw` falls back to a pixel a painter unit when device pixels pass the budget (Swift's "falls back to points"), drawn back smoothly (Swift's `.high`), and nothing only when that passes too; `LayerAdjustment::apply` counts the noise origin per axis (`top × height / max(1, region height)`), where the twin used the width's scale for both. PSD (`IO/PSD`): Levels gamma in hundredths; `hue2` read as Swift's: 16 bytes at least, Colorize's own values or the Master's, then for each of the six ranges its band (degrees wrapped into 0–360) and values, 14 bytes each, until the data runs out; the reader keeps each mask's bounds and default byte (`PSDRecord::maskBounds`, `maskDefault`), and `PSDDocumentBuilder::maskOnLayerGrid` draws the stored patch, unfiltered, onto the layer's grid (its pixels, or the canvas for a layer without) at its place, the default round it; already the grid, or with no size, the patch is kept; a grid that cannot be allocated logs on `omaphoto.io` and takes the builder's existing "couldn't be converted" note, as Swift's nil does. `PSDAdjustments::levels` and `hue` are public, as Swift made them for its tests. Photoshop's Saturation: `HueSaturationFilter::adjustedSaturation`, Swift's to the letter. All cores: `kernels/LevelsPixels.c/.h` are Swift's file again (`cube_apply`); `BrushRaster::copy` (bytewise for the context's own format, drawn otherwise) and `BrushRaster::inBands` (one band under 250,000 pixels, else twice the ideal thread count, through `PoolMap`; a `bad_alloc` there is `ExportError(render)`, since the workers' callers catch that alone); `LevelsFilter::run` copies and runs `levels_apply` in bands; `HueSaturationFilter::run` copies and runs `cube_apply` in bands, where the twin had its own trilinear loop (the kernel's float order now: within a level of it, Swift's tests allow eight); `cube` keeps the last eight tables under a mutex, Swift's `cubes`. A blur's preview kept while the layer grows: `FilterEdit` gains `preparedTransform` and `pendingTransform`; a render carries the grown layer it was made from (`Filtered::placement`) and lands there, shown until the next replaces it; `displayedTransform` and `displayedMaskPlacement` read `preparedTransform`. Left out as dead in the twin: Swift's `current` terms (only a blur grows its grid, and only the automatic kinds, which never grow, refuse a stale preview or read `preparedSettings`), so the grid's version (`previewSourceVersion`, `Filtered::sourceVersion`) goes with nothing left to read it, and so does the twin's own remaking of a waiting job on growth (with Preview off nothing lands; with it on, the update makes a new job). The hierarchy kept: `LayerOrder` (`Document/LayerGroups.h`, its code in `LayerGroups+Order.cpp` with the document's hierarchy members, since `LayerGroups.cpp` would pass 500 lines): `Node`, `Result` (`order`, `visible`, `drawn`) and `resolve`, which keeps the last nodes and answer under a mutex; `CanvasDocument::hierarchy()` replaces `hierarchyEntries()`; `effectiveVisibleIDs`, `renderLayers` (a checked `std::map` index, after Swift's empty-order guard) and `effectiveOpacities` (a map of opacity and folder, no whole layers) read it; `groupSelectedLayers` reads `hierarchy().order`. `dcf1c66` was held: the canvas's `DisplayState` already works out the visible set once. Tests: `SharpCanvasTests` (the rule's edges; plain, masked and tiled layers crisp within the tolerance, filtered past it and when turned 2° at 1:1), `PSDAdjustmentTests` (Swift's three, plus the wrap, Colorize leaving the ranges, a cut record, the builder, a pixel layer's grid, a stretched layer, the patch kept, bounds or a layer of no height or width, the grid's bounds with a smaller patch, a patch the grid's size a pixel along), `PSDReaderTests` (the new layouts, the mask's bounds and default read and placed), `HueSaturationTests::positiveSaturationMatchesPhotoshop` (Swift's values, the clamps, a pixel), `AdjustmentBandsTests` (bands cover each pixel once, the copy's bytes, Levels and Hue/Saturation in bands against one pass of the kernels, cached tables), `HueSaturationFailureTests` (the filter now fails at its copy, the row list being gone), `FilterTests::growingABlurKeepsThePreviewUpUntilTheNextOne` (Swift's) and `aMaskKeepsItsPlaceUnderAPreview` (a covering mask under a preview that did not grow, and on a grown one the old bounds), `FilterSessionTests` and `FinishingFilterTests` Line truncated
- Compositor 1.4, part one (13.8: `8c0417b`, `fe7a83d`, `af3b568`, `3ccc125`, `d0135c8`, `dcf1c66`). A sharper canvas: `LayerRenderer::interpolation` takes Swift's `upright` (a layer whose `radians()` is 0, drawn within a thousandth of pixel for pixel, takes no filter), from `LayerRenderer::draw` and `TiledLayerRenderer`'s frames alike; since 14.4 `LayerRenderer::upright` also counts a quarter turn whose corners land on whole device pixels (14.4's bullet). The twin's adjustment surfaces already held device pixels (9.5b) and its adjustments already ran at the surface's scale (12.8c), so 1.4's surface and `adjustmentScale` changes were held; what was missing is ported: `AdjustmentSurface::draw` falls back to a pixel a painter unit when device pixels pass the budget (Swift's "falls back to points"), drawn back smoothly (Swift's `.high`), and nothing only when that passes too; `LayerAdjustment::apply` counts the noise origin per axis (`top × height / max(1, region height)`), where the twin used the width's scale for both. PSD (`IO/PSD`): Levels gamma in hundredths; `hue2` read as Swift's: 16 bytes at least, Colorize's own values or the Master's, then for each of the six ranges its band (degrees wrapped into 0–360) and values, 14 bytes each, until the data runs out; the reader keeps each mask's bounds and default byte (`PSDRecord::maskBounds`, `maskDefault`), and `PSDDocumentBuilder::maskOnLayerGrid` draws the stored patch, unfiltered, onto the layer's grid (its pixels, or the canvas for a layer without) at its place, the default round it; already the grid, or with no size, the patch is kept; a grid that cannot be allocated logs on `omaphoto.io` and takes the builder's existing "couldn't be converted" note, as Swift's nil does. `PSDAdjustments::levels` and `hue` are public, as Swift made them for its tests. Photoshop's Saturation: `HueSaturationFilter::adjustedSaturation`, Swift's to the letter. All cores: `kernels/LevelsPixels.c/.h` are Swift's file again (`cube_apply`); `BrushRaster::copy` (bytewise for the context's own format, drawn otherwise) and `BrushRaster::inBands` (one band under 250,000 pixels, else twice the ideal thread count, through `PoolMap`; a `bad_alloc` there is `ExportError(render)`, since the workers' callers catch that alone); `LevelsFilter::run` copies and runs `levels_apply` in bands; `HueSaturationFilter::run` copies and runs `cube_apply` in bands, where the twin had its own trilinear loop (the kernel's float order now: within a level of it, Swift's tests allow eight); `cube` keeps the last eight tables under a mutex, Swift's `cubes`. A blur's preview kept while the layer grows: `FilterEdit` gains `preparedTransform` and `pendingTransform`; a render carries the grown layer it was made from (`Filtered::placement`) and lands there, shown until the next replaces it; `displayedTransform` and `displayedMaskPlacement` read `preparedTransform`. Left out as dead in the twin: Swift's `current` terms (only a blur grows its grid, and only the automatic kinds, which never grow, refuse a stale preview or read `preparedSettings`), so the grid's version (`previewSourceVersion`, `Filtered::sourceVersion`) goes with nothing left to read it, and so does the twin's own remaking of a waiting job on growth (with Preview off nothing lands; with it on, the update makes a new job). The hierarchy kept: `LayerOrder` (`Document/LayerGroups.h`, its code in `LayerGroups+Order.cpp` with the document's hierarchy members, since `LayerGroups.cpp` would pass 500 lines): `Node`, `Result` (`order`, `visible`, `drawn`) and `resolve`, which keeps the last nodes and answer under a mutex; `CanvasDocument::hierarchy()` replaces `hierarchyEntries()`; `effectiveVisibleIDs`, `renderLayers` (a checked `std::map` index, after Swift's empty-order guard) and `effectiveOpacities` (a map of opacity and folder, no whole layers) read it; `groupSelectedLayers` reads `hierarchy().order`. `dcf1c66` was held: the canvas's `DisplayState` already works out the visible set once. Tests: `SharpCanvasTests` (the rule's edges; plain, masked and tiled layers crisp within the tolerance, filtered past it and when turned 2° at 1:1), `PSDAdjustmentTests` (Swift's three, plus the wrap, Colorize leaving the ranges, a cut record, the builder, a pixel layer's grid, a stretched layer, the patch kept, bounds or a layer of no height or width, the grid's bounds with a smaller patch, a patch the grid's size a pixel along), `PSDReaderTests` (the new layouts, the mask's bounds and default read and placed), `HueSaturationTests::positiveSaturationMatchesPhotoshop` (Swift's values, the clamps, a pixel), `AdjustmentBandsTests` (bands cover each pixel once, the copy's bytes, Levels and Hue/Saturation in bands against one pass of the kernels, cached tables), `HueSaturationFailureTests` (the filter now fails at its copy, the row list being gone), `FilterTests::growingABlurKeepsThePreviewUpUntilTheNextOne` (Swift's) and `aMaskKeepsItsPlaceUnderAPreview` (a covering maLine truncated
- Blending (13.9a: `6dc2219`, `8ab32c9`, `5a8f6ce`, `e6c964b`). Held, proven: clipping stacks and live-mask coverage at device resolution (the twin's `LiveMaskRenderer` has no `bounds`: its group, alpha and coverage surfaces are the painter's device since 4.x, so Swift's `resolution` and `pixelSize` have no twin); an adjustment in any mode, over the document and inside a stack, blended at full coverage, its alpha restored, then mixed through mask and opacity (`adjust`'s `blended` goes through `LayerRenderer::composite`, which hand-blends every mode QPainter lacks through `HslBlend`, so Swift's `SeparableBlend.blend` has no twin); a clipping stack composited in its base's mode in every mode (`drawComposite` hands `m_stackModes` to `LayerRenderer::composite`, the same path), on the canvas, in export and in Copy Merged alike. Ported: Soft Light by Photoshop's formula, as Core Image computes it (2bs + b²(1 − 2s) up to half, 2b(1 − s) + √b(2s − 1) above), in `SeparableBlend::channel`, and `needsSurface` names Soft Light, so `HslBlend` blends it by hand everywhere a layer, a stack or an adjustment is drawn; `LayerRenderer::compositionMode` loses its Soft Light case (dead now). Compared first: Qt's `CompositionMode_SoftLight` is the PDF's (W3C) formula, which equals Photoshop's below a source of half and above it wherever the backdrop passes 0.25; under that it takes the PDF's cubic for √b and runs up to 17 levels darker (backdrop 5/255, source 1: 19 where Photoshop gives 36). Swift's 0.9 over 0.5 gives 170 under either formula. Known difference: Swift's Core Image is "within 5 levels of Photoshop's"; the twin is Photoshop's own formula, so it may differ from Swift's by those few levels. Tests: `BlendingTests` (Swift's `clippingStacksBlendInTheirBasesMode` for Linear Dodge, Color Dodge and Multiply: Swift's numbers, Copy Merged equal to export, the canvas within a level; adjustments in Difference and Linear Light over the document and Vivid Light and Subtract inside a stack, by the numbers, at full and half opacity, in all three places; Swift's `softLightMatchesPhotoshop` plus a dark backdrop and the lower branch; Swift's `softEdgedLayersBlendTheSame` for Soft Light, Overlay, Multiply and Color Dodge: a radial soft layer over noise, Copy Merged equal to export, the canvas within a level), `BlendModeTests::everyBlendModeMatchesThePdfFormulas` (its Soft Light reference is Photoshop's now; its backdrop of 0.1 under sources past half catches the PDF's cubic (at 0.25 the cubic equals √0.25)), `ClippingStackTests::stacksAndCoverageTakeTheDevicesPixels` (a half-unit stripe stays sharp at a device scale of two, in a stack and through coverage from a source further down). Equivalent mutant: Soft Light's `source <= 0.5` for `< 0.5` (both branches give the backdrop at a half). Judgments: stacks and coverage at device resolution, adjustments in any mode and a stack in its base's mode were held (proven by the tests above); Soft Light ported. Mutants: 5 on Soft Light, 4 caught, one equivalent (above).
- The Metal canvas (13.9b: `cdf8674`, `4b04323`, `5708ed3`, `93de9e7`, `54dd707`, `91cb0b1`, `f773f7b`, `4c5e1a6`, `d04f159`, `b1b7490`). OmaPhoto stays a CPU canvas (the port's decision): Swift's GPU canvas, its `GPUCanvas`, `GPUNoise`, `MetalWarp` and `CanvasLinesOverlay` and the GPU branches of `EditorCanvas`, have no twin; the twin's canvas is the Core Graphics canvas those commits compare against. Ported: Grain and Add Noise anchored to the document (`54dd707`): `LiveMaskRenderer::adjustmentRegion` (Swift's `adjustmentRegion`) maps what the painter shows into the document, and the canvas's `drawLayers` sets it from its centre mapping and zoom, so the smooth path counts noise in frame pixels from the document's corner and Grain in document pixels, as export does, and the crisp path's surface maps to document pixels, so a partial repaint keeps one field; the blur's scale still comes from the unmapped region. This retires 9.5b's and 12.8c's known differences (Grain and noise following the view, a partial repaint's own field); `CanvasDisplayTests::grainFollowsTheViewOnTheCanvas` is gone. Export and `drawLiveComposite` already draw in document pixels and set no region, as Swift's. Tests: `AdjustmentCanvasTests` (Swift's `matchesEveryAdjustment`: every kind through a ramp mask at 80% over a photo and a translucent layer, the canvas within a level of the export over its checkerboard; Swift's `patternsStayWithTheDocument` for Add Noise and Grain: equal to export at 100% and 200%, panned and not, and at 80% a whole-pixel pan moving the pattern with the image and a partial repaint showing the whole's pattern within two levels of resampling), `CanvasEditContractTests` (Swift's `aNewMaskRevealsTheWholeLayer`, upright and turned: a one-pixel mask on a scaled layer changes no covered pixel, the canvas as the export; `matchesWhileMovingPixels`, moving and duplicating on a masked layer at 60% under a soft selection: the preview equals the commit and the export outside the ants, the hole only for a move; `matchesWhileDraggingAGradient` in a selection at 70%: the pending gradient equals its commit outside the overlays, nothing outside the selection; `5708ed3`'s failed fill cancelling with `brushError`, no step, the document unchanged; `matchesALiveMaskOutsideAClippingStack`: a turned source with a layer between, the canvas as the export), `CanvasContractFixtures.h` (the scene, the export over the canvas's checkerboard, a gap that skips the overlays' bands, the ramp mask). Judgments, each Swift contract held unless named above: `cdf8674`: rotation, flips, scale, sharp successive reductions, premultiplied blending in every mode with layer and ancestor opacity, the checkerboard anchored to the document, the crisp composite at high zoom, sampling modes and rounded-up reduction extents, premultiplied blending in sRGB, the surround, shadow and border, placed own masks and multiplied folder masks on pixel layers, filter, Levels and Hue/Saturation previews in the layer's place: held; the CPU paths are the reference and are pinned by `LayerRendererTests` (placement, sampling rules, halvings, overhang), `DownsampleTests`, `BlendModeTests`, `CanvasDisplayTests` (checkerboard, shadow and border, crisp from 200%, the rich scene against the export), `FolderOpacityTests`, `FolderMaskClipTests`, `MaskPlacementTests`, `LevelsCanvasTests`, `HueSaturationCanvasTests`, `VignetteCanvasTests`. `4b04323`: a moved or duplicated selection's preview and commit: held and now proven on a masked, translucent layer (above); the rounded offset is pinned by a move of 5.6 landing at 6 (`EffectsCanvasTests::movingPixelsKeepTheEffects`). Swift's laziness (`PixelMove.applyOffset`, `holed`, `liftedTarget`) only spares the GPU canvas a rebuild per move; the twin rebuilds eagerly in `movePixels`, which realises the tiles before display and commit by construction, and a failure there cancels the move with `brushError` (`PixelMoveTests::aPixelMoveHoldsTheSessionAndAFailedMoveCancels`). `5708ed3`, `91cb0b1`: live paint, erase and mask paint, paint past the old bounds under the old mask (`TiledStrokeTests`, `BrushPreviewTests`, `MaskPaintAnywhereTests`), Smudge and Liquify previews through the mask, opacity and placement (`SmearCanvasTests`, `FolderOpacityTests`): held. `GradientEdit.Fill` and `applyFill` are the GPU's laziness; the twin fills in `refreshGradient`, before display and commit by construction, a failure cancelling with `brushError` (now tested), and the commit publishes what was filled. `MetalWarpTests.matchesTheCPU`: the twin's `WarpStroke` is the CPU reference (`SmudgeLiquifyTests`); nothing to port. `93de9e7`: a one-pixel white mask revealing its layer: held (QPainter's smooth draw clamps an image's edge, as Core Graphics draws it; now tested); the live paint grid's extent and edge sampling: held (`TiledStrokeTests`, `DownsampleTests`, `TiledLayerTests`). `clampedToExtent` is GPU-only. `54dd707`: every adjustment kind with alpha, masks and opacLine truncated
- Shift on a pixel drag and Select All from the list (13.10): `CanvasView::moveSelectionTool` (`EditorCanvas+Selection.cpp`) twins Swift's `60bde4f`: with Shift held a Ctrl-drag of selected pixels zeroes the shorter axis of the offset, lengths compared (`std::abs`), a tie keeping across, as `dragSelection` already does for the outline; read from each move's own modifiers, as Swift's `event.modifierFlags`. `451281e`: Swift's table now hands Select All to the canvas (`session.selectAll()`, guarded on a document) instead of selecting every row, so the twin's `NativeLayerList::selectAllRows` (12.5b's round-3 fix) and the menu's `focusedList` branch are gone: a focused list falls through to `session().selectAll()`, which guards on the document itself. A rename's editor is a `QLineEdit`, so it keeps its own Select All first, as Swift's field editor. Tests: `SelectionCanvasTests::shiftKeepsMovedPixelsOnALine` (across, down, a tie, a long left beating a short down, without Shift, the release keeping the last offset), `SelectMenuTests::selectAllInAFieldIsTheFields` (a real Ctrl+A on the focused list selects the canvas, history "Select All", layers and the active one unchanged, the list keeping focus; Swift's `selectAllInTheLayersPanelSelectsTheCanvas`). Mutants: 5 on the Shift branch, all caught (the `abs`-less comparison caught after the left-beats-down step was added). No survivors.
@@ -184,6 +184,7 @@ Platform adaptations, each forced by a missing Qt analog:
- Inner effects keep a pixel's alpha (14.1, Swift 1.4.6: `e7e295e`, `9cb57db`, `f25effa`): `LayerEffectsKernel` follows v1.4.9's `effects_compose`: the colour overlay, then the inner glow, then the inner shadow move each pixel's colour toward theirs times its alpha (MSL's `mix`, `recolour`), the overlay by its opacity, the inner two by their strength (`effects_inside`: 1 − the softened or moved shape, no longer times the shape); then the pixels go over the shadow, the outer glow and an outside stroke, and an inside stroke goes on top. So a half-clear pixel takes a colour fully and stays half clear, and effects beneath are not tinted. Swift's CPU path (`overlaid`, `recolor`, the surface's inner glow) serves machines without Metal and stays dead here, as 9.6b's. The effects button's tooltip says "Add layer effect". Tests: `ColorOverlayTests` (Swift's three; the inner shadow and inner glow on half-clear pixels, a shadow beneath untinted), the shader replay in `LayerEffectsKernelTests`.
- RAW rounding and Importing… (14.2, Swift 1.4.6: `6345432`, `196342c`, `6e6ca38`, `0907f51`): `kernels/DitherPixels.c/.h` gain `196342c`'s `hash_noise` and `dither_quantize16`, its block body moved into `quantize_band` with a context struct, as 13.17's bands. `RawImporter::develop` works the look at 16 bits (`lround(65535 × …)`, opaque) into a buffer it allocates first (`ExportError(render)` without memory), then rounds it to 8 bits through `dither_quantize16`, where Swift renders Core Image at 16 bits and does the same; Swift's half-float working format names Core Image's own and has no twin. The session's `rawImporting` and `endRawDevelop` are Swift's: Import keeps the request and sets `rawImporting` while an answer waits, Cancel or an answer nobody waits for ends at once, and `finishDecode` ends it once the frame lands or fails (Swift's `defer`), after the layer is in. `RawDevelopSheet::setImporting` shows Swift's plate (a 32-point busy bar over "Importing…" in `PlaceholderText`, padded 16, on the window colour at 85% rounded 10), hides the preview's spinner, rests the sliders, Reset, Cancel and Import, and filters its dialog's Close and Escape while importing (Swift's `interactiveDismissDisabled`). Tests read RAW tones as a mean over the frame, rounded, since the noise moves single pixels a level; Qt's area average truncates, so a fitted preview reads a level low (`theQueueKeepsThePreviewsDecodeUntilReleased`). Holding the global pool freezes painting (Qt's raster engine fills on it), so the sheet's tests read its state before the event loop runs the develop, or use a frame large enough to take a while. Tests: `RawDitherTests` (Swift's two on the kernel; the triangular noise's three in four kept, alpha never passed, a develop dithered and repeatable), `RawDevelopTests` (importing, cancelling, failing, an answer nobody waits for, the plate, the locked dialog, the spinner and Reset while importing).
- New Canvas in units, at a DPI, on a background (14.3, Swift 1.4.6: `b0b4f01`, `ab9779a`, `a00d8b6`): `NewCanvasUnit` and `NewCanvasBackground` (in `NewCanvasSheet.h`, Swift's file) are enums with free functions for Swift's members (`rawValue`, `name`, `next`, `pixels`, `text`; `title`, `next`, `color`). `pixels` trims Foundation's `.whitespaces` (`trimmedWhitespaces`), reads a comma as the point and parses as Swift's `Double(text)` (the whole text through `strtod_l` in the C locale, a leading white space refused), takes a positive number, turns a print size into pixels at the DPI rounded half away from zero, and wants a whole count from 1 to 30,000; Swift's `isFinite` term is dead (infinity fails the range, NaN the whole number) and left out. `text` prints whole pixels, or a print size to two decimals with none trailing, rounded as Foundation's format style rounds: the shortest decimal that reads back as the double (`QLocale::FloatingPointShortest`), then halves to even on its digits (1.905 cm prints 1.9, 3.175 prints 3.18, where rounding the binary value gives 1.91 and 3.17); checked once against Swift 6.0.3 for 1 to 4,000 pixels in every unit at 72 and 300 DPI, all 24,000 alike. The sheet starts in pixels at 72 DPI on a clear canvas each time (`a00d8b6`), and its three settings are Swift's `CyclePill`s, `QAbstractButton`s that paint their text in `PlaceholderText` at 12 pixels and, hovered, a capsule of the text colour at 10% (macOS's quaternary label): the background (Transparent, White, Black canvas), the unit (switching rewrites both fields in the new unit when both read, else leaves them) and the DPI (72 or 300; in a print unit its tooltip adds the pixels it makes). The fields' units follow the unit; the note shows only for a size that does not read, in the unit's words; presets and the clipboard's size are written in the unit. `createNewProject` and `createDocument` take the resolution (1 to 9,600, else 72) and the background; `firstLayer` makes a Background layer of that colour at full size with its thumbnail, or a blank Layer 1, and a background that cannot be allocated logs on `omaphoto.app` and falls back to the blank layer, Swift's `try?` (untested: only starvation reaches it). Tests: `NewCanvasUnitTests` (Swift's four; rounding, trimming, the DPI's ends, the thumbnail), `NewCanvasSheetTests` (the pills, the units, the DPI's tooltip, presets in units, Swift's number parsing).
- Rotate Canvas 90° (14.4, Swift 1.4.6: `754f188`): `Document/CanvasRotation.cpp` twins Swift's file, `CanvasRotation+Session.h` its session half; `LayerTransform::quarterTurned` and `CanvasGuide::quarterTurned` are declared in their own headers and defined there, as `mirrored` is. `rotateCanvas` commits a transform edit and drops a crop frame first, asks `canEditLayers`, then turns every layer and placed mask about the canvas (the middle carried, the angle by ±90 through `fmod`, which keeps Swift's sign), swaps the sides in place, turns the guides and the selection's path (its feather and antialiasing kept), fits the view, one `Rotate Canvas 90° Clockwise` or `Counterclockwise` step; Swift's `finishOpacityEdit` is dead (`commitTransform` runs it, as `flipCanvas` notes) and left out. `restore` fits the view when the canvas changes size too. The Image menu lists both entries before the flips, enabled by `canEditLayers`. A fix the item's exact test found: QPainter turned a layer through `rotateRadians`, whose cos(π/2) is 6e-17, so a turned edge missed its pixel boundary and antialiasing shaved it, and its fixed-point bilinear fetch strayed a level or two (40 read 39) where CoreGraphics' low quality copies a pixel-aligned turn exactly. Every layer placement now turns through `LayerTransform::degrees()` and `QTransform::rotate`, which Qt builds exactly at ±90, ±270 and 180 and as `rotateRadians` elsewhere, and `LayerRenderer::upright` takes no filter for a quarter turn whose two corners land within a thousandth of whole device pixels, through the painter's own transform (a turn half a pixel off still filters, as CoreGraphics blends it). Equivalent mutants: `degrees()` in `BrushStroke`'s grid map, `Distort` and `LayerMask` (the two matrices differ by 1e-16 there; the change keeps every placement one turn). Tests: `CanvasRotationTests` (Swift's four, a turned layer and a placed mask, the crop frame, the refit, refusals and steps), `SharpCanvasTests::aQuarterTurnOnWholePixelsCopiesAcross` (the rule's edges; plain and painted layers exact on whole pixels and filtered half off), the menu in `ImageMenuTests`.
- Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to <title>"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
+1
View File
@@ -82,6 +82,7 @@ add_library(compositor_core STATIC
src/Document/CloneStamp.cpp
src/Document/ColorPalette.cpp
src/Document/ContentFill.cpp
src/Document/CanvasRotation.cpp
src/Document/LayerFlip.cpp
src/Document/LayerMerge.cpp
src/Document/MagicWand.cpp
+1 -1
View File
@@ -92,7 +92,7 @@ QTransform BrushRaster::pixelToDocument(const LayerTransform &transform, int wid
const QPointF center = transform.center();
QTransform map;
map.translate(center.x(), center.y());
map.rotateRadians(transform.radians());
map.rotate(transform.degrees());
map.scale(transform.size.width() / width * (transform.flipX ? -1 : 1),
transform.size.height() / height * (transform.flipY ? -1 : 1));
map.translate(-width / 2.0, -height / 2.0);
+4
View File
@@ -0,0 +1,4 @@
// Swift's CanvasRotation extension: Image › Rotate Canvas 90°.
public:
// A quarter turn of everything; layers placed, not resampled.
void rotateCanvas(bool clockwise);
+51
View File
@@ -0,0 +1,51 @@
#include "Document/EditorSession.h"
#include <cmath>
LayerTransform LayerTransform::quarterTurned(bool clockwise, QSizeF canvas) const
{
LayerTransform result = *this;
// The middle moves where the turn takes it.
const QPointF middle = clockwise ? QPointF(canvas.height() - center().y(), center().x()) : QPointF(center().y(), canvas.width() - center().x());
result.origin = QPointF(middle.x() - size.width() / 2, middle.y() - size.height() / 2);
result.rotation = std::fmod(rotation + (clockwise ? 90 : -90), 360.0);
return result;
}
CanvasGuide CanvasGuide::quarterTurned(bool clockwise, QSizeF canvas) const
{
CanvasGuide guide = *this;
if (axis == Axis::vertical) {
guide.axis = Axis::horizontal;
if (!clockwise)
guide.position = canvas.width() - position;
} else {
guide.axis = Axis::vertical;
if (clockwise)
guide.position = canvas.height() - position;
}
return guide;
}
void EditorSession::rotateCanvas(bool clockwise)
{
commitTransform();
cancelCrop();
if (!canEditLayers())
return;
const QSizeF canvas = m_document->size();
beginEdit(clockwise ? QStringLiteral("Rotate Canvas 90° Clockwise") : QStringLiteral("Rotate Canvas 90° Counterclockwise"));
for (ImageLayer &layer : m_document->layers) {
layer.transform = layer.transform.quarterTurned(clockwise, canvas);
if (layer.mask && layer.mask->placement)
layer.mask->placement = layer.mask->placement->quarterTurned(clockwise, canvas);
}
std::swap(m_document->width, m_document->height);
for (CanvasGuide &guide : m_document->guides)
guide = guide.quarterTurned(clockwise, canvas);
if (m_document->selection) {
const QTransform turn = clockwise ? QTransform(0, 1, -1, 0, canvas.height(), 0) : QTransform(0, -1, 1, 0, 0, canvas.width());
m_document->selection->path = turn.map(m_document->selection->path);
}
viewport.fit(m_document->size());
endEdit();
}
+1 -1
View File
@@ -222,7 +222,7 @@ Corners DistortWarp::carried(const LayerTransform &placement, const LayerTransfo
{
QTransform toDocument;
toDocument.translate(transform.center().x(), transform.center().y());
toDocument.rotateRadians(transform.radians());
toDocument.rotate(transform.degrees());
toDocument.scale(transform.size.width(), transform.size.height());
toDocument.translate(-0.5, -0.5);
const QTransform toUnit = toDocument.inverted();
+5 -1
View File
@@ -5,6 +5,7 @@
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <cmath>
#include <tuple>
EditorSession::EditorSession(QObject *parent) : QObject(parent), m_busyTimer(this)
{
@@ -229,7 +230,10 @@ void EditorSession::restore(const DocumentHistory::Snapshot &snapshot)
{
cancelCrop();
cancelGradient();
const auto canvas = [](const std::optional<CanvasDocument> &document) { return document ? std::optional(document->id) : std::nullopt; };
const auto canvas = [](const std::optional<CanvasDocument> &document) {
return document ? std::optional(std::tuple(document->id, document->width, document->height)) : std::nullopt;
};
// Another canvas, or another size (a rotation): fit anew.
const bool changedCanvas = canvas(m_document) != canvas(snapshot.document);
// The mask stays the target while layer and mask remain.
const bool keepsMaskTarget = m_isMaskSelected && m_activeLayerID == snapshot.activeLayerID;
+1
View File
@@ -276,6 +276,7 @@ public:
#include "Document/Distort+Session.h"
#include "Document/LayerMerge+Session.h"
#include "Document/LayerFlip+Session.h"
#include "Document/CanvasRotation+Session.h"
#include "Document/Selection+Session.h"
#include "Document/MagicWand+Session.h"
#include "Document/SubjectRemoval+Session.h"
+2
View File
@@ -19,6 +19,8 @@ struct CanvasGuide {
CanvasGuide scaled(double x, double y) const;
// Mirrored when it crosses the flip, staying on content.
CanvasGuide mirrored(bool horizontally, double center) const;
// Turned with the canvas: the other axis, same content.
CanvasGuide quarterTurned(bool clockwise, QSizeF canvas) const;
friend bool operator==(const CanvasGuide &, const CanvasGuide &) = default;
};
+1 -1
View File
@@ -200,7 +200,7 @@ void FolderMaskClip::apply(QPointF center, const QPainter &context, QImage &cove
{
QTransform placement;
placement.translate(center.x(), center.y());
placement.rotateRadians(transform.radians());
placement.rotate(transform.degrees());
placement.scale(transform.flipX ? -1 : 1, transform.flipY ? -1 : 1);
QPainter masking(&veil);
masking.setRenderHint(QPainter::SmoothPixmapTransform, quality(transform.sampling) != InterpolationQuality::none);
+5
View File
@@ -89,6 +89,11 @@ double LayerTransform::radians() const
return std::fmod(rotation, 360) * std::numbers::pi / 180;
}
double LayerTransform::degrees() const
{
return std::fmod(rotation, 360);
}
bool LayerTransform::isValid() const
{
const std::array<double, 5> values = {origin.x(), origin.y(), size.width(), size.height(), rotation};
+4
View File
@@ -36,6 +36,8 @@ struct LayerTransform {
friend bool operator==(const LayerTransform &lhs, const LayerTransform &rhs);
QPointF center() const;
double radians() const;
// The turn in degrees: QTransform::rotate makes quarter turns exact.
double degrees() const;
bool isValid() const;
QPointF point(QPointF unit) const;
bool contains(QPointF point) const;
@@ -48,6 +50,8 @@ struct LayerTransform {
bool samePlacement(const LayerTransform &other) const;
// Mirrored across a line at `axis`; defined in LayerFlip.cpp.
LayerTransform mirrored(bool horizontally, double axis) const;
// Turned a quarter with its canvas; nothing resampled.
LayerTransform quarterTurned(bool clockwise, QSizeF canvas) const;
};
// Several layers under one box; each follows it.
+21 -5
View File
@@ -53,13 +53,13 @@ void LayerRenderer::draw(const QImage &image, const LayerTransform &transform, Q
// Large reductions draw from sharp halvings; QPainter does the rest.
const double device = deviceScale(context);
const Reduced source = reduced(image, width, device, transform.sampling);
const InterpolationQuality quality = interpolation(
transform.sampling, width * device / std::max(1, image.width()) * (1 << source.level), transform.radians() == 0);
const QRectF bounds(-width / 2, -height / 2, width, height);
QTransform placement;
placement.translate(center.x(), center.y());
placement.rotateRadians(transform.radians());
placement.rotate(transform.degrees());
placement.scale(transform.flipX ? -1 : 1, transform.flipY ? -1 : 1);
const InterpolationQuality quality = interpolation(transform.sampling, width * device / std::max(1, image.width()) * (1 << source.level),
upright(transform, placement, bounds, context));
if (options.mask.isNull() && options.clip.isNull() && !HslBlend::handles(options.blendMode)) {
const QPainter::CompositionMode mode = compositionMode(options.blendMode);
@@ -115,7 +115,7 @@ void LayerRenderer::drawBrushPreview(const QImage &image, const LayerTransform &
};
QTransform placement;
placement.translate(center.x(), center.y());
placement.rotateRadians(transform.radians());
placement.rotate(transform.degrees());
placement.scale(transform.flipX ? -1 : 1, transform.flipY ? -1 : 1);
QRectF extent = bounds;
for (const BrushPatch &patch : preview.patches)
@@ -282,7 +282,7 @@ void LayerRenderer::drawCoverage(const QImage &image, const LayerTransform &tran
}
QTransform placement;
placement.translate(transform.center().x(), transform.center().y());
placement.rotateRadians(transform.radians());
placement.rotate(transform.degrees());
placement.scale(transform.flipX ? -1 : 1, transform.flipY ? -1 : 1);
context.save();
setSampling(context, quality(transform.sampling), transform.sampling);
@@ -292,6 +292,22 @@ void LayerRenderer::drawCoverage(const QImage &image, const LayerTransform &tran
context.restore();
}
// QPainter's fixed-point bilinear strays a level where CoreGraphics copies.
bool LayerRenderer::upright(const LayerTransform &transform, const QTransform &placement, const QRectF &bounds, const QPainter &context)
{
if (transform.radians() == 0)
return true;
if (std::remainder(transform.rotation, 90) != 0)
return false;
const QTransform device = placement * context.deviceTransform();
for (const QPointF corner : {bounds.topLeft(), bounds.bottomRight()}) {
const QPointF mapped = device.map(corner);
if (std::abs(mapped.x() - std::round(mapped.x())) > 0.001 || std::abs(mapped.y() - std::round(mapped.y())) > 0.001)
return false;
}
return true;
}
InterpolationQuality LayerRenderer::interpolation(LayerSampling sampling, double finalFactor, bool upright)
{
if (sampling == LayerSampling::nearest || (upright && std::abs(finalFactor - 1) < 0.001))
+2
View File
@@ -59,6 +59,8 @@ void composite(QPainter &context, const QTransform &placement, const QRectF &ext
const std::function<void(QPainter &)> &body, const std::function<void(QPainter &)> &veil = {});
// An upright layer drawn pixel for pixel copies straight across.
InterpolationQuality interpolation(LayerSampling sampling, double finalFactor, bool upright = false);
// Swift's upright, or a quarter turn on whole pixels.
bool upright(const LayerTransform &transform, const QTransform &placement, const QRectF &bounds, const QPainter &context);
Reduced reduced(const QImage &image, double width, double device, LayerSampling sampling);
QRectF coverage(const Reduced &reduced, const QRectF &bounds);
double deviceScale(const QPainter &context);
+3 -3
View File
@@ -150,13 +150,13 @@ void withFrame(int pixelWidth, int pixelHeight, const QRectF &painted, const QRe
return;
const double device = LayerRenderer::deviceScale(context);
const int level = transform.sampling == LayerSampling::nearest ? 0 : DownsampleCache::level(width * device / pixelWidth);
const InterpolationQuality quality = LayerRenderer::interpolation(transform.sampling, width * device / pixelWidth * (1 << level),
transform.radians() == 0);
QTransform placement;
placement.translate(center.x(), center.y());
placement.rotateRadians(transform.radians());
placement.rotate(transform.degrees());
placement.scale(transform.flipX ? -1 : 1, transform.flipY ? -1 : 1);
const QRectF bounds(-width / 2, -height / 2, width, height);
const InterpolationQuality quality = LayerRenderer::interpolation(transform.sampling, width * device / pixelWidth * (1 << level),
LayerRenderer::upright(transform, placement, bounds, context));
const QRectF shown = placement.inverted().mapRect(BrushRaster::visibleRect(context));
const double sx = pixelWidth / width, sy = pixelHeight / height;
const Frame frame{bounds, double(pixelWidth), double(pixelHeight), level, device, transform.sampling,
+5
View File
@@ -213,6 +213,9 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
add(image, QStringLiteral("imageSize"), QStringLiteral("Image Size…"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_I), [this] { projects().imageSize(); });
add(image, QStringLiteral("trim"), QStringLiteral("Trim…"), QKeySequence(), [this] { projects().trim(); });
image->addSeparator();
add(image, QStringLiteral("rotateCanvasClockwise"), QStringLiteral("Rotate Canvas 90° Clockwise"), QKeySequence(), [this] { session().rotateCanvas(true); });
add(image, QStringLiteral("rotateCanvasCounterclockwise"), QStringLiteral("Rotate Canvas 90° Counterclockwise"), QKeySequence(),
[this] { session().rotateCanvas(false); });
add(image, QStringLiteral("flipCanvasHorizontal"), QStringLiteral("Flip Canvas Horizontal"), QKeySequence(), [this] { session().flipCanvas(true); });
add(image, QStringLiteral("flipCanvasVertical"), QStringLiteral("Flip Canvas Vertical"), QKeySequence(), [this] { session().flipCanvas(false); });
@@ -386,6 +389,8 @@ void CompositorMenus::synchronize()
action(QStringLiteral("canvasSize"))->setEnabled(drawn && p.canStart());
action(QStringLiteral("imageSize"))->setEnabled(drawn && p.canStart());
action(QStringLiteral("trim"))->setEnabled(drawn && p.canStart());
action(QStringLiteral("rotateCanvasClockwise"))->setEnabled(s.canEditLayers());
action(QStringLiteral("rotateCanvasCounterclockwise"))->setEnabled(s.canEditLayers());
action(QStringLiteral("flipCanvasHorizontal"))->setEnabled(s.canEditLayers());
action(QStringLiteral("flipCanvasVertical"))->setEnabled(s.canEditLayers());
action(QStringLiteral("newAdjustmentLayer"))->setEnabled(s.canEditLayers());
+189
View File
@@ -0,0 +1,189 @@
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "SessionFixtures.h"
#include <QSignalSpy>
#include <QtTest>
// Swift's CanvasRotationTests: a quarter turn, everything along.
namespace {
// Reds 10, 20, 30 over 40, 50, 60.
QImage pixels()
{
QImage image = BrushRaster::context(3, 2, false);
const int values[] = {10, 20, 30, 40, 50, 60};
for (int index = 0; index < 6; ++index)
image.setPixelColor(index % 3, index / 3, QColor(values[index], 0, 0));
return image;
}
void canvas(EditorSession &session)
{
session.createDocument(3, 2);
session.insert(ImportedImage(pixels(), pixels(), QStringLiteral("Pixels")));
}
// The flattened canvas's reds, row by row.
QList<int> reds(const EditorSession &session)
{
const QImage image = ImageExporter::render(session.projectSnapshot().value()).image;
QList<int> values;
for (int y = 0; y < image.height(); ++y)
for (int x = 0; x < image.width(); ++x)
values << image.pixelColor(x, y).red();
return values;
}
}
class CanvasRotationTests : public QObject {
Q_OBJECT
private slots:
void clockwiseTurnsThePixelsExactly();
void counterclockwiseTurnsThePixelsExactly();
void turningBackAndUndoingRestoreTheCanvas();
void guidesAndSelectionTurnWithTheCanvas();
void turnedLayersAndPlacedMasksTurnAlong();
void itIsOneStepRefusedWhereLayersCannotChange();
};
void CanvasRotationTests::clockwiseTurnsThePixelsExactly()
{
EditorSession session;
canvas(session);
session.rotateCanvas(true);
QCOMPARE(session.document().value().size(), QSizeF(2, 3));
// The left column, bottom to top, is the top row.
QCOMPARE(reds(session), (QList<int>{40, 10, 50, 20, 60, 30}));
}
void CanvasRotationTests::counterclockwiseTurnsThePixelsExactly()
{
EditorSession session;
canvas(session);
session.rotateCanvas(false);
QCOMPARE(session.document().value().size(), QSizeF(2, 3));
// The right column, top to bottom, is the top row.
QCOMPARE(reds(session), (QList<int>{30, 60, 20, 50, 10, 40}));
}
void CanvasRotationTests::turningBackAndUndoingRestoreTheCanvas()
{
EditorSession session;
canvas(session);
const QList<int> original = reds(session);
session.rotateCanvas(true);
session.rotateCanvas(false);
QCOMPARE(session.document().value().size(), QSizeF(3, 2));
QCOMPARE(reds(session), original);
session.rotateCanvas(true);
session.rotateCanvas(true);
// Two quarter turns are a half turn.
QList<int> reversed = original;
std::reverse(reversed.begin(), reversed.end());
QCOMPARE(reds(session), reversed);
QCOMPARE(session.activeLayer().value().transform.rotation, 180.0);
// Undo fits the view to the canvas it brings back.
session.zoom(4);
session.undo();
QCOMPARE(session.document().value().size(), QSizeF(2, 3));
const CanvasViewport fitted = [&] {
CanvasViewport viewport = session.viewport;
viewport.fit(QSizeF(2, 3));
return viewport;
}();
QCOMPARE(session.viewport, fitted);
session.undo();
QCOMPARE(session.document().value().size(), QSizeF(3, 2));
QCOMPARE(reds(session), original);
}
void CanvasRotationTests::guidesAndSelectionTurnWithTheCanvas()
{
EditorSession session;
session.createDocument(100, 60);
rewrite(session, [](ProjectSnapshot &snapshot) {
snapshot.manifest.guides = std::vector<CanvasGuide>{{QUuid::createUuid(), CanvasGuide::Axis::vertical, 30},
{QUuid::createUuid(), CanvasGuide::Axis::horizontal, 10}};
});
QPainterPath box;
box.addRect(0, 0, 20, 10);
session.setSelection(DocumentSelection{.path = box, .antialiased = false, .feather = 3}, QStringLiteral("Marquee"));
session.rotateCanvas(true);
const std::vector<CanvasGuide> &guides = session.document().value().guides;
// Clockwise: x becomes y; y, the new width less y.
QVERIFY(guides[0].axis == CanvasGuide::Axis::horizontal && guides[0].position == 30);
QVERIFY(guides[1].axis == CanvasGuide::Axis::vertical && guides[1].position == 50);
const DocumentSelection &selection = session.document().value().selection.value();
QCOMPARE(selection.path.boundingRect(), QRectF(50, 0, 10, 20));
QVERIFY(!selection.antialiased && selection.feather == 3);
// Counterclockwise from the start: the other way round.
session.undo();
session.rotateCanvas(false);
QVERIFY(session.document().value().guides[0].axis == CanvasGuide::Axis::horizontal && session.document().value().guides[0].position == 70);
QVERIFY(session.document().value().guides[1].axis == CanvasGuide::Axis::vertical && session.document().value().guides[1].position == 10);
QCOMPARE(session.document().value().selection.value().path.boundingRect(), QRectF(0, 80, 10, 20));
}
void CanvasRotationTests::turnedLayersAndPlacedMasksTurnAlong()
{
EditorSession session;
session.createDocument(100, 60);
session.insert(ImportedImage(pixels(), pixels(), QStringLiteral("Pixels")), QPointF(20, 10));
const QUuid id = session.activeLayerID().value();
rewrite(session, [id](ProjectSnapshot &snapshot) {
record(snapshot, id).transform.rotation = 300;
record(snapshot, id).transform.flipX = true;
setMask(snapshot, id, coverage());
record(snapshot, id).maskPlacement = LayerTransform{.origin = QPointF(70, 40), .size = QSizeF(10, 4), .rotation = -30};
record(snapshot, id).maskLinked = false;
});
const QPointF before = session.activeLayer().value().transform.center();
session.rotateCanvas(true);
const ImageLayer layer = session.activeLayer().value();
// A centre (x, y) goes to (60 - y, x).
QCOMPARE(layer.transform.center(), QPointF(60 - before.y(), before.x()));
QCOMPARE(layer.transform.size, QSizeF(3, 2));
QCOMPARE(layer.transform.rotation, 30.0);
QVERIFY(layer.transform.flipX);
const LayerTransform placed = layer.mask.value().placement.value();
QCOMPARE(placed.center(), QPointF(18, 75));
QCOMPARE(placed.rotation, 60.0);
// Swift's truncatingRemainder keeps the sign: -60, then -150.
session.rotateCanvas(false);
QCOMPARE(session.activeLayer().value().transform.center(), before);
QCOMPARE(session.activeLayer().value().transform.rotation, -60.0);
session.rotateCanvas(false);
QCOMPARE(session.activeLayer().value().transform.rotation, -150.0);
}
void CanvasRotationTests::itIsOneStepRefusedWhereLayersCannotChange()
{
EditorSession session;
session.rotateCanvas(true);
QVERIFY(!session.document());
canvas(session);
session.setIsProjectBusy(true);
QSignalSpy refused(&session, &EditorSession::changed);
session.rotateCanvas(true);
QCOMPARE(refused.count(), 0);
QCOMPARE(session.document().value().size(), QSizeF(3, 2));
session.setIsProjectBusy(false);
// A drawn crop frame goes first, as Swift's cancelCrop.
session.selectTool(NavigationTool::crop);
QVERIFY(session.cropRect());
session.zoom(3);
session.rotateCanvas(false);
QVERIFY(!session.cropRect());
QCOMPARE(session.history.undoName(), QString("Rotate Canvas 90° Counterclockwise"));
// The view fits the turned canvas.
CanvasViewport fitted = session.viewport;
fitted.fit(QSizeF(2, 3));
QCOMPARE(session.viewport, fitted);
session.undo();
session.rotateCanvas(true);
QCOMPARE(session.history.undoName(), QString("Rotate Canvas 90° Clockwise"));
session.undo();
QCOMPARE(session.document().value().size(), QSizeF(3, 2));
}
QTEST_GUILESS_MAIN(CanvasRotationTests)
#include "CanvasRotationTests.moc"
+2 -40
View File
@@ -20,7 +20,6 @@ private slots:
void transformEntriesFollowTheMoveTool();
void deleteMeansTheMaskTheLayerOrTheLayers();
void theMenusFollowTheFrontTab();
void imageEntriesSizeAndFlipTheCanvas();
};
void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
@@ -36,7 +35,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
{"mergeLayers", "Ctrl+E"}, {"flipHorizontal", ""}, {"flipVertical", ""}, {"deleteLayer", ""},
{"selectAll", "Ctrl+A"}, {"deselect", "Ctrl+D"}, {"inverse", "Ctrl+Shift+I"}, {"layerPixels", ""}, {"subject", "Ctrl+Alt+A"}, {"colorRange", ""}, {"maskBlackAreas", ""},
{"expandSelection", ""}, {"contractSelection", ""}, {"featherSelection", ""}, {"curves", "Ctrl+M"}, {"levels", "Ctrl+L"}, {"hueSaturation", "Ctrl+U"}, {"blackWhite", ""}, {"colorBalance", ""}, {"exposure", ""}, {"gradientMap", ""}, {"grain", ""}, {"invert", "Ctrl+I"},
{"canvasSize", "Ctrl+Alt+C"}, {"imageSize", "Ctrl+Alt+I"}, {"trim", ""}, {"flipCanvasHorizontal", ""}, {"flipCanvasVertical", ""},
{"canvasSize", "Ctrl+Alt+C"}, {"imageSize", "Ctrl+Alt+I"}, {"trim", ""}, {"rotateCanvasClockwise", ""}, {"rotateCanvasCounterclockwise", ""}, {"flipCanvasHorizontal", ""}, {"flipCanvasVertical", ""},
{"gaussianBlur", ""}, {"motionBlur", ""}, {"addNoise", ""}, {"lensCorrection", ""}, {"removeBackground", ""}, {"newAdjustmentLayer", ""}, {"editAdjustment", ""},
{"newHueSaturationAdjustment", ""}, {"newLevelsAdjustment", ""}, {"newCurvesAdjustment", ""}, {"newExposureAdjustment", ""},
{"newGradientMapAdjustment", ""}, {"newGrainAdjustment", ""}, {"newInvertAdjustment", ""}, {"newBlackWhiteAdjustment", ""},
@@ -57,7 +56,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
// Swift 1.3.4's Color Range… follows Subject.
const QList<QAction *> select = menus[3]->actions();
QCOMPARE(select.indexOf(&bar.action("colorRange")), select.indexOf(&bar.action("subject")) + 1);
QCOMPARE(menus[4]->actions().size(), 16);
QCOMPARE(menus[4]->actions().size(), 18);
// Swift's Trim… follows Image Size….
const QList<QAction *> image = menus[4]->actions();
QCOMPARE(image.indexOf(&bar.action("trim")), image.indexOf(&bar.action("imageSize")) + 1);
@@ -458,42 +457,5 @@ void CompositorMenusTests::theMenusFollowTheFrontTab()
QCOMPARE(bar.action("undo").text(), QString("Undo New Blank Layer"));
}
void CompositorMenusTests::imageEntriesSizeAndFlipTheCanvas()
{
Bar bar;
bar.window.show();
const char *entries[] = {"canvasSize", "imageSize", "trim", "flipCanvasHorizontal", "flipCanvasVertical"};
for (const char *name : entries)
QVERIFY2(!bar.action(name).isEnabled(), name);
bar.session().createDocument(100, 50);
bar.session().insert(white(), QPointF(20.5, 10.5));
for (const char *name : entries)
QVERIFY2(bar.action(name).isEnabled(), name);
// Busy holds them all; a drawn frame holds the flips.
bar.session().setIsProjectBusy(true);
for (const char *name : entries)
QVERIFY2(!bar.action(name).isEnabled(), name);
bar.session().setIsProjectBusy(false);
bar.session().selectTool(NavigationTool::crop);
QVERIFY(bar.action("canvasSize").isEnabled() && bar.action("trim").isEnabled() && !bar.action("flipCanvasHorizontal").isEnabled());
bar.session().selectTool(NavigationTool::move);
bar.action("flipCanvasHorizontal").trigger();
QCOMPARE(bar.session().activeLayer().value().origin(), QPointF(79, 10));
QCOMPARE(bar.session().history.undoName(), QString("Flip Canvas Horizontal"));
bar.action("flipCanvasVertical").trigger();
QCOMPARE(bar.session().activeLayer().value().origin(), QPointF(79, 39));
// The size entries open their sheets over the window.
for (const auto &[name, title] : {std::pair("canvasSize", "Canvas Size"), std::pair("imageSize", "Image Size"), std::pair("trim", "Trim")}) {
bar.action(name).trigger();
auto *sheet = bar.window.findChild<QDialog *>();
QVERIFY(sheet && sheet->isVisible());
QCOMPARE(sheet->windowTitle(), QString::fromLatin1(title));
QVERIFY(!bar.action(name).isEnabled());
QTest::keyClick(sheet, Qt::Key_Escape);
QTRY_VERIFY(!bar.session().isProjectBusy());
QTRY_VERIFY(!bar.window.findChild<QDialog *>());
}
}
QTEST_MAIN(CompositorMenusTests)
#include "CompositorMenusTests.moc"
+58
View File
@@ -0,0 +1,58 @@
#include "MenuFixtures.h"
#include <QDialog>
#include <QtTest>
// The Image menu's canvas entries, out of CompositorMenusTests.
class ImageMenuTests : public QObject {
Q_OBJECT
private slots:
void imageEntriesSizeAndFlipTheCanvas();
};
void ImageMenuTests::imageEntriesSizeAndFlipTheCanvas()
{
Bar bar;
bar.window.show();
const char *entries[] = {"canvasSize", "imageSize", "trim", "rotateCanvasClockwise", "rotateCanvasCounterclockwise", "flipCanvasHorizontal", "flipCanvasVertical"};
for (const char *name : entries)
QVERIFY2(!bar.action(name).isEnabled(), name);
bar.session().createDocument(100, 50);
bar.session().insert(white(), QPointF(20.5, 10.5));
for (const char *name : entries)
QVERIFY2(bar.action(name).isEnabled(), name);
// Busy holds them all; a drawn frame holds the flips.
bar.session().setIsProjectBusy(true);
for (const char *name : entries)
QVERIFY2(!bar.action(name).isEnabled(), name);
bar.session().setIsProjectBusy(false);
bar.session().selectTool(NavigationTool::crop);
QVERIFY(bar.action("canvasSize").isEnabled() && bar.action("trim").isEnabled() && !bar.action("flipCanvasHorizontal").isEnabled());
QVERIFY(!bar.action("rotateCanvasClockwise").isEnabled() && !bar.action("rotateCanvasCounterclockwise").isEnabled());
bar.session().selectTool(NavigationTool::move);
bar.action("flipCanvasHorizontal").trigger();
QCOMPARE(bar.session().activeLayer().value().origin(), QPointF(79, 10));
QCOMPARE(bar.session().history.undoName(), QString("Flip Canvas Horizontal"));
bar.action("flipCanvasVertical").trigger();
QCOMPARE(bar.session().activeLayer().value().origin(), QPointF(79, 39));
// A quarter turn swaps the sides and carries the layer.
bar.action("rotateCanvasClockwise").trigger();
QCOMPARE(bar.session().document().value().size(), QSizeF(50, 100));
QCOMPARE(bar.session().history.undoName(), QString("Rotate Canvas 90° Clockwise"));
bar.action("rotateCanvasCounterclockwise").trigger();
QCOMPARE(bar.session().document().value().size(), QSizeF(100, 50));
QCOMPARE(bar.session().activeLayer().value().origin(), QPointF(79, 39));
// The size entries open their sheets over the window.
for (const auto &[name, title] : {std::pair("canvasSize", "Canvas Size"), std::pair("imageSize", "Image Size"), std::pair("trim", "Trim")}) {
bar.action(name).trigger();
auto *sheet = bar.window.findChild<QDialog *>();
QVERIFY(sheet && sheet->isVisible());
QCOMPARE(sheet->windowTitle(), QString::fromLatin1(title));
QVERIFY(!bar.action(name).isEnabled());
QTest::keyClick(sheet, Qt::Key_Escape);
QTRY_VERIFY(!bar.session().isProjectBusy());
QTRY_VERIFY(!bar.window.findChild<QDialog *>());
}
}
QTEST_MAIN(ImageMenuTests)
#include "ImageMenuTests.moc"
+58
View File
@@ -56,6 +56,7 @@ class SharpCanvasTests : public QObject {
private slots:
void anUprightLayerPixelForPixelTakesNoFilter();
void plainMaskedAndTiledLayersCopyAcross();
void aQuarterTurnOnWholePixelsCopiesAcross();
};
void SharpCanvasTests::anUprightLayerPixelForPixelTakesNoFilter()
@@ -96,5 +97,62 @@ void SharpCanvasTests::plainMaskedAndTiledLayersCopyAcross()
}
}
void SharpCanvasTests::aQuarterTurnOnWholePixelsCopiesAcross()
{
// The columns stood up, centred at (10, 60).
const auto standing = [](double rotation, double nudge) {
return LayerTransform{.origin = {-40 + nudge, 58}, .size = {100, 4}, .rotation = rotation};
};
QImage surface = BrushRaster::context(20, 120, false);
QPainter painter(&surface);
const auto upright = [&](const LayerTransform &transform) {
QTransform placement;
placement.translate(transform.center().x(), transform.center().y());
placement.rotateRadians(transform.radians());
const QSizeF size = transform.size;
return LayerRenderer::upright(transform, placement, QRectF(-size.width() / 2, -size.height() / 2, size.width(), size.height()), painter);
};
for (double rotation : {90.0, -90.0, 180.0, 270.0, 450.0})
QVERIFY2(upright(standing(rotation, 0)), qPrintable(QString::number(rotation)));
QVERIFY(!upright(standing(90, 0.5)) && !upright(standing(90, 0.002)) && upright(standing(90, 0.0009)));
QVERIFY(!upright(standing(45, 0)) && !upright(standing(90.000001, 0)));
// Either axis off, or a side stretched: off the pixels.
LayerTransform low = standing(90, 0);
low.origin.ry() += 0.25;
// 4.5 across: one corner on a pixel, one between.
const LayerTransform stretched{.origin = {-40.25, 57.75}, .size = {100, 4.5}, .rotation = 90};
QVERIFY(!upright(low) && !upright(stretched));
// Swift's own upright holds off whole pixels too.
QVERIFY(upright(standing(0, 0.3)));
// The painter's own place counts.
painter.translate(0.5, 0);
QVERIFY(!upright(standing(90, 0)) && upright(standing(90, 0.5)));
painter.end();
const QImage image = columns();
const auto raster = std::make_shared<const RasterSnapshot>(100, 4, image, QRectF(0, 0, 100, 4), std::vector<BrushPatch>{});
const auto mids = [&](const LayerTransform &transform, bool tiled) {
QImage drawn = BrushRaster::context(20, 120, false);
QPainter onto(&drawn);
if (tiled)
TiledLayerRenderer::drawRaster(raster, transform, transform.center(), onto);
else
LayerRenderer::draw(image, transform, transform.center(), onto);
onto.end();
int count = 0;
for (int y = 10; y < 110; ++y) {
for (int x = 8; x < 12; ++x)
count += qRed(drawn.pixel(x, y)) % 255 != 0 || qAlpha(drawn.pixel(x, y)) != 255;
}
return count;
};
for (bool tiled : {false, true}) {
// On whole pixels: each column a row, exact.
QCOMPARE(mids(standing(90, 0), tiled), 0);
QCOMPARE(mids(standing(-90, 0), tiled), 0);
// Half a pixel off: filtered, as CoreGraphics blends it.
QVERIFY(mids(standing(90, 0.5), tiled) > 100);
}
}
QTEST_GUILESS_MAIN(SharpCanvasTests)
#include "SharpCanvasTests.moc"
+2
View File
@@ -100,6 +100,7 @@ compositor_test(CanvasThumbnailTests)
compositor_test(ClippingStackTests)
compositor_test(CompositorTests)
compositor_test(CompositorMenusTests)
compositor_test(ImageMenuTests)
compositor_test(ViewMenuTests)
compositor_test(KeyboardShortcutsTests)
compositor_test(KeyboardShortcutsSheetTests)
@@ -319,6 +320,7 @@ compositor_test(CameraRawGroupWordsTests)
target_include_directories(CameraRawWiringTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(LayerEffectsRendererTests)
compositor_test(LayerFlipTests)
compositor_test(CanvasRotationTests)
compositor_test(LayerMergeTests)
compositor_test(PixelEditTests)
compositor_test(PixelInvertTests)