diff --git a/AGENTS.md b/AGENTS.md index 531720c..e4fb1de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,10 +37,10 @@ Platform adaptations, each forced by a missing Qt analog: - `LayerRenderer::drawBrushPreview` replaces Swift's even-odd clips with `CompositionMode_Source` copies into the aside surface, hard-edged (`composite` runs with nearest sampling and the layer's interpolation quality); the source stays clipped to the grid and a raster's base to the source bounds, as Swift's clips keep them. Its `BrushPreview` struct carries Swift's labelled arguments. A raster counts as a source by itself, so a caller never flattens a painted asset just to pass a non-nil image. It throws `std::logic_error` on an empty pixel grid, where Swift would divide by zero. - `LiveMaskRenderer` has no `bounds`. Swift allocates its coverage and group surfaces over the clip's bounding box, one pixel per user unit, and clips with CoreGraphics; the twin's surfaces are the painter's device in device pixels, drawn with the painter's device transform, because clip coverage must be what `Options::clip` takes. `drawOwn` receives that coverage, and an outer clip (folder masks) multiplies in; a clipping stack composites its group one to one through `LayerRenderer::composite`, under the painter's own opacity as in Swift; `draw` saves and restores the painter around `drawOwn`, exceptions included. The canvas keeps this cheap by painting each dirty rect through an image surface (item 4.4). The constructor takes a pixel budget so tests can reach the limit; passing it, or failing to allocate, logs a warning on `omaphoto.rendering` where Swift skips in silence. - `AdjustmentSurface::draw` allocates what the painter can show in device pixels (Swift: one pixel per user unit), lets the body draw in the painter's own coordinates, and draws the surface back under the painter's own opacity, composition mode and clip, as `CGContext.draw` inherits them. A surface that cannot be allocated, or passes the budget, logs a warning and draws nothing. On Linux it also gives the hand-blended HSL modes a backdrop on devices without readable pixels (widgets, pixmaps). -- `ImageExporter` is a namespace of functions. Swift's actor only serialises calls; the twin keeps no state, and callers run it off the UI thread. Files are `QString` paths. PNG and JPEG go through `QImageWriter` with an sRGB tag and the resolution as dots per metre; the JPEG preview is read back with `QImageReader`, scaled to 1000 pixels at most. `write` replaces the file through `QSaveFile` and throws `std::runtime_error` with the system's message, where Swift rethrows the Cocoa error. Exports log on `omaphoto.io`. +- `ImageExporter` is a namespace of functions. Swift's actor only serialises calls; the twin keeps no state, and callers run it off the UI thread. Files are `QString` paths. PNG and JPEG go through `QImageWriter` with an sRGB tag and the resolution as dots per metre; the JPEG preview is read back with `QImageReader`, at full size up to `JPEGResult::previewLimit` (8192) a side (13.4b). `write` replaces the file through `QSaveFile` and throws `std::runtime_error` with the system's message, where Swift rethrows the Cocoa error. Exports log on `omaphoto.io`. - `ProjectStore` is a namespace too. Swift's synthesized `Codable` is written by hand in `IO/ProjectStore+Manifest.cpp`: the same keys, the same refusals (wrong types, missing required keys, non-canonical UUIDs), unknown keys ignored, `CGPoint` and `CGSize` as two-number arrays, UUIDs upper case (`uuidString`), two-space indentation so the 4 MiB limit bites where Swift's does. Asset files carry the upper-case names macOS writes; Linux file systems tell the cases apart. - Saving stages a hidden sibling folder, syncs its files and folders to disk, then swaps it in with `renameat2(RENAME_EXCHANGE)`; a first save is a plain `rename`. Where the system knows no exchange (FAT sticks, old kernels) the store logs a warning and replaces in two moves. Either way the parent folder is synced after the swap, the old project is kept until that sync succeeds, and a failed sync puts it back before the error is thrown. If the way back fails too, nothing is deleted: the old project stays where the swap left it (the hidden sibling, or its `.old` neighbour after two moves) and a critical log on `omaphoto.io` names that path. `ProjectReplaceTests` defines `rename`, `renameat2`, `fsync` and `read` in the test executable, where they take libc's place, to order and fail each call. There is no `NSFileCoordinator`. A missing manifest is `ProjectError(invalid)` and a missing asset `missingImage`, where Swift passes on a Cocoa error; a failed write throws `std::runtime_error` with the system's message. `QFile` reports no error when a read fails after partial data, so the manifest's byte count is checked against the file's size. The manifest's `version`, `width` and `height` are `qint64`, Swift's `Int`, read from Qt's exact integer (`QJsonValue::toVariant` as `LongLong`), never through a double: a wild number decodes and is refused by its own rule, with every digit of its value in the version error. One fact of Qt's parser stays, checked against Swift 6.0.3's decoder: a number token outside Int64, or one with a fraction or an exponent, passes through a double inside Qt. So the 1,024 integers below Int64's minimum and `-9.223372036854775808e18` read as the minimum where Swift refuses them, and `9007199254740993.0` loses its last digit where Swift keeps it. Every such value lies outside each valid range; only the kind or the number of the refusal differs. `ProjectTests` pins one. A whole number with a fraction or an exponent (`5.0`, `5e0`) reads exactly, as Swift's slow path reads it; a token that underflows a double (`1e-400`) fails Qt's whole parse as an illegal number, where Swift reads zero. Only a Grain seed (9.5a), every `UInt32` of which is valid, turns that into a project Swift opens and the twin refuses; `aSeedReadsWhatQtsParserReads` pins it. -- Qt refuses image decodes over 128 MB (`QImageReader::allocationLimit`). `ImageImporter::liftAllocationLimit` lifts that limit for the store and the importer: their own pixel budgets are checked from the file headers before any pixel is read. +- Qt refuses image decodes over 128 MB (`QImageReader::allocationLimit`). `ImageImporter::liftAllocationLimit` lifts that limit for the store, the importer and the JPEG export's full-size preview (13.4b): their own pixel budgets are checked from the file headers before any pixel is read. - `ImageImporter` is a namespace; the Swift actor only serialises calls and caches a `CIContext`. The type comes from the file's bytes, as `CGImageSourceGetType` reads it: `QImageReader::setDecideFormatFromContent` for JPEG, PNG and TIFF (TIFF needs `qt6-image-formats-plugins`), libheif's `heif_get_file_mime_type` for HEIC (`image/heic`: HEVC in HEIF). Other ISO media (AVIF, plain HEIF, video) and every other format Qt recognises are `unsupported`; bytes nothing recognises are `unreadable`. HEIC decodes through libheif, which applies the file's own turn and crop boxes, so its EXIF orientation is never applied again. Its colours come from the ICC profile, else from the `nclx` box, which ImageIO reads for Swift: the box's primaries (libheif hands over their chromaticities) with the curve it names: sRGB, linear, gamma 2.2 or 2.8 through Qt's own transfer functions, and BT.709's curve (shared by BT.601, BT.2020, xvYCC and BT.1361 between zero and one) as a table; unspecified primaries or curve count as sRGB's, like untagged pixels. Qt 6.4's `QColorSpace` constructor that takes points and a table builds an invalid space (probed), so the table is set afterwards. Curves with no conversion here (PQ and HLG, which need tone mapping; SMPTE 240M, SMPTE 428, the logarithmic ones), primaries Qt cannot hold and a box libheif rejects are `unreadable`, each with its reason in the log: no HEIC imports with colours the importer knows to be wrong. `heif_init` loads the decoder plugins (Ubuntu ships HEVC decoding as `libheif-plugin-libde265`); a missing decoder is `unreadable`, with libheif's message in the log. For Qt's formats the importer reads the pixels as stored, converts them to sRGB while the ICC profile is still attached, and only then applies the EXIF or TIFF orientation itself: Qt 6.4's `setAutoTransform` drops the colour space. Untagged pixels count as sRGB, as CoreImage counts them. Qt 6.4 reads no orientation from a PNG's `eXIf` chunk (probed: `supportsOption(ImageTransformation)` is false), so such a PNG imports as stored; JPEG, TIFF and HEIC turn upright. The result is `RGBA8888_Premultiplied`, tagged sRGB. A thumbnail's long side is 96 and its short side rounds up, as Swift's `extent.integral` does. The HEIC fixtures in `tests/fixtures` are committed; `scripts/heic-fixtures.sh` makes them and stops at the first failure, because the dev image carries no HEVC encoder. The tests patch the lossless `p3-nclx.heic` in memory to name other primaries and curves. - `LayerRenderer::drawCoverage`: Swift clips to the mask and fills white; QPainter has no soft clip. The twin builds white whose alpha is the mask (premultiplied, from the halved mask `draw` would use, since CoreGraphics filters a shrinking mask and QPainter's bilinear filter aliases) and draws it under the painter's own opacity and clip, in the composition mode under which alpha stands for a clip's coverage: the painter's own for every mode that is linear in the source and keeps pixels where the source is clear (source-over, the blend modes, the rest of Porter and Duff's), and its exact stand-in for the seven that are not (Source and Plus become SourceOver, Clear becomes DestinationOut, SourceIn SourceAtop, SourceOut Xor, DestinationIn Destination, DestinationAtop DestinationOver). `DrawCoverageTests` holds all 24 modes against the oracle, a fill of opaque white mixed in by coverage, over opaque, half clear and clear pixels. Those seven take no painter opacity (CoreGraphics scales the source by it, Qt mixes the result by it, and only linear modes make the two agree): below full opacity they are a `logic_error`, as is any raster operation. No caller sets a mode today. Source-over that is white through coverage, 255 a + d(1 - a), on gray and colour devices alike. It takes the placement `draw` takes, `radians()` included, so a wild angle turns both alike. A mask that is not gray is a `logic_error`; a white image that cannot be allocated is `ExportError(render)`. - QPainter places an unscaled, unturned image on whole device pixels whatever the hints say; CoreGraphics would blur it by the fraction. Layers the app makes sit on whole pixels, Shape lines aside (8.8), and any scale or turn takes QPainter's filtered path, so tests of soft edges draw under a scale. @@ -114,7 +114,7 @@ Platform adaptations, each forced by a missing Qt analog: - Remove Background (9.7): Apple's Vision has no Linux twin, so `Document/SubjectRemoval` runs U²-Net (Qin et al., Apache-2.0, its text in `licenses/U-2-Net.txt`) on ONNX Runtime (MIT), as rembg converted it (MIT): `u2net.onnx`, 175,997,641 bytes, SHA-256 `8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491`, the MD5 rembg pins. Chosen over `u2netp` (poorer edges), `silueta` (no clear source) and IS-Net's `isnet-general-use` (sharper, but the DIS repository puts only its code and metric under Apache-2.0 and its DIS5K training set under terms of its own, so the weights' terms are unclear); 90 ms a mask on 32 cores, measured. The model is found with `QStandardPaths::locate(GenericDataLocation, "omaphoto/u2net.onnx")`, so a package installs it as `share/omaphoto/u2net.onnx` and the dev image carries it in `/usr/local/share`. `Dockerfile.dev` fetches it and Microsoft's ONNX Runtime 1.21.0 build (Ubuntu 24.04 packages none; Ubuntu 25.04 on and Arch ship theirs with the same CMake config), both by pinned checksum; that build's CMake config goes to `/usr/local/lib/cmake`, which Ubuntu's CMake searches, and its library to `lib64`, where the config looks for it; CMake links `onnxruntime::onnxruntime`. One `Ort::Session` serves the process, loaded on first use; a load that throws leaves it unloaded, so the next use tries again. Every model step passes through `vision`, where ONNX Runtime's exceptions become `SubjectRemovalError(model)` and a failed allocation (the input buffer, the runtime wrapper's own vectors) becomes `ExportError(render)`, so none passes a worker's catch. `SubjectRemovalError` twins `SubjectRemoval.Failure`: `noSubject` with Swift's words, and a `model` kind for a missing model (naming every folder looked in) or ONNX Runtime's own message, where Swift passes on Vision's error. The input is the paper's and rembg's: the image premultiplied (clear reads as black), scaled to 320 by 320, divided by its brightest byte, then ImageNet's mean and spread; the first output is stretched from its lowest to its highest value, truncated to bytes as rembg does, and scaled to the layer's grid. An output with no value at or above one half is Swift's `noSubject` (flat images give under 0.03, measured). Known difference: U²-Net's mask is made at 320 points, so Basic's edges are softer than Vision's; Advanced's Refine pulls them onto the layer's own. The raw mask of the last image is kept by `cacheKey`, Swift's `MaskCache`. `Document/GuidedMatte` twins Swift's file: `box` keeps Swift's float sums, the column pass running a row at a time; `filter` frees what it no longer needs; `levels` scales through premultiplied RGBA (Qt's smooth scaling of Grayscale8 strays by a level shrinking and up to ten enlarging, probed) and reads gray over black through `qGray`; `refine` draws back up with `SmoothTransformation`, and the model's mask is enlarged through premultiplied RGBA too. `refined` does Core Image's Shift Edge (a Gaussian with the edge repeated through `PixelAdjust::gaussianBlur`, then Swift's clamp-and-scale cut) and Contrast in float over bytes, rounded once; the blur rounds to bytes first, a level at most. Contrast runs unconditionally, being the identity at 0. The preview (`run`) refines a copy 1400 long at most and multiplies each premultiplied byte by the mask into pixels of its own, copied before the loop so a copy that finds no memory is `ExportError(render)` (a shared image would copy inside the loop and come back null); the commit (`subjectMask`) refines at full size, multiplies in a mask of the layer's own grid (another grid is a `logic_error`, never read past) (disabled or not, which is then shown, as Swift's), blends through a selection over that mask or over white, and writes the mask in one `Remove Background` step with the mask chosen; its worker flattens painted pixels and masks. A fix beyond Swift: a placed mask's pixels were dropped but its placement kept, so the new mask, made in the layer's grid, sat at the old placement; the twin clears the placement. The filter sheet shows Swift's words, Quality as two touching checkable buttons (the label hidden, its accessible name) and Advanced's Refine, Contrast and Shift Edge rows with Swift's help, made once and shown while Advanced is chosen, as Swift's `if`. Equivalent mutants: the cache's key (only the time differs) and the commit worker's catch of `SubjectRemovalError` (after its preview succeeded, only ONNX Runtime failing mid-run could throw there). Tests: `GuidedMatteTests` (the box against plain sums, the filter against He's steps in doubles, overshoot included), `SubjectRemovalTests` (Swift's `visionRequestRunsOnAnImage` among them, on a red disc the model finds), `SubjectRemovalModelTests` (a binary of its own: a broken model, a missing one, then the real one), `SubjectRemovalFailureTests` (a starved preview and a starved model input, under `AddressSpaceLimit`, glibc's mmap threshold fixed so large requests map memory the limit counts), `RemoveBackgroundTests`, the sheet in `FilterControlsTests`, the kind in `FilterModelTests` and both menu tests. - Crop (10.1): `Document/Crop.h/.cpp` twin Swift's file, `Crop+Session.h` its session half. `CropGeometry` is a namespace; `CropDrag::Mode` is a kind and an index, as `TransformDrag::Mode` twins an enum with payloads; `CropSnap`'s `nearest` and Swift's local `shift` are file helpers; `cropSnapTargets` returns `SnapGuides`, Swift's tuple. `valid` and `commitCrop` read the frame normalised, as `CGRect`'s `width` and `minX` read it standardised; `valid`'s `isFinite` terms are left out, since the ranges refuse NaN and infinities. `cropRect`, `cropRatioChoice` and `cropError` are getters and setters that notify on a change. `commitCrop` takes a completion callback, posted on every path; the snapshot is taken and the worker started before anything changes, the resize runs through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, the worker catching `ProjectError` and `ExportError` (`CanvasResizer::resize` turns a `std::bad_alloc` of its records and maps into `ExportError(render)`, as 9.7's `vision` does), and the result is moved out (`takeResult`) and installed through `applyDocumentSize`, one `Crop` step; memory that runs out while the snapshot is taken or the result lands sets `cropError` to the render error and changes nothing else, so the project is never left busy. Only a crop that landed drops its frame (Swift drops it first); a failure sets `cropError` to the error's text and keeps it, and the project is freed after either, Swift's `defer`. Hooks: `selectTool` cancels on a change of tool and opens the Crop tool on the whole canvas, Free, when no frame is drawn; `restore`, `beginTransform`, `importImages`, `beginLevels`, `beginFilter` and `ProjectController::begin` cancel; `installProject` and `clearProject` drop the frame and announce it with their own signal; `canEditLayers` reads `cropRect`, `canUseHistory` does not, as in Swift. The canvas's gestures live in `EditorCanvas+Crop.cpp`. A press on a grip resizes (the first region holding the point, corners first, Swift's `first(where:)`), one inside a drawn frame that is not the whole canvas moves it, any other drops the frame and draws anew; `CGRect.contains` leaves out the far edges, so the file's own `contains` does too, where `QRectF::contains` takes them. The snap is built at the press, 8 view points (`cropSnapDistance`); Alt keeps the middle still, Ctrl leaves the edges unsnapped (Swift's Control, as the Move tool's snapping reads it), and either key changing mid-drag reshapes the frame at the last hover point, as Swift's monitor reads the mouse. Swift's `?? CGRect(origin: pixel, size: .zero)` and its `if let cropSnap` are dead (the Crop tool always shows a frame; the snap is built with the drag) and are checked reads. A drag goes on under the left button alone, with the Crop tool, a free project and a document, as Swift's `mouseDragged`; a move without the left button ends it, since Qt can lose a release. `endCropDrag` is Swift's `cropDrag = nil` with its `didSet`: the release, a lost focus, Escape (cancel) and Return or Enter (commit) end the drag and free its cursor. Wheels and pinches rest mid-drag. The drag keeps the cursor its press showed in `m_dragCursor` (Swift's `.current`, with the cursor rects disabled), taken before a new frame drops the old one. Cursors: picking first, then the open hand under Space, then over a grip Qt's resize shape for Swift's frame position (top left and bottom right `SizeFDiagCursor`, top right and bottom left `SizeBDiagCursor`, top and bottom `SizeVerCursor`, left and right `SizeHorCursor`; Qt's shapes say no inward or outward), elsewhere the crosshair. `TransformOverlay` gains `cropViewRect`, `cropResizeRegions` (a `CropRegion` struct for Swift's tuple) and `drawCrop`: the canvas outside the frame under 60% black (a `QPainterPath`'s own odd-even rule), a white frame, the thirds at 40% white in one path so their crossings paint once, and 8-point white grips with black rims, antialiased, with CoreGraphics' butt caps and miter joins. Known difference: at 1× QPainter strokes one-point lines through its cosmetic stroker, which draws no joins, so the outer corner pixels of the frame and the grips stay partly clear, as the text box's (8.9c). The dimming spans the canvas, so a frame that appears or goes repaints the canvas; a frame that changes repaints the old and the new frame with their grips (`drawnRect`), since outside both the dimming stays. That is the twin's `cropDraggingRedrawsOnlyTheOverlay`: Qt has no layers, so the union of the frames repaints, pixels included, never the canvas. Under the Crop tool `renderBounds` takes the frame's union, so a frame past the canvas shows the checkerboard it would take in and redraws the canvas, as in Swift. `CropControls` is the tool's `ToolHeaderBar`: its row spaced 14 as Swift's `HStack`, Swift's labelled picker a "Ratio" `QLabel` (its combo box's buddy) and a `QComboBox` sharing 170 points, a choice that changes the ratio calling `changeCropRatio` (Swift's `onChange`), the frame's size as whole pixels (Swift's `Int`, truncated) grouped through the English locale like the status bar (`monospacedDigit` has no Qt 6.4 twin), and Cancel and Apply Crop resting without a frame; the bar rests while busy shows or without a document. `ContentView` shows it for the Crop tool and the "Couldn’t crop" alert over `cropError`. Left out as dead: `renderBounds`' tool test (a frame lives under the Crop tool alone: every other way to another tool cancels it or asks `canEditLayers`, which a frame refuses) and the strips' `max(0, …)` in `cropResizeRegions` (a strip it would clip lies under the corners' regions, which take the point first). Equivalent mutants: `contains` on the visible frame for the drawn one (while a frame is drawn they are one), the tolerance's `max(…, 0.0001)` (the zoom's floor keeps points per pixel above it), `cropMove`'s answer after a lost release and after a step (the rest of the move only refreshes the cursor), the strips' ends (under the corners), the frame's caps and joins (its corners lie under the grips) `drawCrop`'s `setBrush(Qt::NoBrush)` (the painter arrives with none) and `takeResult` for `result()` in the landing (a copy asks only more memory, and its failure is caught the same). Tests: `CropTests` (Swift's six session and geometry tests; symmetric drags, snapping by side, bounds, ratios), `CropSessionTests` (a failed crop, refusals and the busy project, the signals, every hook, a flipped frame, one step that ends redo), `CropFixtures.h` (`committed`), `CropCanvasTests` (Swift's `cropDraggingRedrawsOnlyTheOverlay`; drags, Alt and Ctrl mid-drag, keys and lost releases, cursors, the dimming, a frame past the canvas), `CropControlsTests`, `CropFailureTests` (a binary of its own whose `main` shares the main heap among threads, `M_ARENA_MAX` 1 before any thread starts, and maps large asks, so a worker's allocations meet `AddressSpaceLimit`: a worker in an arena of its own grows its reserved heap past the limit; the snapshot, the worker and the landing each run out in turn), the bar and the alert in `ContentViewTests`. - Canvas and image size (10.2): `flipCanvas` sits in `LayerFlip.cpp`, as Swift's extension: after `commitTransform` and `cancelCrop` it asks `canEditLayers`, mirrors every layer's transform and every placed mask across the canvas's middle and the selection's outline through a `QTransform` (the path keeps its fill rule), one `Flip Canvas Horizontal` or `Flip Canvas Vertical` step; Swift's `finishOpacityEdit` is left out, since `commitTransform` has run it. `UI/CanvasSizeSheet` is Swift's form in a 450-point column: the title at 17 pixels, bold, the current size grouped through the English locale, the memory as Foundation's `ByteCountFormatter` counts it for memory (binary units: bytes, whole KB, MB to one place, GB to two, trailing zeros dropped; `UI/ByteCounts` since 10.3), a `Mid` divider, Units as a labelled `QComboBox`, Width and Height as `PickerField`s (a readable number applies on Return or on leaving, other text goes back, Swift's value binding; printed as Swift's `.number` to three places, none trailing, never grouped, as every field of the twin, halves rounding to even as Foundation's formatter rounds them), the Relative and Lock toggles (locking takes the width's proportions at once, Swift's `onChange`), the New line or the invalid note in `BrightText`, the anchor grid (nine 25-point buttons in an exclusive `QButtonGroup`, each a circle filled in the accent when chosen for Swift's `circle` and `circle.fill`, with Swift's help as the tooltip and accessible name and "Selected" as the accessible description, Swift's accessibility value), the anchor's name and note, the Canvas extension picker, and for Custom a label and a `DialogColorSwatch` on the app's picker (13.4a); then Cancel and the default OK, which rests while the draft is invalid, so Swift's guard in its action is left out. Units, Relative and Lock change only through their own controls, so nothing writes them back, and so does the image sheet's Resample. A wheel over Units changes the unit without the focus, so both sheets take the focus to Units first: a size still being typed commits in the unit it meant. `UI/ImageSizeSheet` is Swift's sheet: Pixels and Percent only while resampling, Width and Height converting by unit (Swift's binding refuses a number that is not positive; without resampling a size sets the resolution), Lock, disabled without resampling, the resolution with its unit (in inches or centimetres a new resolution scales the pixels, Swift's `onChange`), Resample (turned off, the pixels go back, the lock closes and Pixels or Percent becomes Inches), Sampling while resampling, the explanation, the result or the invalid note, Cancel and the default Resize. The controller's `canvasSize` and `imageSize` live in `IO/ProjectController+Resize.cpp` (with 10.3's exports the controller's file would pass 500 lines) and take a completion callback: refused without a window, a document or `begin`, as Swift's guards; the sheet is a window-modal `QDialog` over the window (`sheet`, Swift's `beginSheet`), whose Escape and close button cancel; the project stays busy until the sheet answers and the resize lands, or its alert is dismissed, Swift's `defer`. `resizeProject` takes the snapshot and starts the worker before anything changes, the worker catching `std::runtime_error` (both of the resizers' errors), and moves the result out and lands it through `applyDocumentSize` or `applyImageSize` (staged since 10.1); memory that runs out while the snapshot is taken or the result lands is explained as the render error. The alert opens from the handler, once the unwinding has freed what failed. The IO file includes the sheets, as Swift's controller hosts them. The Image menu gains Canvas Size… (Ctrl+Alt+C) and Image Size… (Ctrl+Alt+I), enabled with a document while the controller can start, and Flip Canvas Horizontal and Vertical, enabled by `canEditLayers`. Tests: `CanvasSizeSheetTests`, `ImageSizeSheetTests`, `ProjectResizeTests` (the sheets over a window, typed and answered by keys, a cancel, a refusal, a failure's alert), `ProjectResizeFailureTests` (a binary of its own, `main` as `CropFailureTests`': the image resizer, the snapshot, the worker and the landing each run out), the canvas flips in `LayerFlipTests`, the entries in `CompositorMenusTests`. -- Exports (10.3): `IO/ProjectController+Export.cpp` holds `exportPNG` and `exportJPEG`, each with a completion callback. `exportPNG` asks for a document and `begin` (no window: its panel then stands alone), takes the snapshot, opens the panel and renders, encodes and writes in one worker (`exportWork`, which catches `std::runtime_error`, the exporter's errors and the write's); `exportJPEG` also needs the window, renders in a worker (catching `ExportError`), shows `JPEGExportSheet` in the window-modal `sheet` of 10.2, then the panel, then writes the sheet's bytes in a worker. The project stays busy until the file is written, the flow is cancelled or its alert is dismissed, Swift's `defer`; a snapshot that finds no memory is explained as the render error, and `ImageExporter::render` and `jpeg` turn `std::bad_alloc` into `ExportError(render)` at their doors (function-try-blocks), which also serves 9.5c's editor render; the PNG snapshot's copy for the worker and `exportWork`'s launch are guarded too, explained as the render error. The launch's guard is not reached by the tests: its few small allocations fail only under a limit that fails the alert as well. The panel's callback and `showError`'s are moved, never copied, since the PNG flow's completion holds the snapshot. `exportPanel` is Swift's `NSSavePanel` for one type: a `QFileDialog` titled Export PNG or Export JPEG with the type's name filter and default suffix, suggesting the project's name (`ProjectTab::nameWithoutSuffix`, Swift's `deletingPathExtension`) or Untitled with `.png` or `.jpg`; a chosen name without one of the type's suffixes (`png`; `jpeg`, `jpg`, `jpe`; any case) gains the preferred one (`png`, and `jpeg`, `UTType.jpeg`'s preferred extension), where the Mac panel enforces the type, and as with Save As a completed name where anything exists is refused with an alert. `ImageExporter::jpeg` takes Swift's three cancellation points as an optional `cancelled` test, throwing `CancellationError` (Swift's type) before the flatten, before the encoding and before the preview. `UI/JPEGExportSheet` twins Swift's sheet: the title, the 560 by 330 preview on gray 0.12 (the encoded preview fitted, centred and smooth; while updating, a 64-point plate of the window colour at 85%, Swift's regular material, behind a 32-point busy `QProgressBar`), Quality as a slider in hundredths with its percent (`lround`, Swift's `rounded()`) 45 points wide and right-aligned, the matte as a label and a `DialogColorSwatch` on the app's picker (13.4a; the same colour asks nothing, as Swift's `onChange`), the raster's size grouped with "px · sRGB", then the error in `BrightText`, or the file's size and "· encoded preview, fitted to window", or "Updating preview…", and Cancel and the default Export…, enabled only while a ready result stands. Swift's `.task(id: options)` is `request`: it sets the last request's flag, clears the error and restarts `jpegWait`, a 200 ms single-shot `PreciseTimer` (Swift's sleep), whose timeout encodes in a worker holding the new flag; a result or error lands only while its flag is clear, Swift's check after the await; the destructor sets the last flag, as SwiftUI cancels the task with its view. Export stores the quality under Swift's key in `QSettings`; a stored value counts when it reads as a finite number, clamped to 0–1. `UI/ByteCounts` is Linux-only: Foundation's `ByteCountFormatter` in its memory style (binary) and file style (decimal), shared by the canvas sheet and the JPEG sheet: bytes grouped, KB whole, MB to one place, GB to two, trailing zeros dropped. The File menu gains Export PNG… (Ctrl+Shift+E) and Export JPEG… (Ctrl+Alt+Shift+S) after Save As, enabled with a document while the controller can start. Copy Merged came with 7.4. Tests: `JPEGExportSheetTests`, `ProjectExportTests` (the real panels and alerts from `DialogDesk`, names typed in full since a panel opens where the last one went, and the File menu's entries), `ProjectExportFailureTests` (a binary of its own: the render's door, the snapshot, the JPEG worker; `DialogDesk::aroundAccept` sets a limit around a panel's accept alone, since a dialog that paints under one can fail in FreeType and abort; the project holds 2,048 layers and the panel limits sit at 5,120 KB, between the snapshot's peak of 3,456 KB and a copying mutant's 6,912 KB, measured in 12.16, because the heap's free chunks count on top of a limit and shift with load; `projectSnapshot` reserves its record list, since a growing list held two at once (5,120 to 5,376 KB at its peak, which made the old 2,560 KB limits for 1,024 layers fail one run in three), and `aSnapshotHoldsOneRecordList` holds that at 4,608 KB; `limited`'s `malloc_trim(0)` is an equivalent mutant, since no free top chunk serves a mapping that size), `ByteCountsTests`, the cancellation points in `JPEGExportTests`, the shortcuts and the count in `CompositorMenusTests`. Equivalent mutants: the options a result lands with taken as the current ones (only the newest request lands, and its options are current), the `!m_error` terms of the size's visibility and of Export (an error follows only a failed encoding of options that never became ready, and encoding is deterministic), and the destructor's flag (it only stops a worker whose result nobody reads). +- 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_error` terms of the size's visibility and of Export (an error follows only a failed encoding of options that never became ready, and encoding is deterministic), and the destructor's flag (it only stops a worker whose result nobody reads). - File and picture drops (10.4): `IO/ImageFileDrop` twins Swift's enum as a namespace. `importProviders` takes the drop's `QMimeData`, where Swift takes item providers: each local file URL is a file, in order; a URL that is no file counts as unreadable, unless no file came at all and the drop holds a picture (a browser's or a screenshot's), whose bytes are copied to `Dropped-` in the temporary folder, as Swift's `temporaryFile` copies a promised file, the format chosen in Swift's order (PNG, JPEG, HEIC, TIFF, then any `image/` type) and the suffix from Qt's MIME database. The files go to the workspace's `receive` or, without a workspace, to the session's `importImages`; once that has run, a drop with anything unreadable adds Swift's words to the session's import error, after the importer's own ("from the file manager" where Swift says "from Finder"). Swift's `projects:` parameter has no caller and is left out. `holdsImages` is Swift's drop types but the layer row's: a local file or a picture. `receiveProviders` copies layer rows as before and hands anything else to `importProviders` one provider at a time (`providers`: a URL each, or, when no file came, one item holding the preferred picture alone, so a lazy drag is asked for nothing else, as Swift asks for one format; its web addresses are left behind, since alone they read as unreadable either way), each after the last has run, for the tab then in front, whose session hears what could not be read and stays alive until its import has run; so an import error holds the next file until it is dismissed, Swift's gate between providers. A copied picture that cannot be written whole (its flush checked, since `QFile` buffers) is removed and counts as unreadable; a failed flush fails `QFile::remove` too, so the copy is closed and unlinked by path. An open alert shows its message as it stands (`showAlert` updates it), as SwiftUI's alert re-reads it. The canvas, the tab buttons and the New canvas button take files and pictures as they take rows (a canvas without a workspace under Swift's session guards, Levels, busy, the new-canvas sheet, the importer and a rename, importing into its own session); Alt refuses only a row, as Swift's `validateDrop`. Tests: `ImageFileDropTests`. - Slider snapping (10.5): `UI/SliderSnap` twins Swift's enum as a namespace. Swift swaps `NSSliderCell.startTracking` so that a press on the track sets the value whose knob is centred there before tracking begins; the twin's `install` puts one application event filter in place, once, as Swift's lazy static swaps once: a left press on an enabled horizontal `QSlider` with a range, outside its knob and with travel for it, sets the slider down, then sets the value under the press (`QStyle::sliderValueFromPosition` over the groove less the knob, the knob's half-width off, flipped for an inverted slider), and presses where the knob now is, the original press consumed, so Qt drags the knob from there even when the slider's owner rounds the value and moves it (a filter's field); a press made with another button held is left to Qt, which refuses it. So editing begins before the value, Swift's order (the opacity slider's drag opens its one step first), and a press on the knob drags it from where it is. QSlider keeps its style option protected, so the filter builds one as `initStyleOption` does. Qt's own absolute set (`SH_Slider_AbsoluteSetButtons`) would set the value before the press and recentre a pressed knob, so it is not used, but it is the test's oracle for where the knob lands. `main` installs it after the theme. Tests: `SliderSnapTests` (Swift's `clickingTheTrackSnapsTheKnobAndStillEditsTheValue`: the value under the press at once, editing begun first, a drag going on, the release; the knob, other buttons, a disabled, vertical, empty, inverted and narrow slider). Equivalent mutant: the groove's own offset, zero under Fusion, the app's one style. The call in `main` is not under test, as the theme's construction there is not. - Selection modifiers, Select Subject, Soft Light and the font menu (12.1, Swift 1.1.1–1.1.4): `DocumentSelection` gains `feather`, the fade's reach in document pixels. Its coverage is filled antialiased and blurred by `PixelAdjust::gaussianBlur` at sigma feather / 2 with the edge repeated, Core Image's `clampedToExtent`; `coverageBounds` grows the outline's box by `ceil(2 × feather)`, and the clip, the copy region and a mask made from the selection (now drawn through `PixelAdjust::coverage` from the clip) take it. A feather stacks as blurs do (the root of the squares, 250 at most) and travels through moves, Expand, Contract and Inverse. The Select menu's Expand…, Contract… and Feather… ask for an amount (`promptSelectionAmount`); `selectionAmountOperation` is a getter and a setter that resumes file requests, read first by `canEditLayers`, `canUseHistory` and `canStartProjectOperation`. `SelectionAmountSheet` (in `LassoControls`, Swift's file) is Swift's sheet in a `FloatingPanel` named `selectionAmountPanel`, titled by the operation; its note keeps its room while hidden, as SwiftUI's opacity; `QString::toInt` trims spaces as Swift's `trimmingCharacters` does. The selection bar gains Feather and its amount (1–250). Select ▸ Subject (Ctrl+Alt+A) draws the live composite, runs `SubjectRemoval::subjectMask` in a worker with the project busy (a completion callback, as the other jobs), and on the same document applies `MaskTracing::whitePixels` (at least half gray) as the selection, one `Select Subject` step, or sets `brushError`; `canSelectSubject` leaves out Swift's document and busy terms, which `canEditLayers` reads. Soft Light joins `LayerBlendMode` after Overlay as QPainter's `CompositionMode_SoftLight`, which follows the PDF formula `BlendModeTests` holds. `TypeFontPicker` is Swift's: the style's own name alone until the menu opens, a missing name added at the end, an open menu left alone, the face already shown writing nothing, the current name elided at its end within 210 points (Fusion's label draws a pixel in from each side of the edit field). Point text caches its measure by style in the inline editor, and `setTextDraft` resumes file requests only when a draft ends, as Swift 1.1.3's. Swift's `Geometry` cache, which skips AppKit's frame work, has no twin: the canvas draws the editor. Equivalent mutants: the editor's box branch (`textBoxSize` returns the box), `setTextDraft`'s ended test (`resumeFileRequests` asks `canStartProjectOperation`, which a draft refuses). Tests: `SelectionFeatherTests` (Swift's, then stacking, travel, bounds and the sheet's session half), `SelectionAmountSheetTests`, `SelectSubjectTests`, `TypeFontPickerTests`, and the menus, blend modes and Type bar in their files. @@ -160,6 +160,7 @@ Platform adaptations, each forced by a missing Qt analog: - Dither's ASCII as text (13.3a, Swift 1.3.4: `2309a85`): `kernels/DitherPixels.c/.h` are v1.3.4's, whose glyph cells take `glyphWidth` and `glyphHeight`. `DitherSettings` gains `textSize` (6–64, 14, rounded); ASCII draws at full resolution, chunky pixels and their shape left alone. A glyph's cell is one bold monospace M wide (its advance, rounded) and `textSize` tall; the face is `lround(lineHeight / 1.2)` whole pixels where CoreText takes the fraction (known difference, as before); every letter stands on one baseline, centred in the line as Swift's AppKit metrics set it (`(height − (ascent + descent)) / 2 + descent` from the bottom, rounded), and is centred on its advance. The sheet shows Text Size for ASCII ("The height of each line of characters") and hides Pixel Size, Cell Size and Pixel Shape there. Equivalent mutants: the horizontal centring (a monospace advance is the cell, so the offset rounds to 0; only a double-width fallback glyph would move, and the dev image has none) and the kernel's cell rows counted by the width (a cell is narrower than tall, so only rows past the picture are added, never read). Tests: the glyph geometry and coverage against references drawn here at line heights 20 and 64 in `DitherTests`, the tiles, the picked table over dark and light columns and every cell's pick from its whole cell's ink (edge cells included, predicted here) in `DitherKernelTests`, the rows, ends, steps, field and unit in `DitherSheetTests`. - Select › Color Range (13.3b, Swift 1.3.4: `c3e360a`): `kernels/WandPixels.c/.h` are v1.3.4's, with `color_range_mask`. `Document/ColorRangeSelection.h/.cpp` twin Swift's file, `ColorRangeSelection+Session.h` its session half; `ColorRangeEdit` is a class in the session's `std::optional`, its `id` standing for Swift's object identity, its `image` the live composite at document size (`selectionSample` with every layer, premultiplied), so the kernel reads it directly where Swift draws it into a context first. `colorAt` averages the 3 × 3 pixels round the floored point, those past the picture clear as in Swift's context, and unpremultiplies with Swift's rounding; a point off the picture, clear or no number samples nothing. Each update is a worker of its own (`QtConcurrent::run` and a watcher a change, as Swift starts a task each) and lands only on the same edit, generation and document; it says so at once and again when its match lands, which sets the selection without a step (antialiased as the selection tools are) or, on a `MagicWandError` or `ExportError`, keeps the selection, shows the error and logs it on `omaphoto.app`. The preview is the mask as `Grayscale8` scaled smooth to twice the panel's fit, where Swift draws it at medium quality. OK applies the match as one `Color Range` step through `setSelection` (or `deselect` for a match of nothing) and always announces the close; with no colour or an error it keeps the old selection without a step; Cancel puts the old selection back. The gates (`canEditLayers`, `canUseHistory`, `canStartProjectOperation`) wait for the panel, and closing it resumes file requests (Swift's `didSet`; on opening it is a no-op, since an open panel blocks every waiter, and is left out). The sheet's writes (`setColorRangeSampleMode`, `setColorRangeHeld`, `setColorRangeFuzziness` clamped whole to 0–200, `setColorRangeInvert`) notify, an unchanged one silent. On the canvas Color Range is `picking`; a left press, Space aside, samples with Shift adding and Alt taking away (Alt first, as Swift's switch reads them) and refocuses the panel, after Levels' checks; the eyedropper cursor carries a black + or − on a white disc bottom right (`eyedropperCursor(ratio, badge)`) for the held key or the chosen eyedropper, and `syncPicking` redraws it when either changes; Shift and Alt reach `setColorRangeHeld` through the modifier monitor. Swift's `keepColorRangeCursor`, `pointerOverCanvas` and cursor-update monitor work round AppKit's cursor rects and have no twin. `UI/ColorRangeSheet` twins Swift's sheet: the three eyedroppers (`UI/SampleButton`, moved unchanged from the Hue/Saturation sheet so both share it), lit for the effective mode; the preview (black, the picture, a 20% white rim, the canvas's shape within 292 by 200); the caption in `PlaceholderText` (Alt for Option); Fuzziness as a scrubbable title, a slider and a 48-point field; Invert; the error in `BrightText`; Cancel and the default OK; 340 wide, 24 of padding. `ContentView` shows it in `colorRangePanel` while an edit is open, its close button and Escape cancelling, and the Select menu's Color Range… follows Subject, enabled by `canSelectColorRange`. Swift has no tests for this commit: `ColorRangeTests` pins the kernel, the sampling, the modes, Fuzziness, Invert, the preview, OK, Cancel, the gates, the announcements and an outline too detailed (a 2100-pixel checkerboard); `ColorRangeCanvasTests` the canvas's clicks and keys, the badged cursors, the sheet and the panel with its menu entry; `ColorRangeSheetTests` the sheet's layout, field, preview, Invert and error. Equivalent mutants: the no-colours branch's reset of the selection and preview (before any colour both hold those values already), the watcher's `deleteLater` (memory alone), the warning and `syncPicking`'s memory of the shown badge (it only spares cursor updates). Swift's `document` and `colorRange == nil` terms in `canSelectColorRange` are dead (`canEditLayers` reads both) and left out. Untested: the preview's two allocation checks and the worker's `ExportError` catch (only memory starvation reaches them), and the sheet's `releaseFocus` (without it the commit reads members that happen to live; no sanitizer runs). - Dialog colours (13.4a, Swift 1.3.5: `c580a38`): Export JPEG's background for transparency and Canvas Size's custom extension open the app's picker, where they opened the desktop's `QColorDialog`. `ColorPickerTarget` gains `dialog` with its `dialogTitle` (`Color Picker (JPEG Background)`); the change Swift keeps in the session as `dialogColorChange` rides in `ColorPickerState::dialogChange`, so it goes with its picker. `openDialogColorPicker` refuses while a picker is open; `previewDialogColor` hands the dialog the working colour and changes nothing of the session's, so it is silent, as Swift's `@ObservationIgnored` closure; `closeColorPicker` tells it the chosen colour or the original. `pickingForDialog` keeps the canvas from picking, and the picker's sheet leaves out "Click the canvas to sample" for a dialog. `DialogColorSwatch` lives in `UI/ColorPickerSheet+Dialog.h/.cpp`, where Swift keeps it in `ColorPickerSheet.swift`: it derives from `SwatchButton`, whose header includes the picker's. It is drawn as the brush's swatch (34 by 18, radius 4, a white ring, a black rim), named by its title, opens the picker on a click, hands it each new working colour on `changed()` (Swift's `onChange`, the last colour remembered), and closes it, keeping the colour, when hidden (Swift's `onDisappear`: Canvas Size's swatch hides when the extension leaves Custom). The sheets close it before Cancel, OK and Export, OK first so an open picker's colour is the fill, and in their destructors, before their members go, since the swatch's change writes into them. Both sheets take the session; Canvas Size reads the palette from it. A window-modal sheet blocks its window's other windows, the picker's panel among them, where a macOS sheet leaves the app's panels free: `FloatingPanel::show` parents a panel to `QApplication::activeModalWidget()` while one is up, else to its owner's window, moving a panel made earlier across; a panel under a sheet dies with it and the next is the window's again. Judged: `908bed9` (Qt delivers `applicationStateChanged` on the GUI thread, so `RecentProjects` hops nowhere) and `158cd53` (`ProjectDigest` already hashes a local's bytes). Tests: `DialogColorPickerTests` (the session, the swatch, the panel over a modal sheet, the canvas), the sheets' swatches in `JPEGExportSheetTests` and `CanvasSizeSheetTests`, the panel over the real Canvas Size sheet in `ProjectResizeTests`. Equivalent mutants: the swatch's memory of the picker's colour, kept or not (a sheet ignores a colour it holds). +- Export JPEG's zoomable preview (13.4b, Swift 1.3.5: `ac6f309`): the exporter decodes the preview at full size, 8192 a side at most, so 100% shows the file's own pixels. `JPEGPreview` (declared in `UI/JPEGExportSheet.h`, defined in `JPEGExportSheet+Preview.cpp`, as Swift keeps it beside the sheet) is a `QAbstractScrollArea` of Swift's 560 by 330 frame: Fit, or a zoom from Swift's steps (25% to 800%, `step` a thousandth either side), 1 being one image pixel a device pixel (`shownSize` divides the export's size by the device pixel ratio, as Swift by `displayScale`, so a preview decoded smaller still spans it); an image smaller than the view sits in its middle; a new zoom keeps the view's middle on the same part of the image, or starts at the image's middle from Fit (Swift's `keepCentered`, whose `before.width > 0` guard is dead and left out); a left drag moves it (the open hand, closed while dragging, the arrow in Fit); the wheel scrolls; a left double click switches Fit and 100%; from 100% up it draws nearest, below 100% through an area-averaged copy kept per size (`QImage::scaled`, Swift's high quality; QPainter's bilinear filter aliases a big reduction), dropped only for a new image. Without mouse tracking Qt delivers no buttonless move, so a lost release cannot be seen, and a new press restarts the drag. The zoom reaches the sheet through a callback given at construction. The sheet's title row carries Fit (resting in Fit) and the two magnifiers (`LayerIcons`' `plusMagnifyingGlass` and `minusMagnifyingGlass`, redrawn on `PaletteChange`), each resting at its end, with Swift's help naming the key and the zoom; the title row sits 8 above the preview (Swift's `padding(.bottom, -8)`); the size, the result and the buttons share the bottom row, 12 apart. Swift's `previewZoom` hook routes the View menu's Fit Canvas, Actual Pixels, Zoom In and Zoom Out to the preview; a window-modal sheet blocks the window's menus, so the sheet binds those four keys itself as `QShortcut`s, as remapped (`ShortcutSettings::menu`), again on every remap, and no session hook is needed. The import error's second OK has no twin: the alert already holds one button. Tests: `JPEGPreviewTests` (each drawing against `QImage::scaled` references), `JPEGExportSheetTests`, `JPEGExportTests`' limit, the keys through the real menus in `ProjectExportTests`, device pixels in `HighDpiCanvasTests`. Equivalent mutants: the nearest branch from above 1 rather than from 1 (at 100% the averaged copy is the image itself), and dropping the averaged copy for an image already held (it only costs a new average). - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/CMakeLists.txt b/CMakeLists.txt index a8c246c..8e1fd62 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -101,6 +101,7 @@ add_library(compositor_core STATIC src/UI/ImageSizeSheet.cpp src/UI/TrimSheet.cpp src/UI/JPEGExportSheet.cpp + src/UI/JPEGExportSheet+Preview.cpp src/UI/ByteCounts.cpp src/UI/SliderSnap.cpp src/UI/GradientControls.cpp diff --git a/TASKS.md b/TASKS.md index 5453e7d..1eb21ac 100644 --- a/TASKS.md +++ b/TASKS.md @@ -423,7 +423,8 @@ Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`) - 13.3b review (Fable 5.1): round 1 FAIL (the sheet's native keys unbound; test gaps: the click's rows and floor, stale and foreign results, the selection kept on error, the preview's truncation); round 2 FAIL (OK after an error, the antialiased flag; two dead gate terms noted); round 3 PASS. - [x] 13.4a Version 1.3.5, part one: Export JPEG and Canvas Size take the app's colour picker (`c580a38`); `908bed9` and `158cd53` judged: Qt signals arrive on the GUI thread, and the digest already hashes a local's bytes. - 13.4a review (Fable 5.1): round 1 FAIL (the swatch's minimize case, its white ring and radius untested); round 2 PASS. -- [ ] 13.4b Part two: Export JPEG's zoomable preview and tidier layout, the View menu's zoom on it, one OK on the import error (`ac6f309`). +- [x] 13.4b Part two: Export JPEG's zoomable preview and tidier layout, the View menu's zoom on it, one OK on the import error (`ac6f309`). +- 13.4b review (Fable 5.1): round 1 FAIL (the full-size preview passed Qt's 128 MB decode limit on large canvases, so they could not export; the kept middle's `min` untested); round 2 PASS. - [ ] 13.5 Part two: the marquee, shapes and a moved selection snap to View › Snap To (`d6e3e93`, `8b1369a`); View › Grid Settings… with Restore Defaults (`1c819d0`, `db5eafa`). - [ ] 13.6 Part three: Ctrl+Z while typing takes back what was typed (`ee68ba9`); font previews in the Type bar (`1bd1df4`). - [ ] 13.7 Versions 1.3.6–1.3.7: New Canvas presets in a More menu (`aaf3dc0`); the project's Quick Look preview (`14aad18`, `4868465`) judged: the package's preview file is format, Finder's thumbnail extension is macOS alone. diff --git a/src/IO/ImageExporter.cpp b/src/IO/ImageExporter.cpp index e22cd0e..311e1dd 100644 --- a/src/IO/ImageExporter.cpp +++ b/src/IO/ImageExporter.cpp @@ -4,6 +4,7 @@ #include "Document/LayerGroups.h" #include "Document/LayerMask.h" #include "Document/LiveLayerMask.h" +#include "IO/ImageImporter.h" #include "Logging.h" #include "Rendering/LayerRenderer.h" #include "Rendering/LiveMaskRenderer.h" @@ -143,12 +144,13 @@ try { QByteArray data = encode(flattened, "jpeg", qRound(std::clamp(options.quality, 0.0, 1.0) * 100), raster.resolution); check(); // The preview shows what the file holds, compression and all. + ImageImporter::liftAllocationLimit(); QBuffer buffer(&data); QImageReader reader(&buffer, "jpeg"); const QSize size = reader.size(); - // A thin image keeps one pixel across. - if (std::max(size.width(), size.height()) > 1000) - reader.setScaledSize(size.scaled(1000, 1000, Qt::KeepAspectRatio).expandedTo(QSize(1, 1))); + // Full size for the 100% view, capped; one pixel across. + if (std::max(size.width(), size.height()) > JPEGResult::previewLimit) + reader.setScaledSize(size.scaled(JPEGResult::previewLimit, JPEGResult::previewLimit, Qt::KeepAspectRatio).expandedTo(QSize(1, 1))); const QImage preview = reader.read(); if (preview.isNull()) throw ExportError(ExportError::Kind::encode); diff --git a/src/IO/ImageExporter.h b/src/IO/ImageExporter.h index 98f52df..330b04e 100644 --- a/src/IO/ImageExporter.h +++ b/src/IO/ImageExporter.h @@ -32,8 +32,9 @@ struct JPEGOptions { struct JPEGResult { QByteArray data; - // Decoded from `data`, at most 1000 pixels a side. + // Decoded from `data`, at most `previewLimit` pixels a side. QImage preview; + static constexpr int previewLimit = 8192; }; namespace ImageExporter { diff --git a/src/UI/JPEGExportSheet+Preview.cpp b/src/UI/JPEGExportSheet+Preview.cpp new file mode 100644 index 0000000..8d4cae3 --- /dev/null +++ b/src/UI/JPEGExportSheet+Preview.cpp @@ -0,0 +1,178 @@ +#include "UI/JPEGExportSheet.h" +#include <QMouseEvent> +#include <QPainter> +#include <QScrollBar> +#include <algorithm> +#include <cmath> + +double JPEGPreview::fitZoom(int width, int height, QSizeF frame, double displayScale) +{ + const double scale = std::max(1.0, displayScale); + return std::min(frame.width() / (width / scale), frame.height() / (height / scale)); +} + +std::optional<double> JPEGPreview::step(double zoom, int direction) +{ + if (direction > 0) { + const auto next = std::find_if(steps.begin(), steps.end(), [zoom](double step) { return step > zoom * 1.001; }); + return next == steps.end() ? std::nullopt : std::optional(*next); + } + const auto next = std::find_if(steps.rbegin(), steps.rend(), [zoom](double step) { return step < zoom * 0.999; }); + return next == steps.rend() ? std::nullopt : std::optional(*next); +} + +JPEGPreview::JPEGPreview(QSize pixels, std::function<void()> zoomChanged, QWidget *parent) + : QAbstractScrollArea(parent), m_pixels(pixels), m_zoomChanged(std::move(zoomChanged)) +{ + setFixedSize(frame); + setFrameShape(QFrame::NoFrame); + setToolTip(QStringLiteral("Drag or scroll to move around; double-click switches between Fit and 100%")); + horizontalScrollBar()->setSingleStep(20); + verticalScrollBar()->setSingleStep(20); +} + +void JPEGPreview::setImage(const QImage &image, bool updating) +{ + // A new image only: the averaged copy still holds. + if (image.cacheKey() != m_image.cacheKey()) + m_reduced = QImage(); + m_image = image; + m_updating = updating; + viewport()->update(); +} + +double JPEGPreview::shownZoom() const +{ + return m_zoom.value_or(fitZoom(m_pixels.width(), m_pixels.height(), QSizeF(frame), devicePixelRatioF())); +} + +void JPEGPreview::zoomBy(int direction) +{ + if (const std::optional<double> next = step(shownZoom(), direction)) + setZoom(next); +} + +// Swift's keepCentered: the middle stays on the same part. +void JPEGPreview::setZoom(std::optional<double> zoom) +{ + if (zoom == m_zoom) + return; + const QSizeF view(viewport()->size()); + QPointF fraction(0.5, 0.5); + if (m_zoom) { + const QSizeF before = shownSize(*m_zoom); + fraction = QPointF((horizontalScrollBar()->value() + std::min(view.width(), before.width()) / 2) / before.width(), + (verticalScrollBar()->value() + std::min(view.height(), before.height()) / 2) / before.height()); + } + m_zoom = zoom; + updateRanges(); + if (m_zoom) { + const QSizeF size = shownSize(*m_zoom); + const QSizeF shown(viewport()->size()); + const double x = std::clamp(fraction.x() * size.width() - shown.width() / 2, 0.0, std::max(0.0, size.width() - shown.width())); + const double y = std::clamp(fraction.y() * size.height() - shown.height() / 2, 0.0, std::max(0.0, size.height() - shown.height())); + horizontalScrollBar()->setValue(int(std::lround(x))); + verticalScrollBar()->setValue(int(std::lround(y))); + } + m_drag.reset(); + showCursor(false); + viewport()->update(); + m_zoomChanged(); +} + +// The image's size on screen at `zoom`, in points. +QSizeF JPEGPreview::shownSize(double zoom) const +{ + return QSizeF(m_pixels) / std::max(1.0, devicePixelRatioF()) * zoom; +} + +void JPEGPreview::updateRanges() +{ + const QSizeF size = m_zoom ? shownSize(*m_zoom) : QSizeF(0, 0); + const QSize view = viewport()->size(); + horizontalScrollBar()->setRange(0, std::max(0, int(std::ceil(size.width() - view.width())))); + horizontalScrollBar()->setPageStep(view.width()); + verticalScrollBar()->setRange(0, std::max(0, int(std::ceil(size.height() - view.height())))); + verticalScrollBar()->setPageStep(view.height()); +} + +void JPEGPreview::showCursor(bool grabbing) +{ + viewport()->setCursor(!m_zoom ? Qt::ArrowCursor : grabbing ? Qt::ClosedHandCursor : Qt::OpenHandCursor); +} + +void JPEGPreview::paintEvent(QPaintEvent *) +{ + QPainter painter(viewport()); + const QRectF view(viewport()->rect()); + painter.fillRect(view, QColor::fromRgbF(0.12f, 0.12f, 0.12f)); + if (!m_image.isNull()) { + QRectF target; + if (m_zoom) { + // Smaller than the view, it sits in the middle. + const QSizeF size = shownSize(*m_zoom); + target = QRectF(QPointF(size.width() < view.width() ? (view.width() - size.width()) / 2 : -horizontalScrollBar()->value(), + size.height() < view.height() ? (view.height() - size.height()) / 2 : -verticalScrollBar()->value()), + size); + } else { + target = QRectF(QPointF(), QSizeF(m_image.size()).scaled(view.size(), Qt::KeepAspectRatio)); + target.moveCenter(view.center()); + } + // Nearest from 100% up: each JPEG pixel as it is. + if (m_zoom && *m_zoom >= 1) { + painter.drawImage(target, m_image); + } else { + // Swift's high quality: an area average, kept per size. + const QSize device = (target.size() * devicePixelRatioF()).toSize().expandedTo(QSize(1, 1)); + if (m_reduced.size() != device) + m_reduced = m_image.scaled(device, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + painter.drawImage(target, m_reduced); + } + } + if (m_updating) { + // Swift's regular material behind the spinner. + QColor material = palette().color(QPalette::Window); + material.setAlphaF(0.85f); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + painter.setBrush(material); + QRectF plate(0, 0, 64, 64); + plate.moveCenter(view.center()); + painter.drawRoundedRect(plate, 8, 8); + } +} + +void JPEGPreview::resizeEvent(QResizeEvent *event) +{ + QAbstractScrollArea::resizeEvent(event); + updateRanges(); +} + +void JPEGPreview::mousePressEvent(QMouseEvent *event) +{ + if (m_zoom && event->button() == Qt::LeftButton) + m_drag = {event->position().toPoint(), QPoint(horizontalScrollBar()->value(), verticalScrollBar()->value())}; +} + +void JPEGPreview::mouseMoveEvent(QMouseEvent *event) +{ + if (!m_drag) + return; + const QPoint moved = event->position().toPoint() - m_drag->first; + horizontalScrollBar()->setValue(m_drag->second.x() - moved.x()); + verticalScrollBar()->setValue(m_drag->second.y() - moved.y()); + showCursor(true); +} + +void JPEGPreview::mouseReleaseEvent(QMouseEvent *) +{ + m_drag.reset(); + showCursor(false); +} + +// Double-click switches between Fit and 100%. +void JPEGPreview::mouseDoubleClickEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) + setZoom(m_zoom ? std::nullopt : std::optional(1.0)); +} diff --git a/src/UI/JPEGExportSheet.cpp b/src/UI/JPEGExportSheet.cpp index 85dea71..77a9d43 100644 --- a/src/UI/JPEGExportSheet.cpp +++ b/src/UI/JPEGExportSheet.cpp @@ -2,6 +2,7 @@ #include "UI/KeyboardShortcuts.h" #include "UI/ByteCounts.h" #include "UI/ColorPickerSheet+Dialog.h" +#include "UI/LayerIcons.h" #include <QFutureWatcher> #include <QHBoxLayout> #include <QLabel> @@ -9,6 +10,7 @@ #include <QProgressBar> #include <QPushButton> #include <QSettings> +#include <QShortcut> #include <QSlider> #include <QTimer> #include <QVBoxLayout> @@ -50,59 +52,46 @@ struct Encoded { }; } -// Swift's ZStack: dark gray, the fitted preview, a plate. -class JPEGExportSheet::Preview : public QWidget { -public: - using QWidget::QWidget; - QImage image; - bool updating = false; - -protected: - void paintEvent(QPaintEvent *) override - { - QPainter painter(this); - painter.fillRect(rect(), QColor::fromRgbF(0.12f, 0.12f, 0.12f)); - if (!image.isNull()) { - painter.setRenderHint(QPainter::SmoothPixmapTransform); - QRectF target(QPointF(), QSizeF(image.size()).scaled(QSizeF(size()), Qt::KeepAspectRatio)); - target.moveCenter(QRectF(rect()).center()); - painter.drawImage(target, image); - } - if (updating) { - // Swift's regular material behind the spinner. - QColor material = palette().color(QPalette::Window); - material.setAlphaF(0.85f); - painter.setRenderHint(QPainter::Antialiasing); - painter.setPen(Qt::NoPen); - painter.setBrush(material); - QRectF plate(0, 0, 64, 64); - plate.moveCenter(QRectF(rect()).center()); - painter.drawRoundedRect(plate, 8, 8); - } - } -}; - JPEGExportSheet::JPEGExportSheet(ExportRaster raster, EditorSession &session, std::function<void(std::optional<QByteArray>)> finish, QWidget *parent) - : QWidget(parent), m_raster(std::move(raster)), m_session(session), m_finish(std::move(finish)), m_wait(new QTimer(this)), m_preview(new Preview(this)), + : QWidget(parent), m_raster(std::move(raster)), m_session(session), m_finish(std::move(finish)), m_wait(new QTimer(this)), m_preview(new JPEGPreview(m_raster.image.size(), [this] { synchronize(); }, this)), m_spinner(new QProgressBar(m_preview)), m_quality(new QSlider(Qt::Horizontal, this)), m_percent(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)), m_matteSwatch(new DialogColorSwatch( QStringLiteral("JPEG Background"), [this] { return PaletteColor{m_options.red, m_options.green, m_options.blue}; }, [this](const PaletteColor &matte) { setMatte(matte); }, session, this)), m_failure(text(QString(), 13, QFont::Normal, QPalette::BrightText, this)), m_bytes(text(QString(), 13, QFont::Normal, QPalette::WindowText, this)), - m_note(text(QString(), 13, QFont::Normal, QPalette::PlaceholderText, this)), m_export(new QPushButton(QStringLiteral("Export…"), this)) + m_note(text(QString(), 13, QFont::Normal, QPalette::PlaceholderText, this)), m_export(new QPushButton(QStringLiteral("Export…"), this)), + m_fit(new QPushButton(QStringLiteral("Fit"), this)), m_zoomIn(new QPushButton(this)), m_zoomOut(new QPushButton(this)) { m_options.quality = startQuality(); auto *column = new QVBoxLayout(this); column->setContentsMargins(24, 24, 24, 24); column->setSpacing(16); - column->addWidget(text(QStringLiteral("Export JPEG"), 17, QFont::Bold, QPalette::WindowText, this)); + // The title row sits 8 above; it inherits 8. + auto *top = new QVBoxLayout; + top->setSpacing(8); + auto *title = new QHBoxLayout; + title->addWidget(text(QStringLiteral("Export JPEG"), 17, QFont::Bold, QPalette::WindowText, this)); + title->addStretch(1); + m_fit->setObjectName(QStringLiteral("jpegFit")); + m_zoomIn->setObjectName(QStringLiteral("jpegZoomIn")); + m_zoomIn->setAccessibleName(QStringLiteral("Zoom in")); + m_zoomOut->setObjectName(QStringLiteral("jpegZoomOut")); + m_zoomOut->setAccessibleName(QStringLiteral("Zoom out")); + for (QPushButton *button : {m_fit, m_zoomIn, m_zoomOut}) { + button->setAutoDefault(false); + title->addWidget(button); + } + connect(m_fit, &QPushButton::clicked, this, [this] { m_preview->setZoom(std::nullopt); }); + connect(m_zoomIn, &QPushButton::clicked, this, [this] { m_preview->zoomBy(1); }); + connect(m_zoomOut, &QPushButton::clicked, this, [this] { m_preview->zoomBy(-1); }); + top->addLayout(title); m_preview->setObjectName(QStringLiteral("jpegPreview")); - m_preview->setFixedSize(560, 330); m_spinner->setObjectName(QStringLiteral("jpegSpinner")); m_spinner->setRange(0, 0); m_spinner->setTextVisible(false); m_spinner->setGeometry(264, 149, 32, 32); - column->addWidget(m_preview); + top->addWidget(m_preview); + column->addLayout(top); auto *quality = new QHBoxLayout; auto *qualityLabel = new QLabel(QStringLiteral("Quality"), this); @@ -132,17 +121,22 @@ JPEGExportSheet::JPEGExportSheet(ExportRaster raster, EditorSession &session, st matte->addWidget(m_matteSwatch); matte->addStretch(1); column->addLayout(matte); - column->addWidget(text(QStringLiteral("%1 × %2 px · sRGB").arg(grouped(m_raster.image.width()), grouped(m_raster.image.height())), 13, QFont::Normal, - QPalette::PlaceholderText, this)); + // The size, the result and the buttons share one row. auto *buttons = new QHBoxLayout; + buttons->setSpacing(12); + QLabel *size = text(QStringLiteral("%1 × %2 px · sRGB").arg(grouped(m_raster.image.width()), grouped(m_raster.image.height())), 13, QFont::Normal, + QPalette::PlaceholderText, this); + size->setObjectName(QStringLiteral("jpegSize")); + buttons->addWidget(size); + buttons->addStretch(1); m_failure->setObjectName(QStringLiteral("jpegError")); m_bytes->setObjectName(QStringLiteral("jpegBytes")); m_note->setObjectName(QStringLiteral("jpegNote")); + m_note->setText(QStringLiteral("Updating…")); buttons->addWidget(m_failure); buttons->addWidget(m_bytes); buttons->addWidget(m_note); - buttons->addStretch(1); auto *cancel = new QPushButton(QStringLiteral("Cancel"), this); cancel->setObjectName(QStringLiteral("jpegCancel")); cancel->setAutoDefault(false); @@ -169,9 +163,28 @@ JPEGExportSheet::JPEGExportSheet(ExportRaster raster, EditorSession &session, st m_wait->setInterval(200); m_wait->setTimerType(Qt::PreciseTimer); connect(m_wait, &QTimer::timeout, this, &JPEGExportSheet::encode); + bindZoomKeys(); + connect(&ShortcutSettings::shared(), &ShortcutSettings::changed, this, &JPEGExportSheet::bindZoomKeys); request(); } +// A window-modal sheet blocks the menus: it takes their keys. +void JPEGExportSheet::bindZoomKeys() +{ + const std::array<QKeySequence, 4> originals{QKeySequence(Qt::CTRL | Qt::Key_0), QKeySequence(Qt::CTRL | Qt::Key_1), + QKeySequence(Qt::CTRL | Qt::Key_Equal), QKeySequence(Qt::CTRL | Qt::Key_Minus)}; + const std::array<std::function<void()>, 4> actions{[this] { m_preview->setZoom(std::nullopt); }, [this] { m_preview->setZoom(1.0); }, + [this] { m_preview->zoomBy(1); }, [this] { m_preview->zoomBy(-1); }}; + for (size_t index = 0; index < m_zoomKeys.size(); ++index) { + if (!m_zoomKeys[index]) { + m_zoomKeys[index] = new QShortcut(this); + connect(m_zoomKeys[index], &QShortcut::activated, this, actions[index]); + } + m_zoomKeys[index]->setKey(ShortcutSettings::shared().menu(originals[index])); + } + synchronize(); +} + // SwiftUI cancels the task when the sheet goes. JPEGExportSheet::~JPEGExportSheet() { @@ -233,20 +246,37 @@ void JPEGExportSheet::setMatte(const PaletteColor &matte) request(); } +void JPEGExportSheet::changeEvent(QEvent *event) +{ + QWidget::changeEvent(event); + if (event->type() == QEvent::PaletteChange) + synchronize(); +} + void JPEGExportSheet::synchronize() { const bool ready = m_result && m_readyOptions == m_options; const bool updating = m_readyOptions != m_options && !m_error; - m_preview->image = m_result ? m_result->preview : QImage(); - m_preview->updating = updating; - m_preview->update(); + m_preview->setImage(m_result ? m_result->preview : QImage(), updating); m_spinner->setVisible(updating); m_percent->setText(QStringLiteral("%1%").arg(std::lround(m_options.quality * 100))); m_failure->setVisible(m_error.has_value()); m_failure->setText(m_error.value_or(QString())); m_bytes->setVisible(ready && !m_error); m_bytes->setText(m_result ? ByteCounts::file(m_result->data.size()) : QString()); - m_note->setVisible(!m_error); - m_note->setText(ready ? QStringLiteral("· encoded preview, fitted to window") : QStringLiteral("Updating preview…")); + m_note->setVisible(!ready && !m_error); m_export->setEnabled(ready && !m_error); + // The zoom buttons name their keys and the zoom. + const double zoom = m_preview->shownZoom(); + const QString percent = QStringLiteral("%1%").arg(std::lround(zoom * 100)); + const auto key = [this](size_t index) { return m_zoomKeys[index] ? m_zoomKeys[index]->key().toString(QKeySequence::NativeText) : QString(); }; + m_fit->setEnabled(m_preview->zoom().has_value()); + m_fit->setToolTip(QStringLiteral("Show the whole image (%1)").arg(key(0))); + m_zoomIn->setEnabled(JPEGPreview::step(zoom, 1).has_value()); + m_zoomIn->setToolTip(QStringLiteral("Zoom in (%1), now %2. At 100% each pixel of the JPEG is one pixel of the screen, as on the canvas").arg(key(2), percent)); + m_zoomOut->setEnabled(JPEGPreview::step(zoom, -1).has_value()); + m_zoomOut->setToolTip(QStringLiteral("Zoom out (%1), now %2").arg(key(3), percent)); + const QColor ink = palette().color(QPalette::ButtonText); + m_zoomIn->setIcon(LayerIcons::pixmap(LayerIcon::plusMagnifyingGlass, 16, ink, devicePixelRatioF())); + m_zoomOut->setIcon(LayerIcons::pixmap(LayerIcon::minusMagnifyingGlass, 16, ink, devicePixelRatioF())); } diff --git a/src/UI/JPEGExportSheet.h b/src/UI/JPEGExportSheet.h index ace72d3..f7762d6 100644 --- a/src/UI/JPEGExportSheet.h +++ b/src/UI/JPEGExportSheet.h @@ -1,7 +1,9 @@ #pragma once #include "Document/ColorPalette.h" #include "IO/ImageExporter.h" +#include <QAbstractScrollArea> #include <QWidget> +#include <array> #include <atomic> #include <functional> #include <memory> @@ -9,12 +11,58 @@ class QLabel; class QProgressBar; +class QShortcut; class QPushButton; class QSlider; class QTimer; class DialogColorSwatch; class EditorSession; +// Swift's JPEGPreview: the encoded JPEG, fitted or zoomed, dragged. +class JPEGPreview : public QAbstractScrollArea { + Q_OBJECT +public: + static constexpr QSize frame{560, 330}; + static constexpr std::array<double, 6> steps{0.25, 0.5, 1, 2, 4, 8}; + // The zoom at which the whole image fits `frame`. + static double fitZoom(int width, int height, QSizeF frame, double displayScale); + // The next step past `zoom`: 1 in, −1 out. + static std::optional<double> step(double zoom, int direction); + + // `pixels`: the export's size, which the image may undercut. + JPEGPreview(QSize pixels, std::function<void()> zoomChanged, QWidget *parent); + void setImage(const QImage &image, bool updating); + // None fits; 1 is a screen pixel a pixel. + std::optional<double> zoom() const { return m_zoom; } + void setZoom(std::optional<double> zoom); + double shownZoom() const; + void zoomBy(int direction); + +protected: + void paintEvent(QPaintEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void mouseDoubleClickEvent(QMouseEvent *event) override; + +private: + QSizeF shownSize(double zoom) const; + void updateRanges(); + void showCursor(bool grabbing); + + const QSize m_pixels; + // Each change of zoom, for the sheet's buttons. + const std::function<void()> m_zoomChanged; + QImage m_image; + // The image shrunk to the size last drawn. + QImage m_reduced; + bool m_updating = false; + std::optional<double> m_zoom; + // A drag's press and the scroll it began from. + std::optional<std::pair<QPoint, QPoint>> m_drag; +}; + // Swift's JPEGExportSheet: quality, matte and the encoded preview. class JPEGExportSheet : public QWidget { Q_OBJECT @@ -25,12 +73,17 @@ public: JPEGExportSheet(ExportRaster raster, EditorSession &session, std::function<void(std::optional<QByteArray>)> finish, QWidget *parent = nullptr); ~JPEGExportSheet() override; +protected: + // The zoom glyphs follow the theme. + void changeEvent(QEvent *event) override; + private: - class Preview; void request(); void encode(); void setMatte(const PaletteColor &matte); void synchronize(); + // The View menu's zoom keys, as remapped, zoom the preview. + void bindZoomKeys(); const ExportRaster m_raster; EditorSession &m_session; @@ -42,7 +95,7 @@ private: // The newest request's flag, Swift's task cancellation. std::shared_ptr<std::atomic_bool> m_cancelled; QTimer *const m_wait; - Preview *const m_preview; + JPEGPreview *const m_preview; QProgressBar *const m_spinner; QSlider *const m_quality; QLabel *const m_percent; @@ -51,4 +104,9 @@ private: QLabel *const m_bytes; QLabel *const m_note; QPushButton *const m_export; + QPushButton *const m_fit; + QPushButton *const m_zoomIn; + QPushButton *const m_zoomOut; + // Fit, Actual Pixels, Zoom In and Zoom Out. + std::array<QShortcut *, 4> m_zoomKeys{}; }; diff --git a/src/UI/LayerIcons.cpp b/src/UI/LayerIcons.cpp index a38b4f6..0c2ae56 100644 --- a/src/UI/LayerIcons.cpp +++ b/src/UI/LayerIcons.cpp @@ -241,6 +241,16 @@ void wind(QPainter &painter) painter.drawPath(path); } +// The lens toward the top left, its handle down right. +void magnifyingGlass(QPainter &painter, bool plus) +{ + painter.drawEllipse(QPointF(7.5, 7.5), 5.5, 5.5); + painter.drawLine(QPointF(11.4, 11.4), QPointF(16, 16)); + painter.drawLine(QPointF(5, 7.5), QPointF(10, 7.5)); + if (plus) + painter.drawLine(QPointF(7.5, 5), QPointF(7.5, 10)); +} + // Swift's circle.dotted: twelve dots round a ring. void dottedCircle(QPainter &painter) { @@ -301,6 +311,8 @@ void LayerIcons::paint(QPainter &painter, LayerIcon icon, QPointF origin, double case LayerIcon::drop: drop(painter); break; case LayerIcon::wind: wind(painter); break; case LayerIcon::dottedCircle: dottedCircle(painter); break; + case LayerIcon::plusMagnifyingGlass: magnifyingGlass(painter, true); break; + case LayerIcon::minusMagnifyingGlass: magnifyingGlass(painter, false); break; } painter.restore(); } diff --git a/src/UI/LayerIcons.h b/src/UI/LayerIcons.h index 49ed314..267ca7a 100644 --- a/src/UI/LayerIcons.h +++ b/src/UI/LayerIcons.h @@ -8,7 +8,7 @@ enum class LayerIcon { eye, eyeSlash, chevronRight, chevronDown, folder, link, newLayer, newFolder, addMask, trash, layers, text, halfFilledCircle, sliders, curvePath, plusMinusCircle, paintPalette, circleGrid, sparkles, rightHalfCircle, hatchedCircle, axes, - scope, lineDiagonal, drop, wind, dottedCircle + scope, lineDiagonal, drop, wind, dottedCircle, plusMagnifyingGlass, minusMagnifyingGlass }; namespace LayerIcons { diff --git a/tests/HighDpiCanvasTests.cpp b/tests/HighDpiCanvasTests.cpp index 579eb6e..09f0927 100644 --- a/tests/HighDpiCanvasTests.cpp +++ b/tests/HighDpiCanvasTests.cpp @@ -2,7 +2,9 @@ #include "Rendering/AdjustmentSurface.h" #include "Rendering/EditorCanvas.h" #include "RenderFixtures.h" +#include "UI/JPEGExportSheet.h" #include "UI/LevelsSheet.h" +#include <QScrollBar> #include <QtTest> // Widgets at device pixel ratio 2: two coordinate systems. @@ -41,6 +43,7 @@ private slots: void theSurfaceCoversAnOffsetWidgetInDevicePixels(); void theCanvasDrawsDevicePixels(); void levelsEyedroppersDrawDevicePixels(); + void theJPEGPreviewCountsDevicePixels(); }; void HighDpiCanvasTests::theScreenScalesByTwo() @@ -168,6 +171,28 @@ void HighDpiCanvasTests::levelsEyedroppersDrawDevicePixels() } } +void HighDpiCanvasTests::theJPEGPreviewCountsDevicePixels() +{ + QImage image(1200, 800, QImage::Format_RGBA8888_Premultiplied); + for (int y = 0; y < 800; ++y) + for (int x = 0; x < 1200; ++x) + image.setPixelColor(x, y, QColor((x * 7 + y) % 256, (x + y * 3) % 256, (x * y) % 256)); + JPEGPreview preview(QSize(1200, 800), [] {}, nullptr); + preview.setImage(image, false); + preview.show(); + QVERIFY(QTest::qWaitForWindowExposed(&preview)); + // 600 by 400 points: Fit 0.825, averaged per device pixel. + QCOMPARE(preview.shownZoom(), 0.825); + QImage fitted = preview.viewport()->grab().toImage().convertToFormat(QImage::Format_RGB32); + fitted.setDevicePixelRatio(1); + QCOMPARE(fitted.size(), QSize(1120, 660)); + QCOMPARE(fitted.copy(65, 0, 990, 660), image.scaled(990, 660, Qt::IgnoreAspectRatio, Qt::SmoothTransformation).convertToFormat(QImage::Format_RGB32)); + // At 100% an image pixel is a device pixel. + preview.setZoom(1); + QCoreApplication::processEvents(); + QCOMPARE(preview.horizontalScrollBar()->maximum(), int(std::ceil(600 - preview.viewport()->width()))); +} + int main(int argc, char **argv) { // Every widget on this screen scales by two. diff --git a/tests/JPEGExportSheetTests.cpp b/tests/JPEGExportSheetTests.cpp index 5078983..30922fe 100644 --- a/tests/JPEGExportSheetTests.cpp +++ b/tests/JPEGExportSheetTests.cpp @@ -100,7 +100,7 @@ void JPEGExportSheetTests::theSheetShowsTheRasterAndWaitsForItsPreview() QCOMPARE(wait.interval(), 200); QCOMPARE(wait.timerType(), Qt::PreciseTimer); QVERIFY(!shown.ready()); - QCOMPARE(shown.note(), QString("Updating preview…")); + QCOMPARE(shown.note(), QString("Updating…")); QVERIFY(shown.find<QProgressBar>("jpegSpinner").isVisible()); QVERIFY(!shown.find<QLabel>("jpegBytes").isVisible() && !shown.find<QLabel>("jpegError").isVisible()); // The material plate behind the spinner, over the dark gray. @@ -111,8 +111,7 @@ void JPEGExportSheetTests::theSheetShowsTheRasterAndWaitsForItsPreview() QTRY_VERIFY(shown.ready()); const JPEGResult expected = ImageExporter::jpeg(halfRed(1200, 800), {}); QCOMPARE(shown.find<QLabel>("jpegBytes").text(), ByteCounts::file(expected.data.size())); - QVERIFY(shown.find<QLabel>("jpegBytes").isVisible()); - QCOMPARE(shown.note(), QString("· encoded preview, fitted to window")); + QVERIFY(shown.find<QLabel>("jpegBytes").isVisible() && !shown.find<QLabel>("jpegNote").isVisible()); QVERIFY(!shown.find<QProgressBar>("jpegSpinner").isVisible()); // Fitted to 495 × 330 and centred: white, then red. QCOMPARE(shown.previewAt({2, 2}), QColor(31, 31, 31)); @@ -131,7 +130,7 @@ void JPEGExportSheetTests::aChangeSupersedesThePreview() quality.setValue(10); QCOMPARE(shown.find<QLabel>("jpegPercent").text(), QString("10%")); QVERIFY(!shown.ready()); - QCOMPARE(shown.note(), QString("Updating preview…")); + QCOMPARE(shown.note(), QString("Updating…")); QVERIFY(shown.find<QTimer>("jpegWait").isActive()); QVERIFY(shown.find<QProgressBar>("jpegSpinner").isVisible()); quality.setValue(100); @@ -171,7 +170,7 @@ void JPEGExportSheetTests::theMatteComesFromTheAppsPicker() QTRY_VERIFY(shown.ready()); auto &matte = shown.find<DialogColorSwatch>("jpegMatte"); QVERIFY(matte.accessibleName() == QString("JPEG Background") && matte.toolTip() == QString("Color that fills transparent areas")); - QCOMPARE(qobject_cast<QHBoxLayout *>(shown.sheet.layout()->itemAt(3)->layout())->spacing(), 8); + QCOMPARE(qobject_cast<QHBoxLayout *>(shown.sheet.layout()->itemAt(2)->layout())->spacing(), 8); matte.click(); QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (JPEG Background)")); QVERIFY(shown.session.colorPicker().value().original == PaletteColor::white()); @@ -266,7 +265,7 @@ void JPEGExportSheetTests::anEncodingThatFailsIsShown() // A change clears the error while it waits. shown.find<QSlider>("jpegQuality").setValue(50); QVERIFY(!error.isVisible()); - QCOMPARE(shown.note(), QString("Updating preview…")); + QCOMPARE(shown.note(), QString("Updating…")); QTRY_VERIFY(error.isVisible()); } diff --git a/tests/JPEGExportTests.cpp b/tests/JPEGExportTests.cpp index 66e164b..0c0f82f 100644 --- a/tests/JPEGExportTests.cpp +++ b/tests/JPEGExportTests.cpp @@ -14,7 +14,7 @@ private slots: void transparencyUsesChosenMatteAndProducesOpaqueSRGB_data(); void transparencyUsesChosenMatteAndProducesOpaqueSRGB(); void qualityChangesBytesAndDecodedPixels(); - void thePreviewIsDecodedFromTheFileAndKeptSmall(); + void thePreviewIsDecodedFromTheFileUpToItsLimit(); void pixelsLieOverTheMatteOnce(); void whatCannotBeEncodedDecodedOrAllocatedThrows(); void cancellationStopsAtSwiftsThreePoints(); @@ -88,22 +88,25 @@ void JPEGExportTests::qualityChangesBytesAndDecodedPixels() QCOMPARE(written.readAll(), high.data); } -void JPEGExportTests::thePreviewIsDecodedFromTheFileAndKeptSmall() +void JPEGExportTests::thePreviewIsDecodedFromTheFileUpToItsLimit() { const JPEGResult small = ImageExporter::jpeg({noise(64, 48, 1)}, {.quality = 0.1}); QCOMPARE(small.preview.size(), QSize(64, 48)); // Compression shows in the preview: it is not the source. QCOMPARE(small.preview.convertToFormat(QImage::Format_RGB888), QImage::fromData(small.data).convertToFormat(QImage::Format_RGB888)); QVERIFY(small.preview.convertToFormat(QImage::Format_RGB888) != noise(64, 48, 1).convertToFormat(QImage::Format_RGB888)); - const JPEGResult wide = ImageExporter::jpeg({solid(2400, 600, qRgba(10, 200, 30, 255))}, {}); - QCOMPARE(wide.preview.size(), QSize(1000, 250)); - QCOMPARE(QImage::fromData(wide.data).size(), QSize(2400, 600)); - const JPEGResult tall = ImageExporter::jpeg({solid(500, 2000, qRgba(10, 200, 30, 255))}, {}); - QCOMPARE(tall.preview.size(), QSize(250, 1000)); - QCOMPARE(ImageExporter::jpeg({solid(1000, 1000, qRgba(10, 200, 30, 255))}, {}).preview.size(), QSize(1000, 1000)); + // Full size for 100%, past Qt's 128 MB decode limit. + QCOMPARE(ImageExporter::jpeg({solid(6000, 6000, qRgba(10, 200, 30, 255))}, {}).preview.size(), QSize(6000, 6000)); + QCOMPARE(ImageExporter::jpeg({solid(2400, 600, qRgba(10, 200, 30, 255))}, {}).preview.size(), QSize(2400, 600)); + QCOMPARE(JPEGResult::previewLimit, 8192); + QCOMPARE(ImageExporter::jpeg({solid(8192, 4, qRgba(10, 200, 30, 255))}, {}).preview.size(), QSize(8192, 4)); + const JPEGResult wide = ImageExporter::jpeg({solid(9000, 900, qRgba(10, 200, 30, 255))}, {}); + QCOMPARE(wide.preview.size(), QSize(8192, 819)); + QCOMPARE(QImage::fromData(wide.data).size(), QSize(9000, 900)); + QCOMPARE(ImageExporter::jpeg({solid(900, 9000, qRgba(10, 200, 30, 255))}, {}).preview.size(), QSize(819, 8192)); // Thin images keep a pixel across. - QCOMPARE(ImageExporter::jpeg({solid(2400, 1, qRgba(10, 200, 30, 255))}, {}).preview.size(), QSize(1000, 1)); - QCOMPARE(ImageExporter::jpeg({solid(1, 2400, qRgba(10, 200, 30, 255))}, {}).preview.size(), QSize(1, 1000)); + QCOMPARE(ImageExporter::jpeg({solid(9000, 1, qRgba(10, 200, 30, 255))}, {}).preview.size(), QSize(8192, 1)); + QCOMPARE(ImageExporter::jpeg({solid(1, 9000, qRgba(10, 200, 30, 255))}, {}).preview.size(), QSize(1, 8192)); } void JPEGExportTests::pixelsLieOverTheMatteOnce() diff --git a/tests/JPEGPreviewTests.cpp b/tests/JPEGPreviewTests.cpp new file mode 100644 index 0000000..714c9bf --- /dev/null +++ b/tests/JPEGPreviewTests.cpp @@ -0,0 +1,304 @@ +#include "UI/JPEGExportSheet.h" +#include "Document/EditorSession.h" +#include "UI/KeyboardShortcuts.h" +#include <QHBoxLayout> +#include <QPushButton> +#include <QRandomGenerator> +#include <QScrollBar> +#include <QSettings> +#include <QStandardPaths> +#include <QVBoxLayout> +#include <QtTest> + +// Swift 1.3.5's JPEGPreview: fitted or zoomed, dragged, its keys. +class JPEGPreviewTests : public QObject { + Q_OBJECT +private slots: + void initTestCase() { QStandardPaths::setTestModeEnabled(true); } + void fitAndStepsFollowSwift(); + void itDrawsFittedAndBelowOneThroughAnAverage(); + void fromOneUpEachPixelShowsAsItIs(); + void theUpdatingPlateSitsInTheMiddle(); + void zoomKeepsTheMiddleAndDragsMoveIt(); + void theSheetsButtonsAndKeysZoomIt(); +}; + +namespace { +QImage noise(int width, int height, quint32 seed) +{ + QImage image(width, height, QImage::Format_RGBA8888_Premultiplied); + QRandomGenerator random(seed); + for (int y = 0; y < height; ++y) + for (int x = 0; x < width; ++x) + image.setPixelColor(x, y, QColor::fromRgb(random.bounded(256), random.bounded(256), random.bounded(256))); + return image; +} + +QImage drawn(JPEGPreview &preview) +{ + return preview.viewport()->grab().toImage().convertToFormat(QImage::Format_RGB32); +} + +// The part of the view at `rect` equals `reference`. +bool shows(JPEGPreview &preview, QRect rect, const QImage &reference) +{ + return drawn(preview).copy(rect) == reference.convertToFormat(QImage::Format_RGB32); +} + +// A move with `held` down. +void dragTo(QWidget *widget, QPoint at, Qt::MouseButtons held = Qt::LeftButton) +{ + QMouseEvent event(QEvent::MouseMove, at, widget->mapToGlobal(at), Qt::NoButton, held, Qt::NoModifier); + QApplication::sendEvent(widget, &event); +} + +// Counts a widget's repaints. +struct Paints : QObject { + int count = 0; + explicit Paints(QWidget *widget) { widget->installEventFilter(this); } + bool eventFilter(QObject *, QEvent *event) override + { + count += event->type() == QEvent::Paint; + return false; + } +}; + +struct Shown { + JPEGPreview preview; + explicit Shown(QSize pixels, std::function<void()> zoomed = [] {}) : preview(pixels, std::move(zoomed), nullptr) + { + preview.show(); + if (!QTest::qWaitForWindowExposed(&preview)) + throw std::runtime_error("the preview never showed"); + } + // The zoom, then the scroll bars it brings. + void zoom(std::optional<double> zoom) + { + preview.setZoom(zoom); + QCoreApplication::processEvents(); + } +}; +} + +void JPEGPreviewTests::fitAndStepsFollowSwift() +{ + QCOMPARE(JPEGPreview::frame, QSize(560, 330)); + QCOMPARE(JPEGPreview::fitZoom(1200, 800, QSizeF(560, 330), 1), 330.0 / 800); + QCOMPARE(JPEGPreview::fitZoom(1200, 800, QSizeF(560, 330), 2), 330.0 / 400); + QCOMPARE(JPEGPreview::fitZoom(100, 400, QSizeF(560, 330), 0.5), 330.0 / 400); + QCOMPARE(JPEGPreview::step(0.4125, 1), std::optional(0.5)); + QCOMPARE(JPEGPreview::step(0.4125, -1), std::optional(0.25)); + // A step a hair away counts as that step. + QCOMPARE(JPEGPreview::step(0.9995, 1), std::optional(2.0)); + QCOMPARE(JPEGPreview::step(1.0005, -1), std::optional(0.5)); + QCOMPARE(JPEGPreview::step(0.998, 1), std::optional(1.0)); + QCOMPARE(JPEGPreview::step(1.002, -1), std::optional(1.0)); + QCOMPARE(JPEGPreview::step(8, 1), std::nullopt); + QCOMPARE(JPEGPreview::step(0.25, -1), std::nullopt); + QCOMPARE(JPEGPreview::step(20, -1), std::optional(8.0)); +} + +void JPEGPreviewTests::itDrawsFittedAndBelowOneThroughAnAverage() +{ + Shown shown(QSize(1100, 660)); + JPEGPreview &preview = shown.preview; + QVERIFY(preview.size() == QSize(560, 330) && preview.viewport()->cursor().shape() == Qt::ArrowCursor); + QCOMPARE(preview.toolTip(), QString("Drag or scroll to move around; double-click switches between Fit and 100%")); + QVERIFY(preview.horizontalScrollBar()->maximum() == 0 && preview.verticalScrollBar()->maximum() == 0); + QVERIFY(!preview.horizontalScrollBar()->isVisible() && preview.horizontalScrollBar()->singleStep() == 20 && preview.verticalScrollBar()->singleStep() == 20); + // Nothing yet: the dark gray. + QCOMPARE(drawn(preview).pixelColor(280, 165), QColor(31, 31, 31)); + Paints paints(preview.viewport()); + const QImage first = noise(1100, 660, 1); + preview.setImage(first, false); + QTRY_VERIFY(paints.count > 0); + // Fitted, 550 by 330 in the middle, averaged down. + QVERIFY(shows(preview, QRect(5, 0, 550, 330), first.scaled(550, 330, Qt::IgnoreAspectRatio, Qt::SmoothTransformation))); + QCOMPARE(drawn(preview).pixelColor(4, 165), QColor(31, 31, 31)); + // A new image of the same size is averaged anew. + const QImage second = noise(1100, 660, 2); + preview.setImage(second, false); + QVERIFY(shows(preview, QRect(5, 0, 550, 330), second.scaled(550, 330, Qt::IgnoreAspectRatio, Qt::SmoothTransformation))); + // A quarter, then Fit again: each size averaged for itself. + QCoreApplication::processEvents(); + paints.count = 0; + shown.zoom(0.25); + QTRY_VERIFY(paints.count > 0); + QVERIFY(shows(preview, QRect(143, 83, 275, 165), second.scaled(275, 165, Qt::IgnoreAspectRatio, Qt::SmoothTransformation))); + QVERIFY(drawn(preview).pixelColor(141, 165) == QColor(31, 31, 31) && drawn(preview).pixelColor(280, 81) == QColor(31, 31, 31)); + QCOMPARE(preview.horizontalScrollBar()->maximum(), 0); + shown.zoom(std::nullopt); + QVERIFY(shows(preview, QRect(5, 0, 550, 330), second.scaled(550, 330, Qt::IgnoreAspectRatio, Qt::SmoothTransformation))); +} + +void JPEGPreviewTests::fromOneUpEachPixelShowsAsItIs() +{ + Shown shown(QSize(1100, 660)); + JPEGPreview &preview = shown.preview; + const QImage image = noise(1100, 660, 3); + preview.setImage(image, false); + shown.zoom(2); + // Bars come once laid out; ranges follow the view. + QScrollBar &across = *preview.horizontalScrollBar(), &down = *preview.verticalScrollBar(); + const QSize view = preview.viewport()->size(); + QVERIFY(across.isVisible() && down.isVisible() && view.width() < 560 && view.height() < 330); + QVERIFY(across.maximum() == 2200 - view.width() && down.maximum() == 1320 - view.height()); + QVERIFY(across.pageStep() == view.width() && down.pageStep() == view.height()); + // Twice, nearest: each JPEG pixel a 2 by 2 block. + across.setValue(100); + down.setValue(60); + QVERIFY(shows(preview, QRect(0, 0, 200, 100), image.copy(50, 30, 100, 50).scaled(200, 100))); + // A smaller decode still spans the export's pixels. + preview.setImage(image.scaled(550, 330), false); + shown.zoom(1); + QCOMPARE(down.maximum(), 660 - preview.viewport()->height()); + // A size between pixels reaches its last one. + Shown odd(QSize(1201, 1201)); + odd.preview.setImage(noise(1201, 1201, 4), false); + odd.zoom(0.5); + QCOMPARE(odd.preview.horizontalScrollBar()->maximum(), int(std::ceil(600.5 - odd.preview.viewport()->width()))); +} + +void JPEGPreviewTests::theUpdatingPlateSitsInTheMiddle() +{ + Shown shown(QSize(1100, 660)); + shown.preview.setImage(QImage(), true); + const QImage image = drawn(shown.preview); + const QColor window = shown.preview.palette().color(QPalette::Window); + const int plate = qRound(window.red() * 0.85 + 31 * 0.15); + // 64 square, no rim: its edges sit on whole pixels. + QVERIFY(std::abs(image.pixelColor(248, 165).red() - plate) <= 1 && std::abs(image.pixelColor(311, 165).red() - plate) <= 1); + QVERIFY(image.pixelColor(247, 165) == QColor(31, 31, 31) && image.pixelColor(312, 165) == QColor(31, 31, 31)); + QVERIFY(image.pixelColor(280, 132) == QColor(31, 31, 31) && std::abs(image.pixelColor(280, 133).red() - plate) <= 1); + // Its corners round by 8, antialiased. + QCOMPARE(image.pixelColor(248, 133), QColor(31, 31, 31)); + const int corner = image.pixelColor(250, 135).red(); + QVERIFY(corner > 40 && corner < plate - 10); +} + +void JPEGPreviewTests::zoomKeepsTheMiddleAndDragsMoveIt() +{ + int heard = 0; + Shown shown(QSize(1200, 800), [&heard] { ++heard; }); + JPEGPreview &preview = shown.preview; + preview.setImage(noise(1200, 800, 5), false); + // From Fit, the middle of the image. + Paints paints(preview.viewport()); + preview.setZoom(1); + QVERIFY(heard == 1 && paints.count == 0); + QTRY_VERIFY(paints.count > 0); + QScrollBar &across = *preview.horizontalScrollBar(), &down = *preview.verticalScrollBar(); + QVERIFY(across.value() == 320 && down.value() == 235); + QCoreApplication::processEvents(); + // Zooming keeps the point in the middle where it was. + const QSize view = preview.viewport()->size(); + across.setValue(0); + down.setValue(0); + shown.zoom(2); + QVERIFY(across.value() == int(std::lround(view.width() / 2.0)) && down.value() == int(std::lround(view.height() / 2.0))); + shown.zoom(2); + QCOMPARE(heard, 2); + // Smaller than the view, its middle is the image's middle. + shown.zoom(0.25); + preview.setZoom(0.5); + QVERIFY(across.value() == 20 && down.value() == 35); + // A half-pixel landing rounds, as Swift scrolls to it. + Shown odd(QSize(1201, 801)); + odd.preview.setZoom(1); + QVERIFY(odd.preview.horizontalScrollBar()->value() == 321 && odd.preview.verticalScrollBar()->value() == 236); + shown.zoom(2); + // A drag moves the image with the pointer. + across.setValue(500); + down.setValue(300); + QTest::mousePress(preview.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(100, 100)); + QCOMPARE(preview.viewport()->cursor().shape(), Qt::OpenHandCursor); + dragTo(preview.viewport(), QPoint(60, 80)); + QVERIFY(across.value() == 540 && down.value() == 320 && preview.viewport()->cursor().shape() == Qt::ClosedHandCursor); + QTest::mouseRelease(preview.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(60, 80)); + QCOMPARE(preview.viewport()->cursor().shape(), Qt::OpenHandCursor); + dragTo(preview.viewport(), QPoint(20, 20)); + QCOMPARE(across.value(), 540); + // Another button drags nothing; a new zoom ends a drag. + QTest::mousePress(preview.viewport(), Qt::RightButton, Qt::NoModifier, QPoint(100, 100)); + dragTo(preview.viewport(), QPoint(60, 80), Qt::RightButton); + QTest::mouseRelease(preview.viewport(), Qt::RightButton, Qt::NoModifier, QPoint(60, 80)); + QCOMPARE(across.value(), 540); + QTest::mousePress(preview.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(100, 100)); + shown.zoom(4); + const int zoomed = across.value(); + dragTo(preview.viewport(), QPoint(60, 80)); + QVERIFY(across.value() == zoomed && preview.viewport()->cursor().shape() == Qt::OpenHandCursor); + QTest::mouseRelease(preview.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(60, 80)); + // Double-click: 100% to Fit and back; Fit drags nothing. + QTest::mouseDClick(preview.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(50, 50)); + QVERIFY(!preview.zoom() && preview.viewport()->cursor().shape() == Qt::ArrowCursor); + QTest::mousePress(preview.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(100, 100)); + dragTo(preview.viewport(), QPoint(60, 80)); + QCOMPARE(preview.viewport()->cursor().shape(), Qt::ArrowCursor); + QTest::mouseRelease(preview.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(60, 80)); + QTest::mouseDClick(preview.viewport(), Qt::RightButton, Qt::NoModifier, QPoint(50, 50)); + QVERIFY(!preview.zoom()); + QTest::mouseDClick(preview.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(50, 50)); + QCOMPARE(preview.zoom(), std::optional(1.0)); +} + +void JPEGPreviewTests::theSheetsButtonsAndKeysZoomIt() +{ + QSettings().remove(JPEGExportSheet::qualityKey); + EditorSession session; + QImage red(1200, 800, QImage::Format_RGBA8888_Premultiplied); + red.fill(Qt::red); + JPEGExportSheet sheet({red, 72}, session, [](std::optional<QByteArray>) {}); + sheet.show(); + QVERIFY(QTest::qWaitForWindowActive(&sheet)); + auto &preview = *sheet.findChild<JPEGPreview *>(); + auto &fit = *sheet.findChild<QPushButton *>(QStringLiteral("jpegFit")); + auto &in = *sheet.findChild<QPushButton *>(QStringLiteral("jpegZoomIn")); + auto &out = *sheet.findChild<QPushButton *>(QStringLiteral("jpegZoomOut")); + // Title row 8 above the preview; bottom row 12 apart. + auto *top = qobject_cast<QVBoxLayout *>(sheet.layout()->itemAt(0)->layout()); + auto *title = qobject_cast<QHBoxLayout *>(top->itemAt(0)->layout()); + QVERIFY(top->spacing() == 8 && title->spacing() == 8 && title->itemAt(2)->widget() == &fit && title->itemAt(4)->widget() == &out); + auto *bottom = qobject_cast<QHBoxLayout *>(sheet.layout()->itemAt(3)->layout()); + QVERIFY(bottom->spacing() == 12 && bottom->itemAt(0)->widget()->objectName() == QString("jpegSize") && bottom->itemAt(1)->spacerItem()); + QVERIFY(!fit.isEnabled() && in.isEnabled() && out.isEnabled() && in.accessibleName() == QString("Zoom in")); + QCOMPARE(fit.toolTip(), QString("Show the whole image (Ctrl+0)")); + QCOMPARE(in.toolTip(), QString("Zoom in (Ctrl+=), now 41%. At 100% each pixel of the JPEG is one pixel of the screen, as on the canvas")); + QCOMPARE(out.toolTip(), QString("Zoom out (Ctrl+-), now 41%")); + // The magnifiers in the button ink, redrawn with the theme. + const QImage before = in.icon().pixmap(16).toImage(); + QPalette palette = sheet.palette(); + palette.setColor(QPalette::ButtonText, Qt::red); + sheet.setPalette(palette); + QVERIFY(in.icon().pixmap(16).toImage() != before && out.icon().pixmap(16).toImage() != in.icon().pixmap(16).toImage()); + out.click(); + QVERIFY(preview.zoom() == std::optional(0.25) && !out.isEnabled() && fit.isEnabled()); + QCOMPARE(in.toolTip(), QString("Zoom in (Ctrl+=), now 25%. At 100% each pixel of the JPEG is one pixel of the screen, as on the canvas")); + in.click(); + QCOMPARE(preview.zoom(), std::optional(0.5)); + fit.click(); + QVERIFY(!preview.zoom()); + // The View menu's keys, blocked by the sheet, zoom it. + QTest::keyClick(&sheet, Qt::Key_1, Qt::ControlModifier); + QCOMPARE(preview.zoom(), std::optional(1.0)); + for (int step = 0; step < 4; ++step) + QTest::keyClick(&sheet, Qt::Key_Equal, Qt::ControlModifier); + QVERIFY(preview.zoom() == std::optional(8.0) && !in.isEnabled()); + QTest::keyClick(&sheet, Qt::Key_Minus, Qt::ControlModifier); + QCOMPARE(preview.zoom(), std::optional(4.0)); + QTest::keyClick(&sheet, Qt::Key_0, Qt::ControlModifier); + QVERIFY(!preview.zoom()); + // Remapped, the new key zooms and the tooltip says it. + QSettings().remove(QLatin1String(ShortcutSettings::storageKey)); + QVERIFY(ShortcutSettings::shared().save({{QStringLiteral("Menus:Zoom In"), ShortcutChord(QStringLiteral("k"), 1)}})); + QVERIFY(in.toolTip().startsWith(QStringLiteral("Zoom in (Ctrl+K), now 41%"))); + QTest::keyClick(&sheet, Qt::Key_Equal, Qt::ControlModifier); + QVERIFY(!preview.zoom()); + QTest::keyClick(&sheet, Qt::Key_K, Qt::ControlModifier); + QCOMPARE(preview.zoom(), std::optional(0.5)); + QSettings().remove(QLatin1String(ShortcutSettings::storageKey)); + ShortcutSettings::shared().reload(); +} + +QTEST_MAIN(JPEGPreviewTests) +#include "JPEGPreviewTests.moc" diff --git a/tests/ProjectExportTests.cpp b/tests/ProjectExportTests.cpp index 29d77c0..7335c33 100644 --- a/tests/ProjectExportTests.cpp +++ b/tests/ProjectExportTests.cpp @@ -252,7 +252,17 @@ void ProjectExportTests::theFileMenuOffersBothExports() bar.window.show(); bar.action("exportJPEG").trigger(); QTRY_VERIFY(bar.window.findChild<JPEGExportSheet *>()); - QTest::keyClick(bar.window.findChild<JPEGExportSheet *>(), Qt::Key_Escape); + // The menus' zoom keys reach the preview, not the canvas. + JPEGExportSheet &sheet = *bar.window.findChild<JPEGExportSheet *>(); + QTRY_COMPARE(QApplication::activeWindow(), sheet.window()); + const double zoom = bar.session().viewport.zoom(); + QTest::keyClick(&sheet, Qt::Key_1, Qt::ControlModifier); + QCOMPARE(sheet.findChild<JPEGPreview *>()->zoom(), std::optional(1.0)); + QCOMPARE(bar.session().viewport.zoom(), zoom); + // In the dialog only Export takes Return. + for (const char *name : {"jpegFit", "jpegZoomIn", "jpegZoomOut"}) + QVERIFY(!sheet.findChild<QPushButton *>(QString::fromLatin1(name))->autoDefault()); + QTest::keyClick(&sheet, Qt::Key_Escape); QTRY_VERIFY(!bar.session().isProjectBusy()); QCOMPARE(desk.seen.size(), 1); } diff --git a/tests/RemappedKeysTests.cpp b/tests/RemappedKeysTests.cpp index b11edf0..aa82587 100644 --- a/tests/RemappedKeysTests.cpp +++ b/tests/RemappedKeysTests.cpp @@ -229,7 +229,9 @@ void RemappedKeysTests::everySheetBindsSwiftsKeys() QCOMPARE(keys(CanvasSizeSheet(canvas, session, [](std::optional<CanvasSizeOptions>) {})), both); QCOMPARE(keys(ImageSizeSheet(canvas, [](std::optional<ImageSizeOptions>) {})), both); QCOMPARE(keys(TrimSheet([](std::optional<TrimOptions>) {})), both); - QCOMPARE(keys(JPEGExportSheet(ExportRaster{red, 72}, session, [](std::optional<QByteArray>) {})), both); + // Export JPEG also takes the View menu's four zoom keys. + const QSet<QString> zooms{"Ctrl+K", "Ctrl+Q", "Ctrl+0", "Ctrl+1", "Ctrl+=", "Ctrl+-"}; + QCOMPARE(keys(JPEGExportSheet(ExportRaster{red, 72}, session, [](std::optional<QByteArray>) {})), zooms); QCOMPARE(keys(ColorPickerSheet(session, [](bool) {})), both); QCOMPARE(keys(NewCanvasSheet(session, [](int, int) {}, [] {})), QSet<QString>{"Ctrl+K"}); QCOMPARE(keys(TransformInspector(session)), both); diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 1c28efd..3b25963 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -179,6 +179,7 @@ compositor_test(ColorRangeTests) compositor_test(ColorRangeCanvasTests) compositor_test(ColorRangeSheetTests) compositor_test(DialogColorPickerTests) +compositor_test(JPEGPreviewTests) # It calls the colour-range kernel itself. target_include_directories(ColorRangeTests PRIVATE ${OMAPHOTO_KERNELS}) compositor_test(DitherTests)