From c04d1fb6e490a24bb15f64a15cf3fdff99b6e52f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 11 Oct 2026 02:38:04 -0400 Subject: [PATCH] =?UTF-8?q?14.2:=20RAW=20import=20rounds=20to=208=20bits?= =?UTF-8?q?=20with=20fine=20noise,=20in=20the=20kernel;=20the=20develop=20?= =?UTF-8?q?sheet=20shows=20Importing=E2=80=A6=20until=20the=20layer=20land?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 3 +- kernels/DitherPixels.c | 35 ++++++ kernels/DitherPixels.h | 4 + src/ContentView+Panels.cpp | 4 +- src/Document/EditorSession+Imports.cpp | 21 +++- src/Document/EditorSession+Imports.h | 27 +++++ src/Document/EditorSession.h | 21 +--- src/IO/RawImporter.cpp | 18 +++- src/UI/RawDevelopSheet.cpp | 101 ++++++++++++++---- src/UI/RawDevelopSheet.h | 8 ++ tests/RawDevelopTests.cpp | 142 ++++++++++++++++++++++--- tests/RawDitherTests.cpp | 131 +++++++++++++++++++++++ tests/RawImporterTests.cpp | 17 ++- tests/Tests.cmake | 3 + 14 files changed, 470 insertions(+), 65 deletions(-) create mode 100644 src/Document/EditorSession+Imports.h create mode 100644 tests/RawDitherTests.cpp diff --git a/AGENTS.md b/AGENTS.md index 93544bf..daafdbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ Platform adaptations, each forced by a missing Qt analog: - QPainter places an unscaled, unturned image on whole device pixels whatever the hints say; CoreGraphics would blur it by the fraction. Layers the app makes sit on whole pixels, Shape lines aside (8.8), and any scale or turn takes QPainter's filtered path, so tests of soft edges draw under a scale. - `ImageResizer` and `CanvasResizer` are namespaces. `Task.checkCancellation()` in the Swift resizer has no twin: no caller cancels a resize. Each turns a `std::bad_alloc` into `ExportError(render)` at its door (since 10.1 and 10.2), so the workers that run them catch it. A layer's new box is judged by `LayerTransform::isValid` before any number becomes an integer, where Swift converts first: `Int(Double)` traps on a wild value, C++ would be undefined. Records are rebuilt field by field as Swift rebuilds them, so a field Swift leaves out is dropped once that field exists (the image resizer passes on neither shape, effects nor text; the canvas resizer passes on shape and text, not effects). Options hold `qint64`, Swift's `Int`, like the manifest. `CanvasSizeDraft` keeps plain members where Swift has `let`, so the struct stays assignable. - `EditorSession` is a `QObject`. One signal, `changed()`, stands in for Swift's observation: every method that changes state emits it, a refused request stays silent, and widgets compare what they show with what the session holds, as `CanvasView.synchronizeDisplay` does. `beginEdit` emits as `endEdit` does, because an open transaction closes `canUndo` and `canRedo`. `fit` and `zoom` emit only when the view changed, so a zoom that is no number stays silent. The canvas edits `viewport` in place and then calls `notify()`. A Swift property with a `didSet` is a private member with a getter and a setter (`activeLayerID`, `isProjectBusy`, `showsNewDocument`, `showsImporter`, `isImporting`, `importError`, `renamingLayerID`); `document`, `tool`, `collapsedGroupIDs` and `selectedLayerIDs` are read through getters and change only through session methods; `history` and `viewport` are public members, as Swift's tests use them. `activeLayer()` returns a copy, as Swift's computed property does. `EditorSessionSignalTests` holds the signal's contract: every change is announced, a refusal is silent, and the last signal of a change sees the state the call leaves behind, read inside the slot. -- `EditorSession.swift` runs to 741 lines, so the twin splits: `EditorSession+Model.h/.cpp` hold `ImageLayer`, `CanvasDocument` and `NavigationTool` (their own header also breaks the include cycle with `DocumentHistory`, which a member by value needs complete), `EditorSession.h/.cpp` the core, `EditorSession+Layers.cpp` the layer operations, `EditorSession+Transform.cpp` the transform edits, `EditorSession+Imports.cpp` the import queue and the busy gates; `EditorSession+Projects.cpp` twins the Swift file of that name. A session function that Swift defines in an extension lives in the twin of that Swift file, declared in its `Name+Session.h`: the `LayerGroups` extension (folders, the multi-selection, grouping, placing, `layerRows`) in `LayerGroups.cpp`, `finishDeletingLayer(s)` in `LiveLayerMask.cpp`. +- `EditorSession.swift` runs to 741 lines, so the twin splits: `EditorSession+Model.h/.cpp` hold `ImageLayer`, `CanvasDocument` and `NavigationTool` (their own header also breaks the include cycle with `DocumentHistory`, which a member by value needs complete), `EditorSession.h/.cpp` the core, `EditorSession+Layers.cpp` the layer operations, `EditorSession+Transform.cpp` the transform edits, `EditorSession+Imports.cpp` the import queue and the busy gates (its queue's declarations in `EditorSession+Imports.h` since 14.2, keeping the header under 500 lines); `EditorSession+Projects.cpp` twins the Swift file of that name. A session function that Swift defines in an extension lives in the twin of that Swift file, declared in its `Name+Session.h`: the `LayerGroups` extension (folders, the multi-selection, grouping, placing, `layerRows`) in `LayerGroups.cpp`, `finishDeletingLayer(s)` in `LiveLayerMask.cpp`. - `groupSelectedLayers` follows Swift's steps (what stays keeps its place, the folder sits at the topmost selected branch, the wrapped layers go last in hierarchy order) with two terms dropped because they are dead in the twin: the `nil` that ends each ancestor chain (the twin's shared parent starts as the root) and the clamp of the insertion index (it counts layers that stay, so it cannot pass their number). `placeLayer` drops Swift's `target != id` for the same reason: the target is sought after the layer is taken out, so a layer is never found above itself. `canPlaceLayer` is tested by itself as well as through `placeLayer`, whose validation would hide its terms; the panel asks it before a drop. `selectLayers` and `extendSelection` fall back on any selected layer, as Swift's `Set.first` does: tests never depend on which. A session's document always passes `LayerHierarchy::validate` (every mutator checks before it commits), so every `parentID` names a folder of the document; `groupSelectedLayers` reads parents through `std::map::at`, which throws if that ever breaks. - Transform edits: `TransformEdit` holds what exists so far (`layerID`, `draft`, `group`); `persistent`, `corners`, `mask` and `floating` arrive with the items that read them (`TASKS.md`, "Session hooks owed"). A draft changes only through `previewTransform`, which refuses an invalid value, and begins as a valid transform or box, so it is valid by construction. `commitTransform` therefore gives each layer what `pendingTransform` gives it, one definition for preview and commit, inside one transaction, and skips a layer whose own result is invalid (a box squeezed until a member falls under a pixel); Swift's `edit.draft.isValid` and `firstIndex` guards are left out, since nothing can reach them. An edit that moves nothing records nothing, as in Swift: the history drops a transaction that changed nothing. `canTransform` and `groupTransformMembers` drop Swift's `isGroup` terms (a folder never holds pixels, `LayerHierarchy::validate` sees to it, so `asset` says the same), and `beginTransform` drops `transformEdit == nil` (`canTransform` reads it through `canEditLayers`). `groupTransformBox` keeps `max(1, …)`: rounding leaves a one-pixel layer at origin 1.3 a box under one pixel, and a test holds that. `DistortWarp::corners` came forward from 6.2 for the group box. - Swift's `async` session calls (`importImages`, `waitForProjectAccess`, `waitForFileRequest`) take a completion callback in the twin. A waiter runs at once when the session allows it; otherwise it is kept, and the setter that frees it posts each waiter to the event loop (`Qt::QueuedConnection`), where it looks again when its turn comes, as a Swift continuation re-enters its `while` loop: no waiter runs inside the setter, and the first may take the project before the second looks. `importImages` takes `QList` (a URL that is no local file is `unsupported`, Swift's `isFileURL` guard), decodes one file at a time through `QtConcurrent::run` and a `QFutureWatcher` (the worker catches `ImageImportError` and hands back the asset or the failure text; anything else is a bug and is left to crash), inserts on the UI thread, and wraps each request in one `Import Images` transaction. A request's callback is posted to the event loop too, so the caller goes on as a resumed Swift task does: after the transaction ended and, for the last request, after `isImporting` fell and the failures were published; for an earlier request while the next one decodes. Only a project that stops being busy frees project waiters: saying busy again leaves them in line. `showsBusy` is a single-shot `QTimer` of `busyIndicatorDelay` (250 ms), a `Qt::PreciseTimer` because a coarse one fires up to a twentieth early, and a child of the session so a test can read its remaining time; tests of time assert floors and states, never ceilings; `createDocument` clears its two flags and then resumes waiting file requests once, where Swift's two `didSet`s each try. The session therefore needs an event loop: every test that makes one uses `QTEST_GUILESS_MAIN`. @@ -182,6 +182,7 @@ Platform adaptations, each forced by a missing Qt analog: - Held keys, field values and resize snapping in the Move tool (13.13: `bca8f13`, `03d692c`, `686d8c7`, `c459f88`): `UI/HeldModifiers` twins Swift's class, a `QObject` singleton (`shared()`) with one signal, `changed()`. An application event filter reads key presses and releases and left-button presses and releases, Swift's `flagsChanged`, `keyDown`, `leftMouseDown` and `leftMouseUp`; a modifier key's own event sets or clears its bit, since Qt's event may not count it yet. Ctrl, Shift, Alt and Meta count (Swift's Command, Shift, Option, Control). Typing counts as none: Swift's `NSText` first responder is a focus widget with `Qt::WA_InputMethodEnabled`, so a `QLineEdit` and the canvas holding open text alike. `applicationStateChanged` stands for the two activation notifications (inactive clears; active reads `queryKeyboardModifiers`), and a `QMenu`'s `Hide` for `NSMenu.didEndTracking`. `TransformInspector` shows Auto Select as `transformAutoSelect != Ctrl` and the lock as `locksTransformRatio != Shift`, and a click while held writes the other way, as Swift's bindings; it synchronizes on `HeldModifiers::changed` too. Tooltips carry Swift's new words, Ctrl for Command. `transformPressLayer` follows Swift's: `autoSelect = transformAutoSelect != Ctrl`; Ctrl picks the layer under the pointer only with Auto Select off or with Shift (Ctrl+Shift adds either way). `TransformEdit.fromFields` sits last; `beginTransform(persistent, fromFields)` sets it, where Swift writes `transformEdit?.fromFields` in place (the twin's edit is read-only from outside). The bar's `change` opens a non-persistent `fromFields` edit; `finish` commits it, one step, when a field's scrub ends (`NumericScrub`'s `onEnd`) or the field loses focus. A focus borrowed by a menu, a popup or another window in front (`ActiveWindowFocusReason`, new: AppKit keeps the first responder when the app goes to the back) finishes nothing and keeps the typing. Escape in a field throws a `fromFields` edit away before handing the canvas the keys. The Swift path assumed: AppKit sends the Cancel button's Escape key equivalent (`configuredNativeShortcut(.escape)`) before the field editor, the only way `686d8c7`'s "Escape still throws the change away" can hold, since the field's own `onExitCommand` would end the focus and so apply the value. That path would also cancel a Ctrl+T edit from a field (and Return apply it); the twin keeps 6.1's reading there, a Ctrl+T edit waiting for the canvas's keys, a known difference held from 6.1. Flip H, Flip V and Sampling open a `fromFields` edit with no finish of their own, as Swift's do: it applies with the next field's finish, a canvas drag or anything that commits. `beginTransformDrag` commits a `fromFields` edit first, after `transformPressLayer` (so it picks nothing, as Swift's `picks`), so the two stay separate steps. Qt moves the focus before a press arrives, where SwiftUI's focus changes after `mouseDown`: a field left for a mouse press (`MouseFocusReason`) finishes from the event loop (a zero `QTimer`), so a typed value then a press over another layer drags the active layer under Auto Select, as in Swift (review 2's finding; `MoveBarPressTests`). `snappedResizePoint` lives in `Document/Crop.cpp` with Swift's: the edges each axis moves snap on their own to `transformSnapTargets` (the file's `nearest`, the first of ties), the nearer alone when proportional, the pointer nudged by one pixel's measured step; an upright layer only; guides set, or cleared when snapping is off, the drag is no resize or the layer turned. `dragTransform` snaps a resize unless Ctrl is held, proportional as `locksTransformRatio != Shift`. Dead or equivalent: `beginDistort` keeps `fromFields` where Swift's rebuild drops it, but a `fromFields` edit never reaches it (the canvas commits it before any handle drag). `snappedResizePoint`'s `before` read from `update(result)` or `update(point)` is equivalent (unlocked axes are independent, and proportional leaves one snap). The edge's tie (`<=`, the low edge) is reachable (the right handle half a pixel from its anchor, a one-pixel box) and held by `aTieTakesTheLowEdge`; the dragged handle's carried place by a drag past the anchor (`aLongDragSnapsTheEdgeItCarries`); Alt's centre resize by `altResizesFromTheCentreAndSnaps`. `QRectF::normalized` was dropped as dead (a resize keeps sizes positive and flips instead). Untested paths: the menu `Hide` and the activation's `queryKeyboardModifiers` (offscreen holds no real keyboard state). Tests: `MoveBarPressTests` (a typed value then a real press on another layer drags the active layer, two steps; Ctrl with a folder active and Auto Select on drags the folder past its box; a value left by a click in another field applies alone, one step), `MovePressFixtures.h` (the canvas fixture the three press files share), `TransformPressTests` (Swift's `commandFlipsAutoSelectWhileHeld` both ways and `commandShiftClickAddsALayerWithAutoSelectOn`, Ctrl outside the active layer, a field edit applied before a canvas drag; the old "resizing never snaps" line now snaps), `ResizeSnapCanvasTests` (resize snapping on the canvas with Ctrl mid-drag, snapping off, Shift's proportion), `ResizeSnapTests` (Swift's three, a turned layer swept, ties, nearer edges, free corners, a still edge keeping its guide, the dragged side alone, a press away from the handle, snapping off, a move), `TransformInspectorTests` (applied fields, Return, Escape, Ctrl+T's edit, another window in front, held keys flipping the box and the lock), `ScrubbableTransformTests` (scrubs apply on release, one step each). Judgments: Swift's `03d692c` drops a test the twin can keep (the box flipped while Ctrl is held is `heldKeysShowFlippedInTheBar`). - The Smear's 1.4.2–1.4.3 fixes (13.16, part one: `7164dd4`, `ad9ad7c`, `504129d`, and `095da2f`'s commit half): Liquify is Swift's new GPU stroke on the CPU, as the twin has no Metal: `WarpStroke` keeps the layer as it found it (`m_original`, a copy made in the constructor) and a source offset per pixel (`m_offsets`, two floats each, zero at first); each dab copies the offsets under it to `m_scratch`, reads them bilinearly from behind the brush's travel, less the travel times the weight, writes them back, and draws the pixel afresh from the untouched layer, bilinear, held to its edges, each byte rounded once: `warp_push` of `MetalWarp.swift`, float for float (bytes over 255, mixed, times 255). Swift allocates the two textures on the first push; the twin allocates them with the stroke, before any dab (nothing changes the copy between), so a failure is `ExportError(render)` where `beginWarp` catches it, where Swift's guard returns in silence; Smudge's carried colour, allocated at the first dab inside `beginWarp`, fails the same way, and a dab's scratch that finds no memory skips that dab with a warning on `omaphoto.rendering`, Swift's guard on its scratch texture (review 2: it ran outside any catch). The colour reads keep the shader's `size - 2` as they are: the offsets' read above needs two columns and rows of the dab's area (`ix`, `iy`), which a canvas a pixel wide or tall lacks, so no read leaves the image; the clamps first written here for such canvases were dead and are removed (review 2). Smudge is `ad9ad7c` in both of Swift's paths: dabs every 0.005 of the width (a pixel at least), each dab lays what it carries at weight × strength, and carries what it just left, nothing older. `BrushSettings.blurRadius` (5) is the Blur's own Radius: the sample's sigma follows the Radius (see part two for the merged formula). `BrushControls` shows Radius for the Blur mode alone, after Strength: a scrubbable label (a tenth a point, 0.5–50), a 100-point slider over 0.5–20 in hundredths (past 20 Qt rests it at its end, so Swift's `min(20, …)` is left out), and a 42-point `PickerField` with "px": a readable number applies on Return or leaving, clamped, a number that is not finite takes 5 (Swift's binding), other text goes back, arrows step one (Shift ten) from the session's value, clamped; it prints Swift's `fractionLength(0...1)`, never grouped. `BrushStroke::maxDiameter` is 2100, so `finishWarp`'s tip four wider than a 2000 brush is no longer refused; `finishWarp` appends a dab centre only every twentieth of the brush (the last always), as Swift's; the thinning is `WarpStroke::committedPoints`, so the test reads it directly (`aCommitKeepsATwentiethAndTheLastPoint`). Part two (`095da2f`'s Blur half, on 13.11's grid sampling, cc9bf95 merged into port-d): `BrushStroke::Clone` is 13.11's `(image, placed, inGrid)` plus `render`, a function that makes a part of `image` (Swift's `cloneRender`; Swift keeps it beside `clone`, the twin inside it, so one assignment sets both), and `clone` is private behind `clone()` and `setClone()`, which drops the pieces cut so far (Swift's two `didSet`s). `clonePixels(key, tile)` is Swift's `clonePiece`: the tile mapped into sample pixels through 13.11's chain (grid, `pixelToDocument` unless `inGrid`, then `placed` to the image's pixels, Swift's `scaleX` and `scaleY`), two pixels round, made whole outward (`toAlignedRect`, Swift's `integral`), held to the image; an empty part paints nothing on that tile (the fill is skipped, as Swift's `if let piece`); otherwise the piece is `render(part)` or a copy, cut once per tile key and kept in `m_clonePieces`, and the tile's sample is drawn from it through the same transform shifted by the piece's corner, so every path reads the pixels the whole image would give. A piece that cannot be allocated is `ExportError(render)`, thrown on the calling thread with `publish`'s other copies, where Swift's `cropping` returning nil paints nothing. `blurSample(stroke)` returns the sharp sample in the stroke's grid (13.11's `region`, `fit` and budget unchanged) with `render` blurring a part at `sigma × fit`, padded by the Gaussian's reach (`ceil(3σ)`) and cut back, clear past the sample for pixels (`QImage::copy` fills outside with zeros), its edges repeated for a mask (Swift's `clampedToExtent`), so a piece equals that part of the whole byte for byte (Swift allows one level). The Radius sigma is Swift's `min(min(50, max(0.5, blurRadius)) / perPixel, sidePixels / 2)`. Merge: port-c's direct `clone =` writes became `setClone` (`beginBrush`, `finishWarp`, `cloneSample`'s callers, the tests and `documentClone`), every `Clone` initialiser names `render` (GCC's `-Wmissing-field-initializers`), and port-c's Blur sample tests compare the sharp sample (`framed`) and its softening through `render` (`soft`), with each brush size's old sigma now set as the Radius. Equivalent mutants and untested paths for part two: a piece, a padded copy or a cut-back blur that cannot be allocated (`ExportError`) is untested, as no starvation binary reaches one copy here. Allocating the Liquify buffers for every mode is caught: Smudge then needs room it lacks in `WarpFailureTests`. Held since review 2: the Radius field's non-finite branch ("inf" takes 5); the colour read from the original rather than the context is held by the stripes (`liquifyDrawsFromTheUntouchedLayer`, mutant caught). Left out: Swift's `placed.width > 0` guard in `clonePiece` (every `placed` is a layer's rect, the canvas's or a padded region, never empty), and `gaussianBlur`'s edge flag inside `render` (the pad holds the edges, so the blur's own edge rule never reaches a part; it passes `false`). Equivalent under starvation: the untouched copy's null check (the offsets, twice its size, fail after it). Tests: `SmudgeLiquifyTests` (the oracle rewritten from Swift's new `smudge` and `warp_push`, every byte; pushes in from each edge over ramps; the session's smudge values pinned; a rim pixel the liquify moved), `WarpStrokeTests` (Swift's `liquifyStaysSharp` on Swift's photo, under one level where Swift measured 0.49; half-pixel pushes keep stripes crisp; Swift's `smudgeLeavesOneFadingTrail`; the commit's points a twentieth apart, exactly that apart kept, a pixel at least), `WarpFailureTests` (a Liquify dab without scratch memory skipped, Smudge without room to carry and Liquify without room for its offsets a render error), `LargeCanvasBrushTests` (Swift's `largestBrushCommits` for Liquify, Smudge and Blur on 5000 square at 2000; Swift's `blurPiecesMatchTheWhole` for pixels and a mask, exact; pieces cut once per tile, two pixels round, dropped by a new clone, none past the clone's end where the tile stays as it was; a thinned commit lands all a wide hard warp moved, straight and round a tight circle), `BlurBrushTests` (Swift's `radiusSetsHowFarItSoftens`, 6 and 42 columns, size alone; the clamps), `BlurToolTests` (port-c's sample tests on the Radius: the sharp sample and its softening, 0.5 and half the layer's side, 50 on a wide layer), `BrushControlsTests` (the Radius row, "inf" taking 5), `BrushTests` (2100 is taken, past it refused). Judgments: `7164dd4` drops Liquify from Swift's GPU-against-CPU test, and Swift's CPU fallback keeps resampling; the twin has one path, so it takes the GPU's (what every Mac with Metal runs), and its oracle replays the shader. - Inner effects keep a pixel's alpha (14.1, Swift 1.4.6: `e7e295e`, `9cb57db`, `f25effa`): `LayerEffectsKernel` follows v1.4.9's `effects_compose`: the colour overlay, then the inner glow, then the inner shadow move each pixel's colour toward theirs times its alpha (MSL's `mix`, `recolour`), the overlay by its opacity, the inner two by their strength (`effects_inside`: 1 − the softened or moved shape, no longer times the shape); then the pixels go over the shadow, the outer glow and an outside stroke, and an inside stroke goes on top. So a half-clear pixel takes a colour fully and stays half clear, and effects beneath are not tinted. Swift's CPU path (`overlaid`, `recolor`, the surface's inner glow) serves machines without Metal and stays dead here, as 9.6b's. The effects button's tooltip says "Add layer effect". Tests: `ColorOverlayTests` (Swift's three; the inner shadow and inner glow on half-clear pixels, a shadow beneath untinted), the shader replay in `LayerEffectsKernelTests`. +- RAW rounding and Importing… (14.2, Swift 1.4.6: `6345432`, `196342c`, `6e6ca38`, `0907f51`): `kernels/DitherPixels.c/.h` gain `196342c`'s `hash_noise` and `dither_quantize16`, its block body moved into `quantize_band` with a context struct, as 13.17's bands. `RawImporter::develop` works the look at 16 bits (`lround(65535 × …)`, opaque) into a buffer it allocates first (`ExportError(render)` without memory), then rounds it to 8 bits through `dither_quantize16`, where Swift renders Core Image at 16 bits and does the same; Swift's half-float working format names Core Image's own and has no twin. The session's `rawImporting` and `endRawDevelop` are Swift's: Import keeps the request and sets `rawImporting` while an answer waits, Cancel or an answer nobody waits for ends at once, and `finishDecode` ends it once the frame lands or fails (Swift's `defer`), after the layer is in. `RawDevelopSheet::setImporting` shows Swift's plate (a 32-point busy bar over "Importing…" in `PlaceholderText`, padded 16, on the window colour at 85% rounded 10), hides the preview's spinner, rests the sliders, Reset, Cancel and Import, and filters its dialog's Close and Escape while importing (Swift's `interactiveDismissDisabled`). Tests read RAW tones as a mean over the frame, rounded, since the noise moves single pixels a level; Qt's area average truncates, so a fitted preview reads a level low (`theQueueKeepsThePreviewsDecodeUntilReleased`). Holding the global pool freezes painting (Qt's raster engine fills on it), so the sheet's tests read its state before the event loop runs the develop, or use a frame large enough to take a while. Tests: `RawDitherTests` (Swift's two on the kernel; the triangular noise's three in four kept, alpha never passed, a develop dithered and repeatable), `RawDevelopTests` (importing, cancelling, failing, an answer nobody waits for, the plate, the locked dialog, the spinner and Reset while importing). - 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` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/kernels/DitherPixels.c b/kernels/DitherPixels.c index 7fe86c0..2890a83 100644 --- a/kernels/DitherPixels.c +++ b/kernels/DitherPixels.c @@ -416,3 +416,38 @@ void dither_glow(uint8_t *rgba, const uint8_t *glow, size_t width, size_t height glow_job glowing = { rgba, glow, width, stride, amount }; in_bands(height, glow_band, &glowing); } + +// A fixed pseudo-random value in [0, 1) for each pixel and draw. +static inline float hash_noise(size_t x, size_t y, uint32_t draw) { + uint32_t h = (uint32_t)x * 0x9E3779B1u ^ (uint32_t)y * 0x85EBCA77u ^ draw * 0xC2B2AE3Du; + h ^= h >> 15; h *= 0x2C1B3C6Du; h ^= h >> 12; h *= 0x297A2D39u; h ^= h >> 15; + return (float)(h >> 8) / (float)(1u << 24); +} + +typedef struct { const uint16_t *wide; uint8_t *rgba; size_t width, stride; } quantize_job; + +static void quantize_band(void *context, size_t first, size_t last) { + const quantize_job *job = context; + const uint16_t *wide = job->wide; uint8_t *rgba = job->rgba; size_t width = job->width, stride = job->stride; + for (size_t y = first; y < last; ++y) { + uint8_t *out = rgba + y * stride; + const uint16_t *in = wide + y * width * 4; + for (size_t x = 0; x < width; ++x) { + const uint16_t *p = in + x * 4; + long alpha = lroundf((float)p[3] * (255.0f / 65535.0f)); + for (uint32_t c = 0; c < 3; ++c) { + // Two uniform draws added: noise that's strongest at zero and gone past one step. + float value = (float)p[c] * (255.0f / 65535.0f) + hash_noise(x, y, c) + hash_noise(x, y, c + 3) - 1.0f; + long rounded = lroundf(value); + // Premultiplied: the noise mustn't lift a color past its own alpha. + out[x * 4 + c] = (uint8_t)(rounded < 0 ? 0 : rounded > alpha ? alpha : rounded); + } + out[x * 4 + 3] = (uint8_t)alpha; + } + } +} + +void dither_quantize16(const uint16_t *wide, uint8_t *rgba, size_t width, size_t height, size_t stride) { + quantize_job quantizing = { wide, rgba, width, stride }; + in_bands(height, quantize_band, &quantizing); +} diff --git a/kernels/DitherPixels.h b/kernels/DitherPixels.h index dc3769d..7c62b57 100644 --- a/kernels/DitherPixels.h +++ b/kernels/DitherPixels.h @@ -51,4 +51,8 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons void dither_dots(uint8_t *rgba, size_t width, size_t height, size_t stride, int block, const uint8_t *gap); // Adds `glow` (premultiplied RGBA, same layout) over the pixels at `amount`, never past their own alpha. void dither_glow(uint8_t *rgba, const uint8_t *glow, size_t width, size_t height, size_t stride, float amount); +// Rounds 16-bit premultiplied RGBA (`width` × `height`, tightly packed) to 8 bits into `rgba` (`stride` bytes per +// row), adding noise of about one 8-bit step first so smooth gradients don't come out in steps. The noise is fixed per +// pixel, so the same input always gives the same output. +void dither_quantize16(const uint16_t *wide, uint8_t *rgba, size_t width, size_t height, size_t stride); #endif diff --git a/src/ContentView+Panels.cpp b/src/ContentView+Panels.cpp index 93f07c6..eb94b3c 100644 --- a/src/ContentView+Panels.cpp +++ b/src/ContentView+Panels.cpp @@ -183,8 +183,10 @@ void ContentView::showRawDevelopSheet() shown->reject(); return; } - if (m_rawSheet) + if (m_rawSheet) { + m_rawSheet->findChild<RawDevelopSheet *>()->setImporting(m_session.rawImporting()); return; + } m_rawSheet = new QDialog(this); m_rawSheet->setObjectName(QStringLiteral("rawDevelopSheet")); m_rawSheet->setAttribute(Qt::WA_DeleteOnClose); diff --git a/src/Document/EditorSession+Imports.cpp b/src/Document/EditorSession+Imports.cpp index 4a9a489..8b8da27 100644 --- a/src/Document/EditorSession+Imports.cpp +++ b/src/Document/EditorSession+Imports.cpp @@ -208,13 +208,25 @@ void EditorSession::developRaw(const QString &path, std::function<void(std::opti void EditorSession::finishRawDevelop(std::optional<RawDevelopSettings> settings) { + // Importing, the sheet waits for the develop; Cancel closes it. + if (settings && m_rawAnswer) { + m_rawImporting = true; + notify(); + } else { + endRawDevelop(); + } + // The import resumes from the event loop, as a task. + if (const auto answer = std::exchange(m_rawAnswer, {})) + QMetaObject::invokeMethod(this, [answer, settings] { answer(settings); }, Qt::QueuedConnection); +} + +void EditorSession::endRawDevelop() +{ + m_rawImporting = false; m_rawDevelop = std::nullopt; notify(); // Frees the preview's decode on a worker, behind any preview. (void)QtConcurrent::run([] { RawImporter::Queue::shared().release(); }); - // The import resumes from the event loop, as a task. - if (const auto answer = std::exchange(m_rawAnswer, {})) - QMetaObject::invokeMethod(this, [answer, settings] { answer(settings); }, Qt::QueuedConnection); } void EditorSession::finishDecode() @@ -224,6 +236,9 @@ void EditorSession::finishDecode() insert(*decoded.asset, m_importPoint); else m_importFailures << decoded.failure; + // Swift's defer: the develop sheet goes once the frame lands. + if (m_rawImporting) + endRawDevelop(); m_importIndex += 1; decodeNext(); } diff --git a/src/Document/EditorSession+Imports.h b/src/Document/EditorSession+Imports.h new file mode 100644 index 0000000..949c752 --- /dev/null +++ b/src/Document/EditorSession+Imports.h @@ -0,0 +1,27 @@ +// The import queue: files, Photoshop and RAW. +public: + // Files decode off the UI thread; `done` follows this request. + void importImages(const QList<QUrl> &urls, std::optional<QPointF> point = std::nullopt, std::function<void()> done = {}); + // The sheet shows while a Photoshop file is read. + void beginPSDReading(const QString &title, const QString &confirmTitle); + void finishPSDReading(const std::vector<PSDConversion> &conversions, std::function<void(bool)> answer); + void endPSDReading(); + void finishConversion(bool confirmed); + void insertPhotoshop(const PSDImport &imported, const QString &named, std::optional<QPointF> centeredAt = std::nullopt); + // The RAW file the develop sheet shows, while it shows. + const std::optional<RawDevelopRequest> &rawDevelop() const { return m_rawDevelop; } + void decodeRaw(const QString &path, qint64 remaining); + void developRaw(const QString &path, std::function<void(std::optional<RawDevelopSettings>)> answer); + void finishRawDevelop(std::optional<RawDevelopSettings> settings); + // Import pressed: the sheet stays until the layer is in. + bool rawImporting() const { return m_rawImporting; } + // Closes the sheet once the RAW is in, or cancelled. + void endRawDevelop(); + +private: + void drainImports(); + void decodeNext(); + qint64 remainingPixels() const; + void decode(const QUrl &url, bool flattened); + void finishDecode(); + void finishPhotoshopRead(); diff --git a/src/Document/EditorSession.h b/src/Document/EditorSession.h index 1614070..590de7e 100644 --- a/src/Document/EditorSession.h +++ b/src/Document/EditorSession.h @@ -74,9 +74,6 @@ public: void setImportError(std::optional<QString> error); bool showsConversionSheet() const { return m_showsConversionSheet; } const std::optional<PSDConversionRequest> &conversionRequest() const { return m_conversionRequest; } - // The RAW file the develop sheet shows, while it shows. - const std::optional<RawDevelopRequest> &rawDevelop() const { return m_rawDevelop; } - void finishRawDevelop(std::optional<RawDevelopSettings> settings); std::optional<QUuid> renamingLayerID() const { return m_renamingLayerID; } void setRenamingLayerID(std::optional<QUuid> id); @@ -236,17 +233,7 @@ public: bool canMoveActiveLayer(int offset) const; void moveActiveLayer(int offset); - // Files decode off the UI thread; `done` follows this request. - void importImages(const QList<QUrl> &urls, std::optional<QPointF> point = std::nullopt, std::function<void()> done = {}); void insert(const ImportedImage &asset, std::optional<QPointF> centeredAt = std::nullopt); - // The sheet shows while a Photoshop file is read. - void beginPSDReading(const QString &title, const QString &confirmTitle); - void finishPSDReading(const std::vector<PSDConversion> &conversions, std::function<void(bool)> answer); - void endPSDReading(); - void finishConversion(bool confirmed); - void decodeRaw(const QString &path, qint64 remaining); - void developRaw(const QString &path, std::function<void(std::optional<RawDevelopSettings>)> answer); - void insertPhotoshop(const PSDImport &imported, const QString &named, std::optional<QPointF> centeredAt = std::nullopt); void createDocument(int width, int height, bool emptyLayer = false); void fit(); void zoom(double value, std::optional<QPointF> anchor = std::nullopt); @@ -280,6 +267,7 @@ public: void notify() { emit changed(); } // Swift's extensions, each declared in its twin's header. #include "Document/EditorSession+Projects.h" +#include "Document/EditorSession+Imports.h" #include "IO/ImageResizer+Session.h" #include "Document/LayerMask+Session.h" #include "Document/LayerAppearance+Session.h" @@ -324,12 +312,6 @@ private: void restore(const DocumentHistory::Snapshot &snapshot); void resumeFileRequests(); void updateBusyIndicator(); - void drainImports(); - void decodeNext(); - qint64 remainingPixels() const; - void decode(const QUrl &url, bool flattened); - void finishDecode(); - void finishPhotoshopRead(); void endDuplicateTransform(); // Clone Stamp is family 1, the Smear 2, others 0. static int tipFamily(NavigationTool tool); @@ -397,6 +379,7 @@ private: std::function<void(bool)> m_conversionAnswer; std::optional<RawDevelopRequest> m_rawDevelop; std::function<void(std::optional<RawDevelopSettings>)> m_rawAnswer; + bool m_rawImporting = false; // Cancel pressed while the file was still read. bool m_conversionCancelled = false; std::optional<QString> m_brushError; diff --git a/src/IO/RawImporter.cpp b/src/IO/RawImporter.cpp index 8f8aebd..3b6f7e5 100644 --- a/src/IO/RawImporter.cpp +++ b/src/IO/RawImporter.cpp @@ -11,6 +11,10 @@ #include <cmath> #include <numeric> #include <libraw.h> +#include <memory> +extern "C" { +#include "DitherPixels.h" +} namespace { [[noreturn]] void refuse(const QString &path, const char *step, int code) @@ -173,21 +177,25 @@ QImage render(LibRaw &raw, const QString &path, const RawDevelopSettings &settin image.setColorSpace(QColorSpace::SRgb); const double gain = std::pow(2.0, double(settings.exposure)) * smallest; const auto *samples = reinterpret_cast<const quint16 *>(made->data); - uchar *const bits = image.bits(); - const qsizetype stride = image.bytesPerLine(); + // The look at 16 bits, as Core Image renders it. + const std::unique_ptr<quint16[]> wide(new (std::nothrow) quint16[size_t(image.width()) * size_t(image.height()) * 4]); + if (!wide) + throw ExportError(ExportError::Kind::render); std::vector<int> rows(size_t(image.height())); std::iota(rows.begin(), rows.end(), 0); PoolMap::blocking(rows, [&](int y) { for (int x = 0; x < image.width(); ++x) { - uchar *out = bits + y * stride + x * 4; + quint16 *out = wide.get() + (qsizetype(y) * image.width() + x) * 4; for (int channel = 0; channel < 3; ++channel) { const double linear = std::min(samples[(qsizetype(y) * image.width() + x) * 3 + channel] / 65535.0 * gain, 1.0); const double flat = srgb(linear); - out[channel] = uchar(std::lround(255 * (flat + settings.boost * (bt709(linear) - flat)))); + out[channel] = quint16(std::lround(65535 * (flat + settings.boost * (bt709(linear) - flat)))); } - out[3] = 255; + out[3] = 65535; } }); + // To 8 bits with fine noise: gradients don't band. + dither_quantize16(wide.get(), image.bits(), size_t(image.width()), size_t(image.height()), size_t(image.bytesPerLine())); if (!limit || std::max(image.width(), image.height()) <= *limit) return image; const QImage fitted = image.scaled(QSize(int(*limit), int(*limit)), Qt::KeepAspectRatio, Qt::SmoothTransformation); diff --git a/src/UI/RawDevelopSheet.cpp b/src/UI/RawDevelopSheet.cpp index ac4f263..f82d2a7 100644 --- a/src/UI/RawDevelopSheet.cpp +++ b/src/UI/RawDevelopSheet.cpp @@ -5,6 +5,7 @@ #include <QFileInfo> #include <QFutureWatcher> #include <QHBoxLayout> +#include <QKeyEvent> #include <QLabel> #include <QPainter> #include <QPainterPath> @@ -22,6 +23,8 @@ class RawDevelopSheet::Preview : public QWidget { public: using QWidget::QWidget; QImage image; + // Swift's regular material, behind the Importing plate. + QWidget *plate = nullptr; protected: void paintEvent(QPaintEvent *) override @@ -31,20 +34,30 @@ protected: QPainterPath plate; plate.addRoundedRect(QRectF(rect()), 6, 6); painter.fillPath(plate, QColor::fromRgbF(0, 0, 0, 0.35f)); - if (image.isNull()) - return; - QRectF target(QPointF(), QSizeF(image.size()).scaled(QSizeF(size()), Qt::KeepAspectRatio)); - target.moveCenter(QRectF(rect()).center()); - QPainterPath clip; - clip.addRoundedRect(target, 6, 6); - painter.setClipPath(clip); - painter.drawImage(target, image); + if (!image.isNull()) { + QRectF target(QPointF(), QSizeF(image.size()).scaled(QSizeF(size()), Qt::KeepAspectRatio)); + target.moveCenter(QRectF(rect()).center()); + QPainterPath clip; + clip.addRoundedRect(target, 6, 6); + painter.save(); + painter.setClipPath(clip); + painter.drawImage(target, image); + painter.restore(); + } + if (this->plate && this->plate->isVisible()) { + QColor material = palette().color(QPalette::Window); + material.setAlphaF(0.85f); + QPainterPath behind; + behind.addRoundedRect(QRectF(this->plate->geometry()), 10, 10); + painter.fillPath(behind, material); + } } }; RawDevelopSheet::RawDevelopSheet(RawDevelopRequest request, std::function<void(std::optional<RawDevelopSettings>)> finish, QWidget *parent) : QWidget(parent), m_path(std::move(request.path)), m_finish(std::move(finish)), m_settings(request.settings), m_wait(new QTimer(this)), - m_preview(new Preview(this)), m_spinner(new QProgressBar(m_preview)), m_reset(new QPushButton(QStringLiteral("Reset"), this)) + m_preview(new Preview(this)), m_spinner(new QProgressBar(m_preview)), m_reset(new QPushButton(QStringLiteral("Reset"), this)), + m_cancel(new QPushButton(QStringLiteral("Cancel"), this)), m_import(new QPushButton(QStringLiteral("Import"), this)), m_importing(new QWidget(m_preview)) { auto *column = new QVBoxLayout(this); column->setContentsMargins(24, 24, 24, 24); @@ -63,6 +76,23 @@ RawDevelopSheet::RawDevelopSheet(RawDevelopRequest request, std::function<void(s m_spinner->setRange(0, 0); m_spinner->setTextVisible(false); m_spinner->setGeometry(272, 162, 16, 16); + // Swift's VStack: a spinner over "Importing…", padded 16. + m_importing->setObjectName(QStringLiteral("rawImporting")); + auto *importingColumn = new QVBoxLayout(m_importing); + importingColumn->setContentsMargins(16, 16, 16, 16); + importingColumn->setSpacing(8); + auto *importingSpinner = new QProgressBar(m_importing); + importingSpinner->setRange(0, 0); + importingSpinner->setTextVisible(false); + importingSpinner->setFixedSize(32, 32); + importingColumn->addWidget(importingSpinner, 0, Qt::AlignHCenter); + auto *importingWords = new QLabel(QStringLiteral("Importing…"), m_importing); + importingWords->setForegroundRole(QPalette::PlaceholderText); + importingColumn->addWidget(importingWords, 0, Qt::AlignHCenter); + m_importing->adjustSize(); + m_importing->move((m_preview->width() - m_importing->width()) / 2, (m_preview->height() - m_importing->height()) / 2); + m_importing->hide(); + m_preview->plate = m_importing; column->addWidget(m_preview); // Swift's sliders are continuous; these step finer than shown. @@ -89,18 +119,16 @@ RawDevelopSheet::RawDevelopSheet(RawDevelopRequest request, std::function<void(s m_settings.reset(); changed(); }); - auto *cancel = new QPushButton(QStringLiteral("Cancel"), this); - cancel->setObjectName(QStringLiteral("rawCancel")); - cancel->setAutoDefault(false); - connect(cancel, &QPushButton::clicked, this, [this] { m_finish(std::nullopt); }); - auto *importButton = new QPushButton(QStringLiteral("Import"), this); - importButton->setObjectName(QStringLiteral("rawImport")); - importButton->setDefault(true); - connect(importButton, &QPushButton::clicked, this, [this] { m_finish(m_settings); }); + m_cancel->setObjectName(QStringLiteral("rawCancel")); + m_cancel->setAutoDefault(false); + connect(m_cancel, &QPushButton::clicked, this, [this] { m_finish(std::nullopt); }); + m_import->setObjectName(QStringLiteral("rawImport")); + m_import->setDefault(true); + connect(m_import, &QPushButton::clicked, this, [this] { m_finish(m_settings); }); buttons->addWidget(m_reset); buttons->addStretch(1); - buttons->addWidget(cancel); - buttons->addWidget(importButton); + buttons->addWidget(m_cancel); + buttons->addWidget(m_import); column->addLayout(buttons); // The first preview starts at once; slider bursts coalesce. @@ -120,6 +148,37 @@ RawDevelopSheet::~RawDevelopSheet() m_cancelled->store(true); } +void RawDevelopSheet::setImporting(bool importing) +{ + if (importing == !m_importing->isHidden()) + return; + m_importing->setVisible(importing); + if (importing) + m_spinner->hide(); + for (const Row &row : m_rows) + row.slider->setEnabled(!importing); + m_cancel->setEnabled(!importing); + m_import->setEnabled(!importing); + m_preview->update(); + synchronize(); + // Swift's interactiveDismissDisabled: Escape and close wait. + if (importing) + window()->installEventFilter(this); + else + window()->removeEventFilter(this); +} + +bool RawDevelopSheet::eventFilter(QObject *watched, QEvent *event) +{ + if (watched != window()) + return false; + if (event->type() == QEvent::Close) { + event->ignore(); + return true; + } + return event->type() == QEvent::KeyPress && static_cast<QKeyEvent *>(event)->key() == Qt::Key_Escape; +} + void RawDevelopSheet::addRow(const QString &title, float RawDevelopSettings::*field, double low, double high, double scale, int precision, const QString &unit) { auto *slider = new QSlider(Qt::Horizontal, this); @@ -151,7 +210,7 @@ void RawDevelopSheet::changed() void RawDevelopSheet::develop() { m_cancelled = std::make_shared<std::atomic_bool>(false); - m_spinner->show(); + m_spinner->setVisible(m_importing->isHidden()); auto *watcher = new QFutureWatcher<QImage>(this); connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, cancelled = m_cancelled] { watcher->deleteLater(); @@ -184,5 +243,5 @@ void RawDevelopSheet::synchronize() row.slider->setValue(int(std::lround(value * row.scale))); row.value->setText(QString::asprintf("%.*f", row.precision, value) + row.unit); } - m_reset->setEnabled(!m_settings.isAsShot()); + m_reset->setEnabled(!m_settings.isAsShot() && m_importing->isHidden()); } diff --git a/src/UI/RawDevelopSheet.h b/src/UI/RawDevelopSheet.h index 6a7015c..129b864 100644 --- a/src/UI/RawDevelopSheet.h +++ b/src/UI/RawDevelopSheet.h @@ -19,6 +19,11 @@ class RawDevelopSheet : public QWidget { public: RawDevelopSheet(RawDevelopRequest request, std::function<void(std::optional<RawDevelopSettings>)> finish, QWidget *parent = nullptr); ~RawDevelopSheet() override; + // Import pressed: the frame develops; nothing can be changed. + void setImporting(bool importing); + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; private: class Preview; @@ -45,4 +50,7 @@ private: QProgressBar *const m_spinner; std::vector<Row> m_rows; QPushButton *const m_reset; + QPushButton *const m_cancel; + QPushButton *const m_import; + QWidget *const m_importing; }; diff --git a/tests/RawDevelopTests.cpp b/tests/RawDevelopTests.cpp index d2c7dce..90b5e43 100644 --- a/tests/RawDevelopTests.cpp +++ b/tests/RawDevelopTests.cpp @@ -37,15 +37,36 @@ QUrl dng(const QTemporaryDir &folder, const QString &name, const DNGFixture::Opt return QUrl::fromLocalFile(DNGFixture::write(folder.filePath(name), options)); } -QColor centre(const ImageLayer &layer) +// A region's mean, rounded: the noise averages out. +QColor tone(const QImage &image, const QRect ®ion) { - const QImage image = layer.asset.value().image(); - return image.pixelColor(image.width() / 2, image.height() / 2); + std::array<double, 3> sums{}; + for (int y = region.top(); y <= region.bottom(); ++y) { + for (int x = region.left(); x <= region.right(); ++x) { + const QColor colour = image.pixelColor(x, y); + sums[0] += colour.red(); + sums[1] += colour.green(); + sums[2] += colour.blue(); + } + } + const double count = double(region.width()) * region.height(); + return QColor(int(std::lround(sums[0] / count)), int(std::lround(sums[1] / count)), int(std::lround(sums[2] / count))); } QColor centre(const QImage &image) { - return image.pixelColor(image.width() / 2, image.height() / 2); + return tone(image, image.rect()); +} + +QColor centre(const ImageLayer &layer) +{ + return centre(layer.asset.value().image()); +} + +// The preview's picture, clear of the spinner and plate. +QColor shown(QWidget *preview) +{ + return tone(preview->grab().toImage(), QRect(70, 20, 150, 100)); } QString label(QWidget *sheet, const char *name) @@ -62,6 +83,9 @@ private slots: void whatCannotBeDevelopedIsReported(); void theLimitsLetTheirEdgeThrough(); void theSheetPreviewsAndAnswers(); + void theSheetWaitsWhileTheFrameDevelops(); + void aLongImportShowsTheImportingPlateAlone(); + void finishingWithNoImportWaitingClosesTheSheet(); }; void RawDevelopTests::theSheetsSettingsDevelopTheLayer() @@ -78,11 +102,20 @@ void RawDevelopTests::theSheetsSettingsDevelopTheLayer() RawDevelopSettings settings = session.rawDevelop().value().settings; settings.boost = 0; settings.exposure = 1; - // The sheet hears at once that it has gone. + // The sheet hears at once that it is importing. QSignalSpy changed(&session, &EditorSession::changed); session.finishRawDevelop(settings); - QVERIFY(!session.rawDevelop() && changed.count() == 1); + QVERIFY(session.rawDevelop() && session.rawImporting() && changed.count() == 1); + // It goes once the layer is in. + std::vector<std::pair<bool, size_t>> seen; + connect(&session, &EditorSession::changed, this, [&] { + seen.emplace_back(session.rawImporting(), session.document() ? session.document().value().layers.size() : 0); + }); QTRY_VERIFY(done); + QVERIFY(!session.rawDevelop() && !session.rawImporting()); + const auto ended = std::find_if(seen.begin(), seen.end(), [](const auto &each) { return !each.first; }); + QVERIFY(ended != seen.end() && ended->second == 1); + QVERIFY(std::all_of(seen.begin(), ended, [](const auto &each) { return each.first; })); QCOMPARE(names(session), (QStringList{"Gray", "Plain"})); const ImageLayer &developed = session.document().value().layers.front(); QCOMPARE(developed.asset.value().size(), QSize(64, 48)); @@ -100,6 +133,7 @@ void RawDevelopTests::cancelSkipsTheFileAndTheRestImport() session.importImages({dng(folder, "Gray.dng"), png(folder)}, std::nullopt, [&done] { done = true; }); QTRY_VERIFY(session.rawDevelop()); session.finishRawDevelop(std::nullopt); + QVERIFY(!session.rawDevelop() && !session.rawImporting()); QTRY_VERIFY(done); QCOMPARE(names(session), QStringList{"Plain"}); QVERIFY(!session.importError() && !session.rawDevelop()); @@ -140,7 +174,10 @@ void RawDevelopTests::whatCannotBeDevelopedIsReported() QVERIFY(broken.open(QIODevice::WriteOnly) && broken.write("gone") == 4); broken.close(); session.finishRawDevelop(session.rawDevelop().value().settings); + QVERIFY(session.rawImporting()); QTRY_VERIFY(done); + // A develop that fails takes the sheet down too. + QVERIFY(!session.rawDevelop() && !session.rawImporting()); QCOMPARE(session.importError().value(), "Gray.dng: " + unreadable); QCOMPARE(names(session), QStringList{"Plain"}); // A white the camera cannot record says so. @@ -223,7 +260,7 @@ void RawDevelopTests::theSheetPreviewsAndAnswers() // The first preview lands: the boosted gray, fitted. QWidget *preview = sheet->findChild<QWidget *>("rawPreview"); QTRY_VERIFY(sheet->findChild<QProgressBar *>("rawSpinner")->isHidden()); - QCOMPARE(preview->grab().toImage().pixelColor(200, 170), QColor(139, 139, 139)); + QCOMPARE(shown(preview), QColor(139, 139, 139)); // A develop under way spins; a newer change drops it. QSlider *exposure = sheet->findChild<QSlider *>("rawExposure"); QProgressBar *spinner = sheet->findChild<QProgressBar *>("rawSpinner"); @@ -238,12 +275,12 @@ void RawDevelopTests::theSheetPreviewsAndAnswers() disconnect(once); QThreadPool::globalInstance()->waitForDone(); QTest::qWait(50); - QCOMPARE(preview->grab().toImage().pixelColor(200, 170), QColor(139, 139, 139)); + QCOMPARE(shown(preview), QColor(139, 139, 139)); QVERIFY(!spinner->isHidden()); RawDevelopSettings dimmer = session.rawDevelop().value().settings; dimmer.exposure = -1; wait->start(); - QTRY_COMPARE_WITH_TIMEOUT(preview->grab().toImage().pixelColor(200, 170), centre(RawImporter::develop(path, dimmer)), 1000); + QTRY_COMPARE_WITH_TIMEOUT(shown(preview), centre(RawImporter::develop(path, dimmer)), 1000); QVERIFY(spinner->isHidden()); // A slider changes the settings; the preview follows. exposure->setValue(100); @@ -252,7 +289,7 @@ void RawDevelopTests::theSheetPreviewsAndAnswers() RawDevelopSettings brighter = session.rawDevelop().value().settings; brighter.exposure = 1; const QColor expected = centre(RawImporter::develop(path, brighter)); - QTRY_COMPARE(preview->grab().toImage().pixelColor(200, 170), expected); + QTRY_COMPARE(shown(preview), expected); sheet->findChild<QSlider *>("rawTemperature")->setValue(3000); QCOMPARE(label(sheet, "rawTemperatureValue"), QString("3000 K")); reset->click(); @@ -260,11 +297,11 @@ void RawDevelopTests::theSheetPreviewsAndAnswers() QCOMPARE(label(sheet, "rawTemperatureValue"), QString("6501 K")); QCOMPARE(sheet->findChild<QSlider *>("rawExposure")->value(), 0); QVERIFY(!reset->isEnabled()); - QTRY_COMPARE(preview->grab().toImage().pixelColor(200, 170), QColor(139, 139, 139)); + QTRY_COMPARE(shown(preview), QColor(139, 139, 139)); // Import develops the whole frame with the sheet's settings. sheet->findChild<QSlider *>("rawExposure")->setValue(100); sheet->findChild<QPushButton *>("rawImport")->click(); - QVERIFY(!session.rawDevelop()); + QVERIFY(session.rawDevelop() && session.rawImporting()); QTRY_VERIFY(done); QTRY_VERIFY(!view.findChild<QDialog *>("rawDevelopSheet")); QCOMPARE(centre(session.document().value().layers.back()), expected); @@ -292,5 +329,86 @@ void RawDevelopTests::theSheetPreviewsAndAnswers() QCOMPARE(session.document().value().layers.size(), size_t(1)); } +void RawDevelopTests::theSheetWaitsWhileTheFrameDevelops() +{ + const QTemporaryDir folder; + const QUrl raw = dng(folder, "Gray.dng"); + EditorSession session; + ContentView view(session); + view.resize(900, 600); + view.show(); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + bool done = false; + session.importImages({raw}, std::nullopt, [&done] { done = true; }); + QDialog *sheet = view.findChild<QDialog *>("rawDevelopSheet"); + QVERIFY(sheet); + QTRY_VERIFY(sheet->findChild<QProgressBar *>("rawSpinner")->isHidden()); + QWidget *plate = sheet->findChild<QWidget *>("rawImporting"); + QVERIFY(plate->isHidden()); + // Import pressed: the develop waits for the event loop. + sheet->findChild<QPushButton *>("rawImport")->click(); + QVERIFY(!plate->isHidden() && session.rawImporting()); + QStringList words; + for (const QLabel *each : plate->findChildren<QLabel *>()) + words << each->text(); + QCOMPARE(words, QStringList{"Importing…"}); + QVERIFY(sheet->findChild<QProgressBar *>("rawSpinner")->isHidden()); + for (const char *name : {"rawExposure", "rawTemperature", "rawTint", "rawBoost"}) + QVERIFY(!sheet->findChild<QSlider *>(name)->isEnabled()); + for (const char *name : {"rawReset", "rawCancel", "rawImport"}) + QVERIFY(!sheet->findChild<QPushButton *>(name)->isEnabled()); + // Escape and the close button wait for the layer. + QTest::keyClick(sheet, Qt::Key_Escape); + QVERIFY(!sheet->close()); + QVERIFY(sheet->isVisible() && session.rawDevelop() && session.rawImporting() && !done); + QTRY_VERIFY(done); + QTRY_VERIFY(!view.findChild<QDialog *>("rawDevelopSheet")); + QCOMPARE(session.document().value().layers.size(), size_t(1)); +} + +void RawDevelopTests::aLongImportShowsTheImportingPlateAlone() +{ + // A frame large enough that each develop takes a while. + const QTemporaryDir folder; + DNGFixture::Options large; + large.width = 4000; + large.height = 3000; + const QUrl raw = dng(folder, "Large.dng", large); + EditorSession session; + ContentView view(session); + view.resize(900, 600); + view.show(); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + bool done = false; + session.importImages({raw}, std::nullopt, [&done] { done = true; }); + QDialog *sheet = view.findChild<QDialog *>("rawDevelopSheet"); + QProgressBar *spinner = sheet->findChild<QProgressBar *>("rawSpinner"); + QTimer *wait = sheet->findChild<QTimer *>("rawWait"); + QTRY_VERIFY_WITH_TIMEOUT(spinner->isHidden(), 20'000); + // A preview under way: Import hides its spinner at once. + QSlider *exposure = sheet->findChild<QSlider *>("rawExposure"); + exposure->setValue(50); + QPushButton *reset = sheet->findChild<QPushButton *>("rawReset"); + QVERIFY(reset->isEnabled()); + QTRY_VERIFY(!spinner->isHidden()); + // Another change waits on its timer as Import is pressed. + exposure->setValue(60); + QVERIFY(wait->isActive()); + sheet->findChild<QPushButton *>("rawImport")->click(); + QVERIFY(spinner->isHidden() && !reset->isEnabled()); + // That develop starts during the import and shows no spinner. + QTRY_VERIFY(!wait->isActive()); + QVERIFY(session.rawImporting() && spinner->isHidden()); + QTRY_VERIFY_WITH_TIMEOUT(done, 60'000); +} + +void RawDevelopTests::finishingWithNoImportWaitingClosesTheSheet() +{ + // An answer with nobody waiting cannot leave the sheet importing. + EditorSession session; + session.finishRawDevelop(RawDevelopSettings()); + QVERIFY(!session.rawImporting() && !session.rawDevelop()); +} + QTEST_MAIN(RawDevelopTests) #include "RawDevelopTests.moc" diff --git a/tests/RawDitherTests.cpp b/tests/RawDitherTests.cpp new file mode 100644 index 0000000..c3eb579 --- /dev/null +++ b/tests/RawDitherTests.cpp @@ -0,0 +1,131 @@ +#include "DNGFixture.h" +#include "IO/RawImporter.h" +#include <QTemporaryDir> +#include <QtTest> +#include <set> +extern "C" { +#include "DitherPixels.h" +} + +// Swift's RawDitherTests: 16 bits rounded to 8 with fine noise. +namespace { +// Premultiplied RGBA at 16 bits, one value a row. +std::vector<quint16> wide(int width, int height, const std::function<double(int row)> &tone) +{ + std::vector<quint16> values(size_t(width) * size_t(height) * 4); + for (int y = 0; y < height; ++y) { + const quint16 value = quint16(std::lround(tone(y) * 65535)); + for (int x = 0; x < width; ++x) { + quint16 *pixel = values.data() + (size_t(y) * size_t(width) + size_t(x)) * 4; + pixel[0] = pixel[1] = pixel[2] = value; + pixel[3] = 65535; + } + } + return values; +} + +QImage quantized(const std::vector<quint16> &values, int width, int height) +{ + QImage image(width, height, QImage::Format_RGBA8888_Premultiplied); + dither_quantize16(values.data(), image.bits(), size_t(width), size_t(height), size_t(image.bytesPerLine())); + return image; +} +} + +class RawDitherTests : public QObject { + Q_OBJECT +private slots: + void ditheredGradientFollowsTheTonesNotTheSteps(); + void ditherIsFineAndRepeatable(); + void theNoiseNeverPassesAlpha(); + void theDevelopIsDithered(); +}; + +void RawDitherTests::ditheredGradientFollowsTheTonesNotTheSteps() +{ + // From 0.30 at the top to 0.32: five steps. + const auto tone = [](int row) { return 0.30 + 0.02 * (double(row) + 0.5) / 400; }; + const QImage image = quantized(wide(300, 400, tone), 300, 400); + double worst = 0, plain = 0; + std::vector<double> means; + std::set<int> steps; + for (int y = 0; y < 400; ++y) { + const uchar *row = image.constScanLine(y); + double sum = 0; + for (int x = 0; x < 300; ++x) + sum += row[x * 4]; + means.push_back(sum / 300); + const double ideal = tone(y) * 255; + worst = std::max(worst, std::abs(means.back() - ideal)); + plain = std::max(plain, std::abs(std::round(ideal) - ideal)); + steps.insert(int(std::lround(ideal))); + } + QVERIFY2(worst < 0.25 && plain > 0.45, qPrintable(QStringLiteral("%1 %2").arg(worst).arg(plain))); + QVERIFY(means.front() < means.back()); + QVERIFY(steps.size() <= 6); +} + +void RawDitherTests::ditherIsFineAndRepeatable() +{ + const std::vector<quint16> flat = wide(64, 64, [](int) { return 0.5; }); + const QImage first = quantized(flat, 64, 64), second = quantized(flat, 64, 64); + QCOMPARE(first, second); + std::set<int> seen; + double sum = 0; + for (int y = 0; y < 64; ++y) { + for (int x = 0; x < 64; ++x) { + const uchar *pixel = first.constScanLine(y) + x * 4; + seen.insert(pixel[0]); + sum += pixel[0]; + QCOMPARE(int(pixel[3]), 255); + } + } + QVERIFY(*seen.begin() >= 126 && *seen.rbegin() <= 129 && seen.size() > 1); + QVERIFY2(std::abs(sum / 4096 - 127.5) < 0.1, qPrintable(QString::number(sum / 4096))); + // Two draws: three in four stay on a level. + const QImage level = quantized(wide(64, 64, [](int) { return 128.0 / 255; }), 64, 64); + int kept = 0; + for (int y = 0; y < 64; ++y) { + for (int x = 0; x < 64; ++x) + kept += level.constScanLine(y)[x * 4] == 128; + } + QVERIFY2(std::abs(kept / 4096.0 - 0.75) < 0.03, qPrintable(QString::number(kept / 4096.0))); +} + +void RawDitherTests::theNoiseNeverPassesAlpha() +{ + // Premultiplied colour at its alpha: noise up is clamped. + std::vector<quint16> values(16 * 16 * 4); + for (size_t index = 0; index < values.size(); index += 4) { + values[index] = values[index + 1] = values[index + 2] = 32896; + values[index + 3] = 32896; + } + const QImage image = quantized(values, 16, 16); + for (int y = 0; y < 16; ++y) { + for (int x = 0; x < 16; ++x) { + const uchar *pixel = image.constScanLine(y) + x * 4; + QCOMPARE(int(pixel[3]), 128); + QVERIFY(pixel[0] <= 128 && pixel[1] <= 128 && pixel[2] <= 128); + } + } +} + +void RawDitherTests::theDevelopIsDithered() +{ + const QTemporaryDir folder; + const QString path = DNGFixture::write(folder.filePath("gray.dng")); + RawDevelopSettings settings = RawImporter::asShot(path).value(); + settings.boost = 0; + const QImage developed = RawImporter::develop(path, settings); + QCOMPARE(developed, RawImporter::develop(path, settings)); + std::set<int> reds; + for (int y = 0; y < developed.height(); ++y) { + for (int x = 0; x < developed.width(); ++x) + reds.insert(developed.pixelColor(x, y).red()); + } + // 20000 of 65535 through sRGB's curve is 149.94. + QCOMPARE(reds, (std::set<int>{149, 150, 151})); +} + +QTEST_GUILESS_MAIN(RawDitherTests) +#include "RawDitherTests.moc" diff --git a/tests/RawImporterTests.cpp b/tests/RawImporterTests.cpp index bbad1e2..2774089 100644 --- a/tests/RawImporterTests.cpp +++ b/tests/RawImporterTests.cpp @@ -12,10 +12,20 @@ QColor middle(const QImage &image) return image.pixelColor(image.width() / 2, image.height() / 2); } +// Each channel's mean, rounded: the import's noise averages out. std::array<int, 3> rgb(const QImage &image) { - const QColor colour = middle(image); - return {colour.red(), colour.green(), colour.blue()}; + std::array<double, 3> sums{}; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + const QColor colour = image.pixelColor(x, y); + sums[0] += colour.red(); + sums[1] += colour.green(); + sums[2] += colour.blue(); + } + } + const double count = double(image.width()) * image.height(); + return {int(std::lround(sums[0] / count)), int(std::lround(sums[1] / count)), int(std::lround(sums[2] / count))}; } // The camera's neutral under a white, through the matrix. @@ -231,7 +241,8 @@ void RawImporterTests::theQueueKeepsThePreviewsDecodeUntilReleased() QCOMPARE(queue.develop(path, settings, 40).size(), QSize(32, 24)); const QString other = DNGFixture::write(folder.filePath("other.dng")); QCOMPARE(rgb(queue.develop(other, settings, 20))[1], 150); - QCOMPARE(rgb(queue.develop(path, settings, 20))[1], 109); + // Qt's area average truncates the fine noise: a level low. + QCOMPARE(rgb(queue.develop(path, settings, 20))[1], 108); queue.release(); } diff --git a/tests/Tests.cmake b/tests/Tests.cmake index ad185f7..2518026 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -294,6 +294,9 @@ compositor_test(CanvasEditContractTests) target_include_directories(AdjustmentBandsTests PRIVATE ${OMAPHOTO_KERNELS}) compositor_test(RawImporterTests) compositor_test(RawDevelopTests) +compositor_test(RawDitherTests) +# It drives the kernel's rounding directly. +target_include_directories(RawDitherTests PRIVATE ${OMAPHOTO_KERNELS}) compositor_test(ToolDefaultsTests) compositor_test(ColorAdjustmentTests) compositor_test(CameraRawTests)