14.19: Filter › Scanlines, its own filter, on Swift's cdde327 kernel

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 10:46:16 -04:00
1 parent d06fb55bc4
commit 1a129b9a23
26 files changed
+905 -479

No files matched your search

+2 -1
View File
@@ -177,7 +177,7 @@ Platform adaptations, each forced by a missing Qt analog:
- Shift on a pixel drag and Select All from the list (13.10): `CanvasView::moveSelectionTool` (`EditorCanvas+Selection.cpp`) twins Swift's `60bde4f`: with Shift held a Ctrl-drag of selected pixels zeroes the shorter axis of the offset, lengths compared (`std::abs`), a tie keeping across, as `dragSelection` already does for the outline; read from each move's own modifiers, as Swift's `event.modifierFlags`. `451281e`: Swift's table now hands Select All to the canvas (`session.selectAll()`, guarded on a document) instead of selecting every row, so the twin's `NativeLayerList::selectAllRows` (12.5b's round-3 fix) and the menu's `focusedList` branch are gone: a focused list falls through to `session().selectAll()`, which guards on the document itself. A rename's editor is a `QLineEdit`, so it keeps its own Select All first, as Swift's field editor. Tests: `SelectionCanvasTests::shiftKeepsMovedPixelsOnALine` (across, down, a tie, a long left beating a short down, without Shift, the release keeping the last offset), `SelectMenuTests::selectAllInAFieldIsTheFields` (a real Ctrl+A on the focused list selects the canvas, history "Select All", layers and the active one unchanged, the list keeping focus; Swift's `selectAllInTheLayersPanelSelectsTheCanvas`). Mutants: 5 on the Shift branch, all caught (the `abs`-less comparison caught after the left-beats-down step was added). No survivors.
- Why a brush cannot paint (13.11, `133c34a`): `EditorSession::paintRefusal` (`EditorSession+Brush.cpp`, declared in `EditorSession+Brush.h`) twins Swift's `paintRefusal`: none while `canEditLayers` refuses, without an active layer or when `canPaint` holds; then several layers, a folder's pixels, a hidden layer or one in a hidden folder, a mask turned off, an adjustment's pixels, an empty selection, in Swift's order and words, with Ctrl+D for ⌘D and the curly quotes kept. `beginBrush` splits Swift's guard: a tool that cannot paint the target (Spot Healing or Clone Stamp on a mask, a tool that is no brush) returns silently, a refused target sets `brushError` to the refusal (none when busy, so a waiting error is cleared, as Swift's `brushError = paintRefusal`). `beginWarp` sets the mask's words or the refusal. Fix beyond Swift: Swift's gradient sets the refusal only past its first guard, which is reached with a refused target only while a gradient is pending, where `canEditLayers` refuses and the refusal is always nil; so Swift's gradient says nothing on an empty selection, which its commit message says it explains. The twin's `beginGradient` sets the refusal at the first guard (no pending gradient, `canPaint` refusing); Swift's second assignment, always nil there, is left silent. Like the brush, the gradient's refusal while the editor is busy is none, which clears a stale alert where Swift's first guard returns untouched. `invertSelection` leaves no selection when the inverse is empty (Select All then Inverse), one `Inverse` step, as Swift's. Tests: `PaintRefusalTests` (every refusal, and the order at each seam that two reasons can share: two layers before a folder, a folder before its hiding, hiding before a mask turned off, an adjustment before an empty selection, a folder's or adjustment's mask past the folder or adjustment; the mask-off and adjustment tests exclude each other, so their order is unreachable; a layer inside a hidden folder; the gradient and the Smear; a busy session clearing, another tool silent), `SelectionTests::inverseOfEverythingDeselects` (Swift's). Equivalent mutants: `paintRefusal`'s `canPaint()` term (when `canPaint` holds every later test is false) and the empty-selection test without `isEmpty` (reached only when `canPaint` refuses for no listed reason, which needs an active layer outside `selectedLayerIDs`, which no session call makes). Painting at the layer's own resolution (13.11, `d3170ab`): `BrushStroke::Clone` is Swift's tuple `{image, placed, inGrid}`: the image painted through the tip and where it sits, in document pixels or in the stroke's own grid. `clonePixels` maps tile pixels through the grid (and `pixelToDocument` unless `inGrid`) into `placed`, scaled to the image, so a translation alone still copies or shifts by the fraction as before. `BrushStroke::gridRect` (in `BrushStroke+Commit.cpp`, beside `transform`, as Swift's) carries a document offset into the grid through the inverse map's linear part (`CGSize.applying`), turned, scaled and flipped as the layer. `cloneSample` takes the stroke and the offset: the stroke layer's own pixels placed at `gridRect(sourceRect, offset)`, or, sampling all layers, the composite at document size placed `offset` away; `blurSample` takes the stroke: the layer's pixels or mask drawn nearest at the margin of a region `ceil(3σ)` round `sourceRect`, σ the bar's Radius (13.16; Swift's tenth of the brush, 1.5 to 30, at 13.11) divided by the grid's document size per pixel (`sqrt|det|`) and capped at half the longer side, a mask on its edge tone first (Swift's `if let` on the mask and its `?? 0` for a missing document are dead, since the mask's asset is the sample and a stroke needs a document, and left out), softened through `PixelAdjust::gaussianBlur` (a mask's edge repeated, pixels clear past theirs) at σ × fit, in the pieces the brush reaches since 13.16, where fit shrinks the sample to a budget of `min(maxSurfacePixels, max(16 M, four canvases))`; it returns the region as `placed`, `inGrid`. Swift's old mask sample (the placement's black box, `drawCoverage`) is gone with it. `beginBrush` makes the stroke first, then samples into it, as Swift's; a sample that fails returns without a stroke. The Smear's commit places its copy at document size. The brush and eraser: Swift's commit only changes its CPU fallback's stamp (`stampDiameter`), dead in the twin (8.1); the held judgment holds: the twin's coverage is rendered in the stroke's grid, so `NativeResolutionPaintTests` passes Swift's erase cases (scaled down, unequal, turned, and on the canvas scaled back up) and the blank layer's document resolution without any change to `BrushStroke::grid` or `BrushCoverage`. Tests: `NativeResolutionPaintTests` (Swift's seven, plus the offset carried through a flipped and a turned layer, pixel for pixel), `BlurToolTests` (`theSampleFollowsTheRadius`, `theSampleIsInTheLayersOwnPixels`: twice as fine, and a tiny layer's cap; `aHugeLayersSampleIsMadeLine truncated
- Masks as Photoshop's (13.12, `b3419ab`): `addMask` with a selection now reveals it (white inside, black outside, the coverage as drawn) and, `revealing` false, hides it (the coverage inverted), one `Reveal Selection` or `Hide Selection` step; the row menu's Reveal All and Hide All call it as before, so Hide All with a selection hides the selection. `LayerMaskMenu` reads Alt from `QGuiApplication::keyboardModifiers()` when clicked, Swift's `NSApp.currentEvent`, so Alt-click adds the opposite, with Swift's help in Alt's words. The session keeps `viewsMaskAlone` (a getter and a setter that notifies on a change, the badge's write; `EditorSession.h`, defined in `EditorSession.cpp` beside Swift's `didSet`) and `maskAloneLayer()` (Swift's computed property: the view asked, the mask targeted, the active layer with a mask); `isMaskSelected` gains Swift's `didSet` as a private `setIsMaskSelected` (declared in `LayerMask+Session.h`, since `EditorSession.h` sits at 499 lines; defined in `EditorSession.cpp`), which every former write of `m_isMaskSelected` now calls, so targeting the pixels, another layer, a deleted mask or a load ends the view. `maskAloneLayer` and `toggleMaskAlone` live in `LayerMask+Alone.cpp`, since `LayerMask.cpp` already held 495 lines. `toggleMaskAlone` targets the mask through `selectLayerTarget` and shows it unless it already showed; where that refuses and leaves another target, so does the toggle, but with the project busy and the same mask already targeted the refusal is silent and the view still flips, as Swift's does. The canvas's `DisplayState` gains `maskAlone` (the mask asset's `ImageIdentity`, whether or not enabled, since the composite may skip it), and `drawLayers` hands off to `drawMaskAlone` (`EditorCanvas+Masks.cpp`, beside `liveFolderMaskClip`, which moved there to keep `EditorCanvas.cpp` under 500 lines): the canvas filled with the mask's edge tone, then a brush stroke or gradient laid into that mask through `LayerRenderer::drawBrushPreview` (the painted mask's own raster when it has one), else the mask at its displayed placement or the layer's transform, gray where it shows; the crisp zoom path draws through `drawLayers` too. The list's mask thumbnail: Alt's release without a drag calls `toggleMaskAlone`, only inside the thumbnail after a move under `startDragDistance` (4.7's rule; Swift's `dragMaskCopy` toggles on any left-up before a 3-point drag) (Shift no longer toggles there, as Swift's), the tooltip names Alt-click, and the targeted ring is white while that mask shows alone (`setTargeted`'s `alone`; Swift's `maskAloneLayer?.id == layerID` is left out as dead: the ring shows only on the active row, whose layer is the one shown alone). `LayerListFocusTests` and `LayerDragGestureTests` expected Alt-Shift to toggle the mask; they now expect Swift's view. `MaskAloneBadge` (in `UI/LayerMaskMenu`, Swift's file) is 26 points tall, 75% black with a 14% white rim, radius 13: the mask glyph at 11, "Layer Mask" 12 px semibold, the layer's name 12 px at 60% white, elided within 220 points, and an `xmark` button (a new `LayerIcon`, Swift's bold `xmark` as two round-capped strokes) with Swift's tooltip in Alt's words and accessible name, which ends the view. `ContentView` keeps one badge, shown while `maskAloneLayer` holds, in a layout nested in the canvas slot (it starts hidden through `synchronize`, which the constructor runs) (bottom, centred, 14 points up), so the room beside and below it stays the canvas's; Qt child widgets take their own clicks, so Swift's AppKit hosting has no twin. Swift's GPU fix (a mask stroke's grid starting at the mask's background) is the twin's behaviour already: `allocateTile` fills a mask tile with `maskBackground`. Tests: `MaskAloneTests` (Swift's three, plus another layer ending the view and the setter's signal, a stroke and a gradient shown as committed, a disabled and a placed mask, the crisp zoom, the badge's words, place, elision and clicks, the list's Alt-click, Shift ignored and the white ring; the view across undo and redo and when the shown mask is targeted again; a pending pixel gradient on the layer and another layer's pending mask gradient kept out of the view while their commits are posted), `SelectionEditTests` (Swift's renamed two and the scaled layer's hiding mask), `LayersPanelTests::theMaskButtonUsesTheSelection` (plain and Alt clicks with and without a selection, the tooltips, the row's Hide All). Mutants: the session 10 + 1 rerun (the active-layer term of `toggleMaskAlone` pinned by a busy refusal with another layer's mask targeted), equivalent: `maskAloneLayer`'s `m_isMaskSelected` and `layer->mask` terms (every path that clears either clears the view through `setIsMaskSelected`; Swift's guards, kept); `drawMaskAlone` 6 + 3 reruns (the placement pinned off the old box), the stroke's `isMask` and layer terms are live, not equivalent: `resolveGradient` only posts the commit, so a pending pixel gradient, or another layer's mask gradieLine truncated
- Quit mid-edit and Scanlines (13.17, Swift 1.4.4). Quit (`1aad384`): `ProjectWorkspace::settlePendingEdits` twins Swift's: on the tab on screen a pending gradient is applied (`commitGradient`), then a pixel move (`finishPixelMove`), then a filter, Hue/Saturation, Levels, Color Range, the Expand/Contract/Feather panel (`setSelectionAmountOperation(nullopt)`) and the colour picker are cancelled (their Cancel, so nothing un-OK'd lands). It takes a completion callback (Swift's `async`), run from the event loop once both commits have landed, and only while the workspace lives (`QPointer`), the tab kept alive in the callbacks as every workspace flow keeps it. `confirmQuit` refuses at once while the workspace manages (Swift's guard moved there from `applicationShouldTerminate`; the twin's window close reaches `confirmQuit` directly, so this guard is what stops a second close mid-quit), applies open text, settles, and only then asks `canSwitch`; a refusal there (a busy project, a commit still running) is a warning on `omaphoto.app` where Swift beeps. Swift's `gradientEdit != nil` and `pixelMove != nil` tests before awaiting are left out: both twins return (posted) when there is nothing to commit. Swift's `finishAdjustmentEditing(commit: false)` is left out as dead: `cancelFilter`, `cancelHueSaturation` and `cancelLevels`, each called before it, call it first, and an adjustment's editor is always one of those three (its request and original are set and cleared with them; while the editor still renders, `finishAdjustmentEditing` does nothing in Swift either, and `canSwitch` refuses the quit). Tests: `QuitPendingEditsTests` (the tab on screen settled and another left as it is; a quit refused while an adjustment's editor is asked for and not yet rendered, then cancelled once open; Swift's `quittingAppliesAPendingGradient` and `quittingCancelsAnOpenDialog`; a pixel move applied as one `Move Pixels` step; Color Range putting the selection back, the selection-amount panel, the colour picker not taking its colour; an adjustment's editor putting the settings back; a workspace gone mid-settle calling nothing; closing the window applying the gradient before the Save question; a busy project refused with the warning, nothing asked or applied; a second quit while the first asks refused unsettled). Scanlines (`9c99853`, `cd2f998`): `DitherStyle::scanlines` ("Scanlines (CRT)") joins the fourth group; `usesPixelSize` (false for ASCII and Scanlines: no chunky pixels, no Pixel Size or Pixel Shape rows) and `drawsMarks` without Scanlines, as Swift's. `DitherSettings` gains `lineSpacing` (2–32, rounded, 4), `glow` (0–100, 35), `dots` (0–100, 0) and `wobble` (0–64 px, unrounded, 0), normalized as Swift's; the kernel's cell is the line spacing for Scanlines. `kernels/DitherPixels.c/.h` are cd2f998's, with one adaptation: Swift's kernel threads through GCD (`dispatch_apply`) and Apple blocks, which GCC cannot compile. `in_bands` keeps Swift's split (one band under 64 items, else 32) but takes a C function and a context; the three block bodies (the tone pass, the scanlines, the glow) are moved word for word into static functions with context structs (`tone_job`, `scan_job`, `glow_job`), and `__block int failed` is a C11 `atomic_int`. The bands run through `dither_bands`, a C hook defined in `Document/Dither.cpp` on `PoolMap::blocking` (QtConcurrent, so a filter worker lends its pool place). The arithmetic is unchanged; this is a forced difference (GCC has no blocks or GCD), agreed with the coordinator. `bandsDrawAsOneBandDoes` holds 32 bands against one: the top rows of a 300-row picture equal a 62-row picture's, which runs as one band in both passes. `glowing` twins Swift's: sigma `lineSpacing × 3 + 3`, shrunk by `max(1, floor(sigma / 4))`, blurred at `sigma / shrink`, scaled back and added through `dither_glow` at `glow / 100 × 2.5`. Core Image clamps to the extent before it shrinks, so the twin pads the image by the blur's reach (three sigmas, a whole number of shrink steps) with its edges repeated, the far sides filled to a whole number of steps so the shrink and the enlargement are both exactly the shrink (`theGlowScalesBackExactlyOffTheStep`), shrinks that with `QImage::scaled`'s smooth filter (an area average, where Core Image samples bilinearly), blurs with the edge repeated (`PixelAdjust::gaussianBlur`), scales back bilinearly and crops the middle; it stays within three levels of light of Swift's first commit's full-size blur (`theGlowIsTheBlurredLightAtItsAmount`, both axes). The sheet adds Line Spacing (px), Glow (%), Dots (%) and Wobble (px) with Swift's help under Scanlines, and Pixel Size and Pixel Shape follow `usesPixelSize`. Tests: `ScanlinesTests` (Swift's four `DitherTests`; the beam's rows for white, 0.35 gray and a striped average; the style's flags and that pixel size, shape and cell size change nothing; the four settings' clamps, the rows' units and ranges read from the sliders, rounding and defaults; TwLine truncated
- Quit mid-edit and Scanlines (13.17, Swift 1.4.4; since 14.19 Scanlines is a filter of its own, below). Quit (`1aad384`): `ProjectWorkspace::settlePendingEdits` twins Swift's: on the tab on screen a pending gradient is applied (`commitGradient`), then a pixel move (`finishPixelMove`), then a filter, Hue/Saturation, Levels, Color Range, the Expand/Contract/Feather panel (`setSelectionAmountOperation(nullopt)`) and the colour picker are cancelled (their Cancel, so nothing un-OK'd lands). It takes a completion callback (Swift's `async`), run from the event loop once both commits have landed, and only while the workspace lives (`QPointer`), the tab kept alive in the callbacks as every workspace flow keeps it. `confirmQuit` refuses at once while the workspace manages (Swift's guard moved there from `applicationShouldTerminate`; the twin's window close reaches `confirmQuit` directly, so this guard is what stops a second close mid-quit), applies open text, settles, and only then asks `canSwitch`; a refusal there (a busy project, a commit still running) is a warning on `omaphoto.app` where Swift beeps. Swift's `gradientEdit != nil` and `pixelMove != nil` tests before awaiting are left out: both twins return (posted) when there is nothing to commit. Swift's `finishAdjustmentEditing(commit: false)` is left out as dead: `cancelFilter`, `cancelHueSaturation` and `cancelLevels`, each called before it, call it first, and an adjustment's editor is always one of those three (its request and original are set and cleared with them; while the editor still renders, `finishAdjustmentEditing` does nothing in Swift either, and `canSwitch` refuses the quit). Tests: `QuitPendingEditsTests` (the tab on screen settled and another left as it is; a quit refused while an adjustment's editor is asked for and not yet rendered, then cancelled once open; Swift's `quittingAppliesAPendingGradient` and `quittingCancelsAnOpenDialog`; a pixel move applied as one `Move Pixels` step; Color Range putting the selection back, the selection-amount panel, the colour picker not taking its colour; an adjustment's editor putting the settings back; a workspace gone mid-settle calling nothing; closing the window applying the gradient before the Save question; a busy project refused with the warning, nothing asked or applied; a second quit while the first asks refused unsettled). Scanlines (`9c99853`, `cd2f998`): `DitherStyle::scanlines` ("Scanlines (CRT)") joins the fourth group; `usesPixelSize` (false for ASCII and Scanlines: no chunky pixels, no Pixel Size or Pixel Shape rows) and `drawsMarks` without Scanlines, as Swift's. `DitherSettings` gains `lineSpacing` (2–32, rounded, 4), `glow` (0–100, 35), `dots` (0–100, 0) and `wobble` (0–64 px, unrounded, 0), normalized as Swift's; the kernel's cell is the line spacing for Scanlines. `kernels/DitherPixels.c/.h` are cd2f998's, with one adaptation: Swift's kernel threads through GCD (`dispatch_apply`) and Apple blocks, which GCC cannot compile. `in_bands` keeps Swift's split (one band under 64 items, else 32) but takes a C function and a context; the three block bodies (the tone pass, the scanlines, the glow) are moved word for word into static functions with context structs (`tone_job`, `scan_job`, `glow_job`), and `__block int failed` is a C11 `atomic_int`. The bands run through `dither_bands`, a C hook defined in `Document/Dither.cpp` on `PoolMap::blocking` (QtConcurrent, so a filter worker lends its pool place). The arithmetic is unchanged; this is a forced difference (GCC has no blocks or GCD), agreed with the coordinator. `bandsDrawAsOneBandDoes` holds 32 bands against one: the top rows of a 300-row picture equal a 62-row picture's, which runs as one band in both passes. `glowing` twins Swift's: sigma `lineSpacing × 3 + 3`, shrunk by `max(1, floor(sigma / 4))`, blurred at `sigma / shrink`, scaled back and added through `dither_glow` at `glow / 100 × 2.5`. Core Image clamps to the extent before it shrinks, so the twin pads the image by the blur's reach (three sigmas, a whole number of shrink steps) with its edges repeated, the far sides filled to a whole number of steps so the shrink and the enlargement are both exactly the shrink (`theGlowScalesBackExactlyOffTheStep`), shrinks that with `QImage::scaled`'s smooth filter (an area average, where Core Image samples bilinearly), blurs with the edge repeated (`PixelAdjust::gaussianBlur`), scales back bilinearly and crops the middle; it stays within three levels of light of Swift's first commit's full-size blur (`theGlowIsTheBlurredLightAtItsAmount`, both axes). The sheet adds Line Spacing (px), Glow (%), Dots (%) and Wobble (px) with Swift's help under Scanlines, and Pixel Size and Pixel Shape follow `usesPixelSize`. Tests: `ScanlinesTests` (Swift's four `DitherTests`; the beam's rows for white, 0.35 gray and a striped average; the style's flags and that pixel size, shape and cell size change nothing; the four settings' clamps, the rows' units and raLine truncated
- Camera Raw curves as Photoshop's (13.18, Swift 1.4.5: `60d9117`): `kernels/AdjustPixels.c/.h` are 60d9117's: the tone curve runs on red, green and blue alike (`toneLut`, Swift's rename of `lumaLut`), and Refine Saturation means what Swift's now means: 0 the curve per channel, below 0 easing toward the colour scaled to the curved brightness (−100 brightness alone), above 0 pushing the curved colour from its luminance by 1 + the amount. `CameraRawCurveSettings::parametric` twins Swift's: unchanged with all four amounts at 0; otherwise 33 anchors, each `bend` of `bend` (shadows and highlights past the outer dividers, then darks and lights either side of the middle one, gamma curves of strength 1.66), run through the same curve as Image › Curves (`point`), so the regions meet without a corner. `bend` is a private static, as Swift's; `lumaTable` is `toneTable`. The twin's normalization of the dividers (5–90, +2 apart, 98 at most) still clamps what a drag writes. The graph's gestures (`UI/CameraRawColorControls+Curve.cpp`, beside the controls' file) are Swift's single `DragGesture`: a left press picks once (`parametricDrag`: within 18 points of the bottom the nearest divider, the first of equals; elsewhere the region whose tone range holds the press, a tone on a divider joining the region above; `pointDrag`: the point nearest by distance within 0.055, chosen; else a new point there, if fewer than 16, inside x 0.01–0.99 and over a hundredth from every point's x, sorted in, stored and chosen), then `continueDrag` runs at the press and on every left-button move: a point's output follows the pointer, an inner point's x stays a hundredth inside its neighbours, an end keeps its x; a divider follows, shadow below dark − 2 and dark below light − 2; a region's amount is its start minus the drag's height over the graph's times 200, within ±100, rounded. Left out as dead, since normalizing does each (`CameraRawCurveSettings::normalized`: amounts, dividers 5–90, +2 apart, 98 at most, and the repair's 0–1 outputs): Swift's 2–98 clamp of a divider, dark's `shadow + 2` floor and light's `dark + 2` floor, and the 0–1 clamps of a dragged or new point's output (a press inside the graph is inside 0–1 anyway). The new point's `x > 0.01` and `x < 0.99` are dead too: the ends sit at 0 and 1, and a point within a hundredth of any x is refused. `bend`'s `lower > 0` and `upper < 1` are dead (no tone lies below 0 or above 1). The release lets go. Qt can lose a release, so every press picks afresh. A double click is Qt's second press: it picks and drags as a press (Swift's drag sees it too), then on the point page removes the nearest inner point by x within 0.04 and drops the choice, and lets go of the drag (Swift removes at the tap's end, which ends the drag). Swift re-picks on each move while a press picked nothing; with the same start and points it picks nothing again, so the twin leaves that out. The chosen point (Swift's `selected`) draws in `Highlight`, the accent, and the readout shows only while it exists, following the drag; another channel drops the choice and the drag (Swift's `onChange`). The graph's help and Refine Saturation's are Swift's new words. Swift's `CameraRawMixerControls` gains the same `Drag` and `selected` state but never reads it: dead, left out. A fix beyond Swift: Swift holds an inner point exactly a hundredth from its neighbours, where the curve's repair (`x > kept.x + 0.01`) drops it (or drops the next point), and the drag's index then names another point; the twin holds it a billionth further inside, so the repair keeps it and the drag stays on it (`aPointIsPickedByDistanceAndKept`, `endPointsStayAndARealDoubleClickRemoves`, both failing before the fix; `CameraRawColorTests` pins the repair's strict `>` at exactly a hundredth, which the fix rests on). A fix beyond Swift: a preset's points drop the choice (TASKS asks for it; Swift's `applyPreset` keeps the index, which then lights whatever point holds it); Custom changes nothing and keeps it. Tests: `CameraRawCurveTests` (Swift's `parametricCurveIsSmooth`, `parametricCurveMatchesPhotoshop`, `curveDeepensColorLikePhotoshop`; each anchor against the bend written apart, and the smooth curve between; each region bending only its range, dividers at the ends bending nothing; the tone table through the point curve; Refine Saturation at 0 per channel through the table, −100 brightness with the hue kept, −50 halfway, +50 half again from luminance), `CameraRawCurveGraphTests` (a double click's 0.04 reach by x, and one in the divider strip moving the divider; regions lifted and lowered from their start, clamped and rounded, picked once by tone; dividers in the strip, nearest and first of equals, kept ordered and within the settings' limits; points by distance, kept through the drag, a new point added and dragged by the same press, ends moving their outputs alone; refusals near an edge, a point's x, at sixteen; the chosen point lit and read Line truncated
- Held keys, field values and resize snapping in the Move tool (13.13: `bca8f13`, `03d692c`, `686d8c7`, `c459f88`): `UI/HeldModifiers` twins Swift's class, a `QObject` singleton (`shared()`) with one signal, `changed()`. An application event filter reads key presses and releases and left-button presses and releases, Swift's `flagsChanged`, `keyDown`, `leftMouseDown` and `leftMouseUp`; a modifier key's own event sets or clears its bit, since Qt's event may not count it yet. Ctrl, Shift, Alt and Meta count (Swift's Command, Shift, Option, Control). Typing counts as none: Swift's `NSText` first responder is a focus widget with `Qt::WA_InputMethodEnabled`, so a `QLineEdit` and the canvas holding open text alike. `applicationStateChanged` stands for the two activation notifications (inactive clears; active reads `queryKeyboardModifiers`), and a `QMenu`'s `Hide` for `NSMenu.didEndTracking`. `TransformInspector` shows Auto Select as `transformAutoSelect != Ctrl` and the lock as `locksTransformRatio != Shift`, and a click while held writes the other way, as Swift's bindings; it synchronizes on `HeldModifiers::changed` too. Tooltips carry Swift's new words, Ctrl for Command. `transformPressLayer` follows Swift's: `autoSelect = transformAutoSelect != Ctrl`; Ctrl picks the layer under the pointer only with Auto Select off or with Shift (Ctrl+Shift adds either way). `TransformEdit.fromFields` sits last; `beginTransform(persistent, fromFields)` sets it, where Swift writes `transformEdit?.fromFields` in place (the twin's edit is read-only from outside). The bar's `change` opens a non-persistent `fromFields` edit; `finish` commits it, one step, when a field's scrub ends (`NumericScrub`'s `onEnd`) or the field loses focus. A focus borrowed by a menu, a popup or another window in front (`ActiveWindowFocusReason`, new: AppKit keeps the first responder when the app goes to the back) finishes nothing and keeps the typing. Escape in a field throws a `fromFields` edit away before handing the canvas the keys. The Swift path assumed: AppKit sends the Cancel button's Escape key equivalent (`configuredNativeShortcut(.escape)`) before the field editor, the only way `686d8c7`'s "Escape still throws the change away" can hold, since the field's own `onExitCommand` would end the focus and so apply the value. That path would also cancel a Ctrl+T edit from a field (and Return apply it); the twin keeps 6.1's reading there, a Ctrl+T edit waiting for the canvas's keys, a known difference held from 6.1. Flip H, Flip V and Sampling open a `fromFields` edit with no finish of their own, as Swift's do: it applies with the next field's finish, a canvas drag or anything that commits. `beginTransformDrag` commits a `fromFields` edit first, after `transformPressLayer` (so it picks nothing, as Swift's `picks`), so the two stay separate steps. Qt moves the focus before a press arrives, where SwiftUI's focus changes after `mouseDown`: a field left for a mouse press (`MouseFocusReason`) finishes from the event loop (a zero `QTimer`), so a typed value then a press over another layer drags the active layer under Auto Select, as in Swift (review 2's finding; `MoveBarPressTests`). `snappedResizePoint` lives in `Document/Crop.cpp` with Swift's: the edges each axis moves snap on their own to `transformSnapTargets` (the file's `nearest`, the first of ties), the nearer alone when proportional, the pointer nudged by one pixel's measured step; an upright layer only; guides set, or cleared when snapping is off, the drag is no resize or the layer turned. `dragTransform` snaps a resize unless Ctrl is held, proportional as `locksTransformRatio != Shift`. Dead or equivalent: `beginDistort` keeps `fromFields` where Swift's rebuild drops it, but a `fromFields` edit never reaches it (the canvas commits it before any handle drag). `snappedResizePoint`'s `before` read from `update(result)` or `update(point)` is equivalent (unlocked axes are independent, and proportional leaves one snap). The edge's tie (`<=`, the low edge) is reachable (the right handle half a pixel from its anchor, a one-pixel box) and held by `aTieTakesTheLowEdge`; the dragged handle's carried place by a drag past the anchor (`aLongDragSnapsTheEdgeItCarries`); Alt's centre resize by `altResizesFromTheCentreAndSnaps`. `QRectF::normalized` was dropped as dead (a resize keeps sizes positive and flips instead). Untested paths: the menu `Hide` and the activation's `queryKeyboardModifiers` (offscreen holds no real keyboard state). Tests: `MoveBarPressTests` (a typed value then a real press on another layer drags the active layer, two steps; Ctrl with a folder active and Auto Select on drags the folder past its box; a value left by a click in another field applies alone, one step), `MovePressFixtures.h` (the canvas fixture the three press files share), `TransformPressTests` (Swift's `commandFlipsAutoSelectWhileHeld` both ways and `commandShiftClickAddsALayerWithAutoSelectOn`, Ctrl outside the active layer, a fieLine truncated
- The Smear's 1.4.2–1.4.3 fixes (13.16, part one: `7164dd4`, `ad9ad7c`, `504129d`, and `095da2f`'s commit half): Liquify is Swift's new GPU stroke on the CPU, as the twin has no Metal: `WarpStroke` keeps the layer as it found it (`m_original`, a copy made in the constructor) and a source offset per pixel (`m_offsets`, two floats each, zero at first); each dab copies the offsets under it to `m_scratch`, reads them bilinearly from behind the brush's travel, less the travel times the weight, writes them back, and draws the pixel afresh from the untouched layer, bilinear, held to its edges, each byte rounded once: `warp_push` of `MetalWarp.swift`, float for float (bytes over 255, mixed, times 255). Swift allocates the two textures on the first push; the twin allocates them with the stroke, before any dab (nothing changes the copy between), so a failure is `ExportError(render)` where `beginWarp` catches it, where Swift's guard returns in silence; Smudge's carried colour, allocated at the first dab inside `beginWarp`, fails the same way, and a dab's scratch that finds no memory skips that dab with a warning on `omaphoto.rendering`, Swift's guard on its scratch texture (review 2: it ran outside any catch). The colour reads keep the shader's `size - 2` as they are: the offsets' read above needs two columns and rows of the dab's area (`ix`, `iy`), which a canvas a pixel wide or tall lacks, so no read leaves the image; the clamps first written here for such canvases were dead and are removed (review 2). Smudge is `ad9ad7c` in both of Swift's paths: dabs every 0.005 of the width (a pixel at least), each dab lays what it carries at weight × strength, and carries what it just left, nothing older. `BrushSettings.blurRadius` (5) is the Blur's own Radius: the sample's sigma follows the Radius (see part two for the merged formula). `BrushControls` shows Radius for the Blur mode alone, after Strength: a scrubbable label (a tenth a point, 0.5–50), a 100-point slider over 0.5–20 in hundredths (past 20 Qt rests it at its end, so Swift's `min(20, …)` is left out), and a 42-point `PickerField` with "px": a readable number applies on Return or leaving, clamped, a number that is not finite takes 5 (Swift's binding), other text goes back, arrows step one (Shift ten) from the session's value, clamped; it prints Swift's `fractionLength(0...1)`, never grouped. `BrushStroke::maxDiameter` is 2100, so `finishWarp`'s tip four wider than a 2000 brush is no longer refused; `finishWarp` appends a dab centre only every twentieth of the brush (the last always), as Swift's; the thinning is `WarpStroke::committedPoints`, so the test reads it directly (`aCommitKeepsATwentiethAndTheLastPoint`). Part two (`095da2f`'s Blur half, on 13.11's grid sampling, cc9bf95 merged into port-d): `BrushStroke::Clone` is 13.11's `(image, placed, inGrid)` plus `render`, a function that makes a part of `image` (Swift's `cloneRender`; Swift keeps it beside `clone`, the twin inside it, so one assignment sets both), and `clone` is private behind `clone()` and `setClone()`, which drops the pieces cut so far (Swift's two `didSet`s). `clonePixels(key, tile)` is Swift's `clonePiece`: the tile mapped into sample pixels through 13.11's chain (grid, `pixelToDocument` unless `inGrid`, then `placed` to the image's pixels, Swift's `scaleX` and `scaleY`), two pixels round, made whole outward (`toAlignedRect`, Swift's `integral`), held to the image; an empty part paints nothing on that tile (the fill is skipped, as Swift's `if let piece`); otherwise the piece is `render(part)` or a copy, cut once per tile key and kept in `m_clonePieces`, and the tile's sample is drawn from it through the same transform shifted by the piece's corner, so every path reads the pixels the whole image would give. A piece that cannot be allocated is `ExportError(render)`, thrown on the calling thread with `publish`'s other copies, where Swift's `cropping` returning nil paints nothing. `blurSample(stroke)` returns the sharp sample in the stroke's grid (13.11's `region`, `fit` and budget unchanged) with `render` blurring a part at `sigma × fit`, padded by the Gaussian's reach (`ceil(3σ)`) and cut back, clear past the sample for pixels (`QImage::copy` fills outside with zeros), its edges repeated for a mask (Swift's `clampedToExtent`), so a piece equals that part of the whole byte for byte (Swift allows one level). The Radius sigma is Swift's `min(min(50, max(0.5, blurRadius)) / perPixel, sidePixels / 2)`. Merge: port-c's direct `clone =` writes became `setClone` (`beginBrush`, `finishWarp`, `cloneSample`'s callers, the tests and `documentClone`), every `Clone` initialiser names `render` (GCC's `-Wmissing-field-initializers`), and port-c's Blur sample tests compare the sharp sample (`framed`) and its softening through `render` (`soft`), with each brush size's old sigma now set as the Radius. Equivalent mutants and untested paths for part two: a piece, a padded copy or a cut-back blur that cannot Line truncated
@@ -198,6 +198,7 @@ Platform adaptations, each forced by a missing Qt analog:
- Camera Raw's Shadows and Highlights (14.16, Swift 1.4.8: `0b4c56c`): `kernels/AdjustPixels.c` is 0b4c56c's, unchanged: each follows a bump, u(1 − 2u)², over its half of the tones (Shadows from black, Highlights from white), two and a half times the bump to lift Shadows or pull Highlights down and once the other way, so tones never swap, and black, middle gray and white stay. Tests: `CameraRawToneOrderTests` (Swift's ramp kept in order at ±100 and the dark tones lifted; every level of a gray ramp against the curve written apart, each way at full and partial amounts and both together, Highlights first).
- The Layers panel's gray (14.17, Swift 1.4.8: `06313a9`): `layersLookEditable` is Swift's: `canEditLayers` without what lasts only while the mouse is down (a stroke, a warp, a drag's transform edit, a pixel move) or a moment (`isProjectBusy`, read as `showsBusy`, which shows after 250 ms). The list's rows and their eyes (an effect's eye keeps `canEditLayers`, as Swift makes each effect row anew with it), the footer's buttons (new layer, folder, adjustments, delete with an active layer), the effects menu (with a layer that has pixels) and the mask button (one layer selected, without a mask) follow it, so nothing flashes gray for a click; every action still asks the session's own gate, which refuses meanwhile. The effects menu's folder term is left out as dead: a folder never holds pixels, so the pixel term refuses it. Tests: `LayersLookEditableTests` (each moment looks editable while the gate stays shut, each lasting state grays, the footer and list mid-stroke and under a pending transform, the effects menu's and mask button's own terms); the busy cases in `BlendModePickerTests`, `LayersPanelTests`, `NativeLayerListTests` and `LayerEffectRowTests` now wait for the indicator, and a click before it changes nothing.
- Export As (14.18, Swift 1.4.8: `9287cf1`, `450af5f`, `b1db93d`): `ProjectController::exportAs(start, done)` replaces `exportJPEG`; `ExportFormat` (png, jpeg, pdf, Swift's raw values through `rawValue`, `allExportFormats`) starts the sheet, else the last format exported this run (`m_lastExportFormat`, PNG at first), and the File menu's Export JPEG… opens it on JPEG; Export As… (Ctrl+Alt+Shift+W, a new shortcut definition) on the last. The flow is the JPEG one: the render in a worker, the sheet, then the format's panel (Export PNG, Export JPEG or Export PDF, suggesting `.png`, `.jpeg` or `.pdf`), the write in a worker, one alert titled "Couldn’t export" for a failure past the render. `UI/ExportAsSheet` (Swift's sheet, renamed from the JPEG one): titled Export As; the format as three checkable buttons; the size as two `PickerField`s keeping the proportions (a number applies on Return or leaving, else both fields show the size again; a scrub or menu replaces their typing) and a scale menu of 25, 50, 75, 100 and 200%, every scale held between a hundredth and the side and surface limits; Quality for JPEG alone (saved only from a JPEG export), the page size in inches at the document's DPI for PDF alone, the background swatch ("Export Background") for JPEG and PDF; "Transparency kept · sRGB" for PNG, else "sRGB"; a change of format or size asks again only when the settings change, as Swift's `task(id:)`. The encoding resizes first (`ImageExporter::resized`: smooth, the same raster at the same size, `tooLarge` past the limits) and encodes by format (`pngData`; `jpeg`; `pdfData` over `flattened` for the preview). `JPEGPreview` takes the export's size beside its image (`setImage(image, pixels, updating)`), so Fit and 100% follow the chosen size, and draws Swift 1.4.8's checkerboard (20-point tiles of grays 0.30 and 0.35, anchored at the image) behind what it shows. Swift's PDF comes from `CGContext`'s PDF writer; `QPdfWriter` rounds a page to whole points, so `ImageExporter::pdfData` writes PDF 1.4 by hand: one page whose MediaBox is the print size in points (72 / resolution a pixel, 72 when none), the background as a filled rectangle when given, the pixels as a Flate RGB image with a gray soft mask, straight colour (adaptation; checked against poppler's `pdfinfo` and `pdftoppm`). A Flate stream that `qCompress` cannot make (it returns nothing when memory runs out) is `ExportError(render)`, never an empty stream. A zoom set before the first image (Ctrl+1 while the sheet still encodes) centres that image when it lands, as Swift's preview, made with its first result, centres on appearing. The page's inches round as Swift's formatter: its shortest digits, halves to even (45 pixels at 72 DPI are 0.62 in). A size field reads Swift's `Int` field (`typedInteger`, checked against Swift 6.0.3): a sign, decimal digits of any script read by code point, a fraction and an exponent (its leading zeros dropped), the number truncated toward zero and exact within Int64 (`1e3` is 1000, `12.7` is 12, `-9.223372036854775808e18` is Int64's least, `1e-400` is 0, `128e` is 128), grouping refused; known difference: ICU's lenient parse also reads a leading number before other text (`7abc` is 7, `1,000` is 1, `--5` is −5), where the twin puts the size back; and ICU rounds some long fractions before Swift truncates them (`199.99999999999999` is 200, `0.99999999999999999` is 1, while `9007199254740993.5` stays …993 and `12.99999999999999` stays 12), a rule the measured cases do not pin down, so the twin truncates the digits as typed. Encoding finds no memory as `ExportError(render)` at `encode`'s door, so Export As's PNG worker never meets a `bad_alloc`. Equivalent mutant: the parser's `point <= 0` as `< 0` (a point at zero leaves no digits, which read as 0). Tests: `ExportPDFTests` (the page, its size, the straight pixels and alpha, rows and channels, the background, a refusal, resizing, flattening), `ExportPDFFailureTests` (a binary of its own: a 360-pixel PDF, small enough that Qt converts it on the calling thread (a pool thread that cannot start hangs Qt 6.4's conversion), under limits from 64 KB to 4 MB throws or inflates whole, both reached, and a PNG likewise throws or matches the unstarved file), `ExportAsSheetTests` (formats and their rows and bytes, the size and its limits, the checkerboard, the page against Swift 6.0.3's formatter), `JPEGPreviewTests::aZoomBeforeTheFirstImageCentresIt`, `ProjectExportTests` (each format through the controller, the menu's Export As on the last format), the entry in `CompositorMenusTests` and `KeyboardShortcutsTests` (its chord, group and place). Equivalent mutants: none found.
- Filter › Scanlines (14.19, Swift 1.4.9: `06c3a22`, `1187736`, `cdde327`): Scanlines leaves Dither for a filter of its own. `kernels/DitherPixels.c/.h` are `cdde327`'s, with 13.17's adaptation carried on: Swift's GCD blocks become static band functions with context structs (`scanlines_tone_band`, `line_scan_band`, `blur_band`, `spread_band`, `column_band`, `split_band`), each failure flag an `atomic_int`, every band through `dither_bands`; the arithmetic is Swift's word for word, and `dither_glow`'s new easing near white came with it. `ScanlinesTests::theKernelDrawsSwiftsOwnBytes` holds that: Swift's own file, built unchanged with clang's blocks and libdispatch in the `swift:6.0.3` image, drew five scenes (37 by 53 and 70 by 300, every setting, both directions of Displace, 32 bands of columns, lines and rows) and a glow, and the twin's bytes hash the same (FNV-1a). `Document/Scanlines.h/.cpp` twin Swift's file: `ScanlinesSettings` with Swift's fields, defaults (glow 0 now, thickness 70, smoothness 50) and clamps, Line Spacing and Color Split rounded, `apply` handing the kernel the normalized settings and Two Colors' bytes (black on white otherwise), then 13.17's `glowing` moved here unchanged. `DitherStyle` loses `scanlines` (`usesPixelSize` is ASCII alone, `drawsMarks` every style without tones) and `DitherSettings` its four fields. `FilterKind::scanlines` ("Scanlines") sits after Dither in Swift's order, so the Filter menu lists it there; it previews whole, as Dither does. Its two colours reuse Dither's picker (`openDitherColorPicker`, `setDitherColor`, Swift's), choosing the edit's own settings, and the titles drop "Dither" (`Color Picker (Light Color)`, `(Dark Color)`). The sheet's `scanlines` (`UI/FilterSheet+Scanlines.cpp`) is Swift's `scanlinesControls`: twelve rows with Swift's ranges, units and help, then the Colors menu and swatches, which `colorChoices` builds for Dither too (the screen's and the lines' colour help for Scanlines). Equivalent mutant: `glowing` run at glow 0 (it adds nothing). Tests: `ScanlinesTests` (Swift's eight, Swift's own bytes, each setting reaching its field, every clamp, clear pixels, the eased glow against a full-size blur, the menu place and a commit, the sheet's rows, ranges and fields, both pickers), the order in `FilterModelTests`, `FinishingFilterTests`, `DitherSheetTests` and `CompositorMenusTests`, the acceptance run's Scanlines row through the menu.
- Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to <title>"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
+2
View File
@@ -59,6 +59,7 @@ add_library(compositor_core STATIC
src/Document/ToolDefaults.cpp
src/Document/ObjectSelection.cpp
src/Document/Dither.cpp
src/Document/Scanlines.cpp
src/Document/Filters.cpp
src/Document/CameraRaw.cpp
src/Document/CameraRaw+Session.cpp
@@ -154,6 +155,7 @@ add_library(compositor_core STATIC
src/UI/FilterSheet+Adjustments.cpp
src/UI/FilterSheet+Controls.cpp
src/UI/FilterSheet+Dither.cpp
src/UI/FilterSheet+Scanlines.cpp
src/UI/FilterSheet+Finishing.cpp
src/UI/FloatingPanel.cpp
src/UI/KeyboardShortcuts.cpp
+1 -1
View File
@@ -13,7 +13,7 @@ It is a Linux port of [Compositor](https://github.com/robbietilton/Compositor) b
- Levels, Curves, Hue/Saturation, Exposure, Black & White, Color Balance, Gradient Map, Grain, Invert, blur and noise, as edits or as adjustment layers
- Camera Raw Filter, docked beside the canvas, with Photoshop's curves
- Layer effects: stroke, drop shadow, outer and inner glow, color overlay, inner shadow
- Filters: Gaussian and motion blur, noise, lens correction, Vignette, Bloom / Glow, Tonal Contrast, Dither, Content-Aware Fill and Remove Background (U²-Net on ONNX Runtime, offline)
- Filters: Gaussian and motion blur, noise, lens correction, Vignette, Bloom / Glow, Tonal Contrast, Dither, Scanlines, Content-Aware Fill and Remove Background (U²-Net on ONNX Runtime, offline)
- Last Filter (Ctrl+Meta+F) runs the last filter again with the same settings
- Crop, trim, canvas size, image size; PNG, JPEG and one-page PDF export, scaled if you like, with a zoomable preview (File › Export As…); New Canvas presets for screens and social formats
- Imports JPEG, PNG, TIFF, HEIC, SVG, Photoshop files (PSD and PSB) and camera RAW
+1 -1
View File
@@ -52,7 +52,7 @@ The screenshots come from Qt's offscreen platform, so floating panels appear wit
| Move tool keys and values | Held Ctrl flips Auto Select; a typed width applies as one step. | — | `TransformInspectorTests`, `ResizeSnapCanvasTests` | pass |
| Blending and a sharper canvas | Soft Light and a clipping stack in Linear Dodge show on the canvas as exported. | — | `SharpCanvasTests`, `BlendingTests`, `ClippingStackTests` | pass |
| Camera Raw curves | Lifts a region of the parametric curve and adds a point on the point curve, then OK; the ground brightens. | — | `CameraRawCurveTests`, `CameraRawCurveGraphTests` | pass |
| Dither ASCII and Scanlines | In the Dither sheet, ASCII of "@" draws that letter, holes and all, about twice as tall at a text size of 28; Scanlines draws dark gaps between rows. | — | `DitherTests`, `ScanlinesTests` | pass |
| Dither ASCII and Scanlines | In the Dither sheet, ASCII of "@" draws that letter, holes and all, about twice as tall at a text size of 28; Filter › Scanlines, a filter of its own since 1.4.9, draws dark gaps between rows. | — | `DitherTests`, `ScanlinesTests` | pass |
| Export JPEG preview | The View keys zoom the preview to 100%, in, and back to Fit. | — | `JPEGPreviewTests`, `ExportAsSheetTests` | pass |
| Quit mid-edit | Quits with a gradient pending: it is applied before the one question. With Levels open, Levels closes and the window goes. | — | `QuitPendingEditsTests`, `WindowTeardownTests` | pass |
+271 -81
View File
@@ -1,6 +1,7 @@
#include "DitherPixels.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
#include <stdatomic.h>
// Linux: GCD's dispatch_apply, run on QtConcurrent by Dither.cpp.
@@ -168,74 +169,6 @@ static void tone_band(void *context, size_t first, size_t last) {
}
}
typedef struct {
uint8_t *rgba; size_t stride, width, height, count; int planes; const uint8_t *alpha; const float *tone;
const DitherParams *p; size_t spacing; float middle, dots; const float *screen, *phosphor; atomic_int failed;
} scan_job;
static void scan_band(void *context, size_t firstLine, size_t lastLine) {
scan_job *job = context;
uint8_t *rgba = job->rgba; size_t stride = job->stride, width = job->width, height = job->height, count = job->count;
int planes = job->planes; const uint8_t *alpha = job->alpha; const float *tone = job->tone; const DitherParams *p = job->p;
size_t spacing = job->spacing; float middle = job->middle, dots = job->dots;
const float *screen = job->screen, *phosphor = job->phosphor;
float *scan = malloc(width * sizeof(float) * (size_t)planes);
if (!scan) { atomic_store(&job->failed, 1); return; }
for (size_t line = firstLine; line < lastLine; ++line) {
size_t top = line * spacing;
size_t bottom = top + spacing < height ? top + spacing : height;
// Wobble: each line is pushed sideways, a slow wave down the screen with a quicker one over it, as a
// CRT's picture wavers when its sync drifts.
float wave = sinf((float)line * 0.45f) * 0.7f + sinf((float)line * 1.7f + 1.3f) * 0.3f;
long shift = lroundf(p->wobble * wave);
for (size_t x = 0; x < width; ++x) {
float sum[3] = { 0, 0, 0 }; int n = 0;
long sx = (long)x - shift;
if (sx >= 0 && sx < (long)width)
for (size_t y = top; y < bottom; ++y) {
size_t at = y * width + (size_t)sx;
if (!alpha[at]) continue;
for (int c = 0; c < planes; ++c) sum[c] += tone[(size_t)c * count + at];
++n;
}
for (int c = 0; c < planes; ++c) scan[(size_t)c * width + x] = n ? sum[c] / (float)n : 0;
}
for (size_t y = top; y < bottom; ++y) {
uint8_t *row = rgba + y * stride;
float offset = fabsf((float)(y - top) + 0.5f - middle);
for (size_t x = 0; x < width; ++x) {
if (!alpha[y * width + x]) continue;
// Dots: the line breaks into beads, one every line spacing, each lit in the color at its middle.
float along = fmodf((float)x + 0.5f, (float)spacing) - middle;
long centered = lroundf((float)x - along * dots);
size_t at = centered < 0 ? 0 : (size_t)centered >= width ? width - 1 : (size_t)centered;
float r, g, b, t;
if (p->originalColors) {
r = scan[at]; g = scan[width + at]; b = scan[2 * width + at];
t = 0.2126f * r + 0.7152f * g + 0.0722f * b;
} else {
t = scan[at];
r = screen[0] + (phosphor[0] - screen[0]) * t;
g = screen[1] + (phosphor[1] - screen[1]) * t;
b = screen[2] + (phosphor[2] - screen[2]) * t;
}
// The beam is driven brighter than the picture, making up for the dark screen between lines.
r *= 1.35f; g *= 1.35f; b *= 1.35f;
// Half the beam's height: a thin line in the shadows, most of the way across in the highlights,
// always leaving dark screen between lines.
float beam = middle * (0.2f + 0.5f * sqrtf(clamp01(t)));
float across = along * dots, distance = sqrtf(offset * offset + across * across);
float cover = clamp01(beam - distance + 0.5f);
// Between the lines, the screen: black in Original, else the dark color.
float br = p->originalColors ? 0 : screen[0], bg = p->originalColors ? 0 : screen[1];
float bb = p->originalColors ? 0 : screen[2];
write_pixel(row + x * 4, br + (r - br) * cover, bg + (g - bg) * cover, bb + (b - bb) * cover);
}
}
}
free(scan);
}
int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const DitherParams *p) {
size_t count = width * height;
if (!count) return 1;
@@ -286,18 +219,6 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons
}
}
}
} else if (style == DITHER_SCANLINES) {
// A CRT: each line scans the image, its tone along the line the average of the rows it covers. The beam glows
// brighter and blooms thicker where the picture is light, and the screen between lines stays dark.
size_t spacing = (size_t)(p->cell < 2 ? 2 : p->cell);
float middle = (float)spacing / 2, dots = clamp01(p->dots);
// Each line is drawn on its own, so the lines are shared out across the cores.
size_t lines = (height + spacing - 1) / spacing;
const float *screen = dark, *phosphor = light;
scan_job scanning = { rgba, stride, width, height, count, planes, alpha, tone, p, spacing, middle, dots, screen, phosphor, 0 };
in_bands(lines, scan_band, &scanning);
int failed = atomic_load(&scanning.failed);
if (failed) { free(tone); free(alpha); free(source); return 0; }
} else {
// Marks (halftone shapes, patterns, glyphs) cover as much of each spot as the tone calls for. On light, they
// stand for darkness and are drawn in the dark color; light on dark, the reverse.
@@ -405,7 +326,10 @@ static void glow_band(void *context, size_t first, size_t last) {
for (size_t x = 0; x < width * 4; x += 4) {
float a = row[x + 3];
for (int c = 0; c < 3; ++c) {
float v = (float)row[x + c] + (float)light[x + c] * amount * a / 255.0f;
// The light eases in as the pixel nears full brightness rather than clipping there, so where the
// picture is bright the gaps between lines glow without filling up to the lines.
float v = row[x + c], added = (float)light[x + c] * amount * a / 255.0f, room = (a - v) * 0.7f;
if (room > 0) v += room * (1 - expf(-added / room));
row[x + c] = (uint8_t)lroundf(v > a ? a : v);
}
}
@@ -451,3 +375,269 @@ void dither_quantize16(const uint16_t *wide, uint8_t *rgba, size_t width, size_t
quantize_job quantizing = { wide, rgba, width, stride };
in_bands(height, quantize_band, &quantizing);
}
typedef struct { float *lift; size_t width; long radius; atomic_int failed; } blur_job;
static void blur_band(void *context, size_t firstLine, size_t lastLine) {
blur_job *job = context;
float *lift = job->lift; size_t width = job->width; long radius = job->radius;
float *copy = malloc(width * sizeof(float));
if (!copy) { atomic_store(&job->failed, 1); return; }
for (size_t line = firstLine; line < lastLine; ++line) {
float *row = lift + line * width;
for (int pass = 0; pass < 3; ++pass) {
memcpy(copy, row, width * sizeof(float));
float sum = 0;
for (long x = -radius; x <= radius; ++x) sum += copy[x < 0 ? 0 : x >= (long)width ? width - 1 : (size_t)x];
for (size_t x = 0; x < width; ++x) {
row[x] = sum / (float)(2 * radius + 1);
long out = (long)x - radius, in = (long)x + radius + 1;
sum += copy[in >= (long)width ? width - 1 : (size_t)in] - copy[out < 0 ? 0 : (size_t)out];
}
}
}
free(copy);
}
typedef struct { const float *lift; float *spread; size_t width, lines; } spread_job;
static void spread_band(void *context, size_t firstLine, size_t lastLine) {
const spread_job *job = context;
const float *lift = job->lift; float *spread = job->spread; size_t width = job->width, lines = job->lines;
for (size_t line = firstLine; line < lastLine; ++line) {
const float *above = lift + (line ? line - 1 : 0) * width, *here = lift + line * width;
const float *below = lift + (line + 1 < lines ? line + 1 : line) * width;
for (size_t x = 0; x < width; ++x) spread[line * width + x] = (above[x] + 2 * here[x] + below[x]) / 4;
}
}
typedef struct {
uint8_t *rgba; size_t width, height, stride, lines, spacing, plane; const float *scan, *tone, *lift; const uint8_t *alpha;
int original, rising; float middle, dots, threshold, displace, thickness, blackLevel; const float *screen, *phosphor;
atomic_int failed;
} column_job;
static void column_band(void *context, size_t first, size_t last) {
column_job *job = context;
uint8_t *rgba = job->rgba; size_t width = job->width, height = job->height, stride = job->stride, lines = job->lines;
size_t spacing = job->spacing, plane = job->plane; const float *scan = job->scan, *tone = job->tone, *lift = job->lift;
const uint8_t *alpha = job->alpha; int original = job->original, rising = job->rising;
float middle = job->middle, dots = job->dots, threshold = job->threshold, displace = job->displace;
float thickness = job->thickness, blackLevel = job->blackLevel; const float *screen = job->screen, *phosphor = job->phosphor;
float *cover = malloc(height * sizeof(float) * 4);
if (!cover) { atomic_store(&job->failed, 1); return; }
float *color = cover + height;
for (size_t x = first; x < last; ++x) {
memset(cover, 0, height * sizeof(float));
float along = fmodf((float)x + 0.5f, (float)spacing) - middle;
// The edge of everything drawn so far in this column; lines further back show only beyond it.
float horizon = rising ? INFINITY : -INFINITY;
for (size_t step = 0; step < lines; ++step) {
size_t line = rising ? lines - 1 - step : step;
// Dots: darker than the Dots level, the line breaks into beads, one every line spacing, each lit in the
// tone at its middle; just above it, into dashes that close up into the solid line.
float beading = dots > 0 ? clamp01((dots - tone[line * width + x]) / 0.2f) : 0, across = along * beading;
long centered = lroundf((float)x - across);
size_t at = centered < 0 ? 0 : (size_t)centered >= width ? width - 1 : (size_t)centered;
size_t i = line * width + at;
float t = tone[i];
float lit = threshold > 0 ? clamp01((t - threshold) / 0.04f) : 1;
if (lit <= 0) continue;
// Drawn between this column's height and the last's, so a steep climb never breaks the line.
float base = (float)line * (float)spacing + middle;
float here = base - displace * lift[i], before = at > 0 ? base - displace * lift[i - 1] : here;
float lo = fminf(here, before), hi = fmaxf(here, before);
// Black Level: the line's least brightness, so it still shows where the picture is black.
float level = blackLevel + (1 - blackLevel) * t, c[3];
for (int k = 0; k < 3; ++k)
c[k] = original ? blackLevel + (1 - blackLevel) * scan[(size_t)k * plane + i] : screen[k] + (phosphor[k] - screen[k]) * level;
// Half the line's height: thinner where the picture is dim.
float beam = middle * thickness * (0.29f + 0.71f * sqrtf(clamp01(level)));
long top = (long)floorf(rising ? lo - beam - 1 : fmaxf(lo - beam - 1, horizon - 1));
long bottom = (long)ceilf(rising ? fminf(hi + beam + 1, horizon + 1) : hi + beam + 1);
if (top < 0) top = 0;
if (bottom > (long)height) bottom = (long)height;
for (long y = top; y < bottom; ++y) {
float yy = (float)y + 0.5f;
float off = yy < lo ? lo - yy : yy > hi ? yy - hi : 0;
float distance = sqrtf(off * off + across * across);
float hidden = rising ? clamp01(horizon - yy + 0.5f) : clamp01(yy - horizon + 0.5f);
float shown = clamp01(beam - distance + 0.5f) * lit * hidden;
if (shown > cover[y]) {
cover[y] = shown;
color[y * 3] = c[0]; color[y * 3 + 1] = c[1]; color[y * 3 + 2] = c[2];
}
}
if (lit > 0.5f) horizon = rising ? fminf(horizon, lo - beam) : fmaxf(horizon, hi + beam);
}
for (size_t y = 0; y < height; ++y) {
if (!alpha[y * width + x]) continue;
// The beam is driven brighter than the picture, making up for the dark screen between lines.
float shown = cover[y];
write_pixel(rgba + y * stride + x * 4, screen[0] + (color[y * 3] * 1.35f - screen[0]) * shown,
screen[1] + (color[y * 3 + 1] * 1.35f - screen[1]) * shown,
screen[2] + (color[y * 3 + 2] * 1.35f - screen[2]) * shown);
}
}
free(cover);
}
// Draws the lines over the scanned tones, a column at a time. Displaced, they're a landscape seen from the front, after
// the Rutt-Etra video synthesizer: each line is lifted (or, displaced the other way, lowered) by the picture's
// brightness smoothed into hills, and hides whatever lies behind it, so nearer lines wrap over the shapes rather than
// crossing the ones beyond. Nearest line first: the bottom one when lines rise, the top one when they fall.
static int draw_lines(uint8_t *rgba, size_t width, size_t height, size_t stride, const ScanlinesParams *p,
const float *scan, const uint8_t *alpha, size_t lines, size_t spacing) {
int original = p->originalColors;
size_t plane = lines * width;
float *tone = malloc(plane * sizeof(float)), *lift = malloc(plane * sizeof(float)), *spread = malloc(plane * sizeof(float));
if (!tone || !lift || !spread) { free(tone); free(lift); free(spread); return 0; }
for (size_t at = 0; at < plane; ++at)
tone[at] = original ? 0.2126f * scan[at] + 0.7152f * scan[plane + at] + 0.0722f * scan[2 * plane + at] : scan[at];
// The height: brightness blurred along each line (three box passes, close to a Gaussian) and then across its
// neighbors, so a face rises as one rounded hill instead of a staircase of the pixels under it.
float smoothness = clamp01(p->smoothness);
long radius = lroundf(smoothness * (float)spacing * 2);
memcpy(lift, tone, plane * sizeof(float));
if (radius > 0 && p->displace != 0) {
blur_job blurring = { lift, width, radius, 0 };
in_bands(lines, blur_band, &blurring);
int failed = atomic_load(&blurring.failed);
if (failed) { free(tone); free(lift); free(spread); return 0; }
long across = lroundf(smoothness * 2);
for (long step = 0; step < across; ++step) {
spread_job spreading = { lift, spread, width, lines };
in_bands(lines, spread_band, &spreading);
memcpy(lift, spread, plane * sizeof(float));
}
}
free(spread);
float middle = (float)spacing / 2, dots = clamp01(p->dots), threshold = clamp01(p->threshold), displace = p->displace;
float thickness = clamp01(p->thickness), blackLevel = clamp01(p->blackLevel);
int rising = displace >= 0;
float dark[3] = { p->dark[0] / 255.0f, p->dark[1] / 255.0f, p->dark[2] / 255.0f };
float light[3] = { p->light[0] / 255.0f, p->light[1] / 255.0f, p->light[2] / 255.0f };
if (original) dark[0] = dark[1] = dark[2] = 0;
const float *screen = dark, *phosphor = light;
column_job drawing = { rgba, width, height, stride, lines, spacing, plane, scan, tone, lift, alpha, original, rising, middle,
dots, threshold, displace, thickness, blackLevel, screen, phosphor, 0 };
in_bands(width, column_band, &drawing);
int failed = atomic_load(&drawing.failed);
free(tone); free(lift);
return !failed;
}
typedef struct {
const uint8_t *rgba; size_t stride, width, count; uint8_t *alpha; float *tone; float gamma, contrast; int original;
} scanlines_tone_job;
static void scanlines_tone_band(void *context, size_t first, size_t last) {
const scanlines_tone_job *job = context;
const uint8_t *rgba = job->rgba; size_t stride = job->stride, width = job->width, count = job->count;
uint8_t *alpha = job->alpha; float *tone = job->tone; float gamma = job->gamma, contrast = job->contrast;
int original = job->original;
for (size_t y = first; y < last; ++y) {
const uint8_t *row = rgba + y * stride;
for (size_t x = 0; x < width; ++x) {
const uint8_t *px = row + x * 4;
size_t at = y * width + x;
alpha[at] = px[3];
float r = 0, g = 0, b = 0;
if (px[3]) { float scale = 1.0f / (float)px[3]; r = px[0] * scale; g = px[1] * scale; b = px[2] * scale; }
if (original) {
tone[at] = adjust_tone(r, gamma, contrast);
tone[count + at] = adjust_tone(g, gamma, contrast);
tone[2 * count + at] = adjust_tone(b, gamma, contrast);
} else {
tone[at] = adjust_tone(0.2126f * r + 0.7152f * g + 0.0722f * b, gamma, contrast);
}
}
}
}
typedef struct {
size_t width, height, count, spacing, lines; int planes; const uint8_t *alpha; const float *tone; float *scan; float wobble;
} line_scan_job;
static void line_scan_band(void *context, size_t firstLine, size_t lastLine) {
const line_scan_job *job = context;
size_t width = job->width, height = job->height, count = job->count, spacing = job->spacing, lines = job->lines;
int planes = job->planes; const uint8_t *alpha = job->alpha; const float *tone = job->tone; float *scan = job->scan;
float wobble = job->wobble;
for (size_t line = firstLine; line < lastLine; ++line) {
size_t top = line * spacing, bottom = top + spacing < height ? top + spacing : height;
// A slow wave down the screen with a quicker one over it, as a CRT's picture wavers when its sync drifts.
float wave = sinf((float)line * 0.45f) * 0.7f + sinf((float)line * 1.7f + 1.3f) * 0.3f;
long shift = lroundf(wobble * wave);
for (size_t x = 0; x < width; ++x) {
float sum[3] = { 0, 0, 0 }; int n = 0;
long sx = (long)x - shift;
if (sx >= 0 && sx < (long)width)
for (size_t y = top; y < bottom; ++y) {
size_t at = y * width + (size_t)sx;
if (!alpha[at]) continue;
for (int c = 0; c < planes; ++c) sum[c] += tone[(size_t)c * count + at];
++n;
}
for (int c = 0; c < planes; ++c) scan[((size_t)c * lines + line) * width + x] = n ? sum[c] / (float)n : 0;
}
}
}
typedef struct { uint8_t *rgba; size_t stride, width; long split; atomic_int failed; } split_job;
static void split_band(void *context, size_t first, size_t last) {
split_job *job = context;
uint8_t *rgba = job->rgba; size_t stride = job->stride, width = job->width; long split = job->split;
uint8_t *copy = malloc(width * 4);
if (!copy) { atomic_store(&job->failed, 1); return; }
for (size_t y = first; y < last; ++y) {
uint8_t *row = rgba + y * stride;
memcpy(copy, row, width * 4);
for (size_t x = 0; x < width; ++x) {
long from = (long)x - split, to = (long)x + split;
uint8_t a = row[x * 4 + 3];
uint8_t r = from >= 0 ? copy[from * 4] : 0, b = to < (long)width ? copy[to * 4 + 2] : 0;
row[x * 4] = r > a ? a : r;
row[x * 4 + 2] = b > a ? a : b;
}
}
free(copy);
}
int scanlines_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const ScanlinesParams *p) {
size_t count = width * height;
if (!count) return 1;
size_t spacing = (size_t)(p->spacing < 2 ? 2 : p->spacing);
size_t lines = (height + spacing - 1) / spacing;
int planes = p->originalColors ? 3 : 1;
float *tone = malloc(count * sizeof(float) * (size_t)planes);
uint8_t *alpha = malloc(count);
// Each line's tone along it: the average of the rows it covers, sampled where its wobble moves it from.
float *scan = malloc(lines * width * sizeof(float) * (size_t)planes);
if (!tone || !alpha || !scan) { free(tone); free(alpha); free(scan); return 0; }
float gamma = exp2f(p->density * 1.5f);
float contrast = p->contrast >= 0 ? 1.0f / (1.0f - 0.95f * p->contrast) : 1.0f + p->contrast;
int original = p->originalColors;
scanlines_tone_job toning = { rgba, stride, width, count, alpha, tone, gamma, contrast, original };
in_bands(height, scanlines_tone_band, &toning);
float wobble = p->wobble;
line_scan_job scanning = { width, height, count, spacing, lines, planes, alpha, tone, scan, wobble };
in_bands(lines, line_scan_band, &scanning);
int drawn = draw_lines(rgba, width, height, stride, p, scan, alpha, lines, spacing);
free(tone); free(alpha); free(scan);
if (!drawn) return 0;
// Color split: red moved one way and blue the other, for colored fringes on the lines' edges.
long split = lroundf(p->split);
if (split > 0) {
split_job splitting = { rgba, stride, width, split, 0 };
in_bands(height, split_band, &splitting);
int failed = atomic_load(&splitting.failed);
if (failed) return 0;
}
return 1;
}
+37 -7
View File
@@ -8,7 +8,7 @@ enum {
DITHER_ATKINSON, DITHER_FLOYD_STEINBERG,
DITHER_BAYER_2, DITHER_BAYER_4, DITHER_BAYER_8,
DITHER_DOTS, DITHER_LINES, DITHER_DIAMONDS,
DITHER_PATTERNS, DITHER_GLYPHS, DITHER_SCANLINES
DITHER_PATTERNS, DITHER_GLYPHS
};
typedef struct {
@@ -20,8 +20,7 @@ typedef struct {
// −1…1: darker (more ink) or lighter, and flatter or punchier, before dithering.
float density;
float contrast;
// Halftone and glyph cells, in pixels (scanlines: the distance between lines), and the halftone screen's angle in
// radians.
// Halftone and glyph cells, in pixels, and the halftone screen's angle in radians.
int cell;
float angle;
// Halftone dots, patterns and glyphs mark the light tones on the dark color instead of the dark on the light.
@@ -38,18 +37,49 @@ typedef struct {
const uint8_t *glyphs;
const float *glyphCoverage;
int glyphCount;
// Scanlines: how far each line breaks into round dots (0–1), and how many pixels its wobble pushes it sideways.
float dots;
float wobble;
} DitherParams;
// Filter › Scanlines: the picture as a CRT draws it, in lines of light on a dark screen.
typedef struct {
// Pixels from one line to the next.
int spacing;
// How much of the gap a full-bright line fills, 0–1; dimmer parts draw it thinner.
float thickness;
// 0–1: tones darker than this break the lines into round dots, blending through dashes into the solid line above it.
float dots;
// Pixels a line wavers sideways, in a wave down the screen.
float wobble;
// Pixels a line rises where the picture under it is bright (falls, if negative), so lines swell into its shapes.
float displace;
// 0–1: tones darker than this draw no line at all.
float threshold;
// Pixels the red and blue are moved apart, either way, for colored fringes.
float split;
// −1…1: darker or lighter, and flatter or punchier, before the lines are drawn.
float density;
float contrast;
// 0–1: how bright the lines are where the picture is black.
float blackLevel;
// 0–1: how far the brightness is smoothed, over a few line spacings, before it displaces the lines.
float smoothness;
// 0: lines of `light` on `dark` (straight sRGB), brighter where the picture is. 1: lines in the picture's colors.
int originalColors;
uint8_t dark[3];
uint8_t light[3];
} ScanlinesParams;
// Draws premultiplied RGBA pixels (4 bytes per pixel, `stride` bytes per row) as scanlines, in place. Alpha is kept and
// fully transparent pixels are left alone. Returns 0 if working memory couldn't be had.
int scanlines_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const ScanlinesParams *params);
// Dithers premultiplied RGBA pixels (4 bytes per pixel, `stride` bytes per row) in place. Alpha is kept and fully
// transparent pixels are left alone. Returns 0 if working memory couldn't be had.
int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const DitherParams *params);
// Turns each `block` × `block` square of premultiplied RGBA pixels into a round dot in its own color on `gap` (straight
// sRGB), like the lit pixels of a dot-matrix screen. The dot's edge is smoothed and alpha is kept.
void dither_dots(uint8_t *rgba, size_t width, size_t height, size_t stride, int block, const uint8_t *gap);
// Adds `glow` (premultiplied RGBA, same layout) over the pixels at `amount`, never past their own alpha.
// Adds `glow` (premultiplied RGBA, same layout) over the pixels at `amount`, easing off as they near full brightness
// and never past their own alpha.
void dither_glow(uint8_t *rgba, const uint8_t *glow, size_t width, size_t height, size_t stride, float amount);
// Rounds 16-bit premultiplied RGBA (`width` × `height`, tightly packed) to 8 bits into `rgba` (`stride` bytes per
// row), adding noise of about one 8-bit step first so smooth gradients don't come out in steps. The noise is fixed per
+10 -5
View File
@@ -102,7 +102,7 @@ QString ColorPickerTarget::title() const
if (kind == Kind::vignette)
return QStringLiteral("Color Picker (Vignette Color)");
if (kind == Kind::dither)
return light ? QStringLiteral("Color Picker (Dither Light Color)") : QStringLiteral("Color Picker (Dither Dark Color)");
return light ? QStringLiteral("Color Picker (Light Color)") : QStringLiteral("Color Picker (Dark Color)");
if (kind == Kind::dialog)
return QStringLiteral("Color Picker (%1)").arg(dialogTitle);
return background ? QStringLiteral("Color Picker (Background Color)") : QStringLiteral("Color Picker (Foreground Color)");
@@ -288,9 +288,11 @@ void EditorSession::openVignetteColorPicker()
void EditorSession::openDitherColorPicker(bool light)
{
// A commit keeps the project busy: no `committing` term.
if (!canEditPalette() || m_colorPicker || !m_filterEdit || m_filterEdit->kind != FilterKind::dither)
if (!canEditPalette() || m_colorPicker || !m_filterEdit || (m_filterEdit->kind != FilterKind::dither && m_filterEdit->kind != FilterKind::scanlines))
return;
const AdjustmentColor value = light ? m_filterEdit->settings.dither.light : m_filterEdit->settings.dither.dark;
const FilterSettings &settings = m_filterEdit->settings;
const bool scanlines = m_filterEdit->kind == FilterKind::scanlines;
const AdjustmentColor value = light ? (scanlines ? settings.scanlines.light : settings.dither.light) : (scanlines ? settings.scanlines.dark : settings.dither.dark);
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::dither, false, std::nullopt, false, LayerEffectKind{}, light},
PaletteColor{value.red, value.green, value.blue});
notify();
@@ -302,11 +304,14 @@ void EditorSession::previewDitherColor()
setDitherColor(m_colorPicker->color(), m_colorPicker->target.light);
}
// Its picker lives only while its Dither edit is open.
// Its picker lives only while Dither or Scanlines is open.
void EditorSession::setDitherColor(const PaletteColor &color, bool light)
{
FilterSettings settings = m_filterEdit.value().settings;
(light ? settings.dither.light : settings.dither.dark) = AdjustmentColor(color);
if (m_filterEdit->kind == FilterKind::scanlines)
(light ? settings.scanlines.light : settings.scanlines.dark) = AdjustmentColor(color);
else
(light ? settings.dither.light : settings.dither.dark) = AdjustmentColor(color);
if (settings == m_filterEdit->settings)
return;
updateFilter(settings, m_filterEdit->preview);
+1 -1
View File
@@ -47,7 +47,7 @@ struct ColorPickerTarget {
bool highlights = false;
// An effect's colour: which effect.
LayerEffectKind effect{};
// Dither's Two Colors: the light one, else the dark.
// Dither's or Scanlines' Two Colors: light, else dark.
bool light = false;
// A dialog's own colour, such as Export JPEG's background.
QString dialogTitle{};
+5 -48
View File
@@ -64,7 +64,6 @@ QString rawValue(DitherStyle style)
case DitherStyle::diamonds: return QStringLiteral("Halftone Diamonds");
case DitherStyle::patterns: return QStringLiteral("Mac Patterns");
case DitherStyle::ascii: return QStringLiteral("ASCII");
case DitherStyle::scanlines: return QStringLiteral("Scanlines (CRT)");
}
throw std::logic_error("unknown dither style");
}
@@ -92,12 +91,12 @@ bool isHalftone(DitherStyle style)
bool drawsMarks(DitherStyle style)
{
return !hasTones(style) && style != DitherStyle::scanlines;
return !hasTones(style);
}
bool usesPixelSize(DitherStyle style)
{
return style != DitherStyle::ascii && style != DitherStyle::scanlines;
return style != DitherStyle::ascii;
}
QString rawValue(DitherPixelShape shape)
@@ -122,10 +121,6 @@ DitherSettings DitherSettings::normalized() const
result.pixelSize = std::round(clamp(pixelSize, pixelSizeLow, pixelSizeHigh, 2));
result.cellSize = std::round(clamp(cellSize, cellSizeLow, cellSizeHigh, 8));
result.textSize = std::round(clamp(textSize, textSizeLow, textSizeHigh, 14));
result.lineSpacing = std::round(clamp(lineSpacing, lineSpacingLow, lineSpacingHigh, 4));
result.glow = clamp(glow, 0, 100, 35);
result.dots = clamp(dots, 0, 100, 0);
result.wobble = clamp(wobble, wobbleLow, wobbleHigh, 0);
result.angle = clamp(angle, -90, 90, 45);
result.levels = std::round(clamp(levels, levelsLow, levelsHigh, 2));
result.diffusion = clamp(diffusion, 0, 100, 100);
@@ -146,7 +141,7 @@ DitherSettings DitherSettings::normalized() const
QImage DitherSettings::apply(const QImage &image) const
{
const DitherSettings settings = normalized();
// ASCII and Scanlines draw full size: chunks would blur them.
// ASCII draws full size: chunks would break its characters.
const int block = usesPixelSize(settings.style) ? int(settings.pixelSize) : 1;
const QImage source = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
if (source.isNull())
@@ -174,8 +169,6 @@ QImage DitherSettings::apply(const QImage &image) const
}
}
const QImage dithered = settings.dither(working);
if (settings.style == DitherStyle::scanlines && settings.glow > 0)
return settings.glowing(dithered);
if (block <= 1)
return dithered;
// Blown back up without smoothing.
@@ -194,44 +187,9 @@ QImage DitherSettings::apply(const QImage &image) const
return full;
}
// The lines' light blurred wide and added back: phosphor bloom.
QImage DitherSettings::glowing(const QImage &image) const
{
// Blurred small, scaled back up: same light, less work.
const double sigma = lineSpacing * 3 + 3, shrink = std::max(1.0, std::floor(sigma / 4));
const int width = image.width(), height = image.height();
// Swift clamps first: edges repeat outward before shrinking.
const int step = int(shrink), pad = int(std::ceil(sigma * 3 / shrink)) * step;
// Far sides fill whole steps: both scales are exact.
const auto whole = [step](int side) { return (side + step - 1) / step * step; };
QImage padded = BrushRaster::context(whole(width + 2 * pad), whole(height + 2 * pad), false);
for (int y = 0; y < padded.height(); ++y) {
const uchar *source = image.constScanLine(std::clamp(y - pad, 0, height - 1));
uchar *out = padded.scanLine(y);
for (int x = 0; x < padded.width(); ++x)
std::copy_n(source + std::clamp(x - pad, 0, width - 1) * 4, 4, out + x * 4);
}
const int smallWidth = padded.width() / step, smallHeight = padded.height() / step;
const QImage small = padded.scaled(smallWidth, smallHeight, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
if (small.isNull())
throw ExportError(ExportError::Kind::render);
const QImage blurred = PixelAdjust::gaussianBlur(small, sigma / shrink, true)
.scaled(smallWidth * step, smallHeight * step, Qt::IgnoreAspectRatio, Qt::SmoothTransformation)
.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
QImage result = image.copy();
if (blurred.isNull() || result.isNull())
throw ExportError(ExportError::Kind::render);
// The scaled light's own rows, cropped to the image.
QImage bloom = BrushRaster::context(width, height, false);
for (int y = 0; y < height; ++y)
std::copy_n(blurred.constScanLine(y + pad) + pad * 4, width * 4, bloom.scanLine(y));
dither_glow(result.bits(), bloom.constBits(), size_t(width), size_t(height), size_t(result.bytesPerLine()), float(glow / 100 * 2.5));
return result;
}
QImage DitherSettings::dither(const QImage &image) const
{
const int cell = int(style == DitherStyle::scanlines ? lineSpacing : cellSize);
const int cell = int(cellSize);
const Glyphs glyphs = style == DitherStyle::ascii ? DitherSettings::glyphs(characters.isEmpty() ? defaultCharacters() : characters, int(textSize)) : Glyphs();
const bool two = colors == DitherColors::twoColors;
const std::array<uint8_t, 3> darkColor = two ? bytes(dark) : std::array<uint8_t, 3>{0, 0, 0};
@@ -242,8 +200,7 @@ QImage DitherSettings::dither(const QImage &image) const
.contrast = float(contrast / 100), .cell = cell, .angle = float(angle * M_PI / 180), .lightOnDark = lightOnDark ? 1 : 0,
.originalColors = colors == DitherColors::original ? 1 : 0, .dark = {darkColor[0], darkColor[1], darkColor[2]},
.light = {lightColor[0], lightColor[1], lightColor[2]}, .glyphWidth = glyphs.width, .glyphHeight = glyphs.height, .glyphs = glyphs.maps.data(),
.glyphCoverage = glyphs.coverage.data(), .glyphCount = int(glyphs.coverage.size()), .dots = float(dots / 100),
.wobble = float(wobble)};
.glyphCoverage = glyphs.coverage.data(), .glyphCount = int(glyphs.coverage.size())};
failed = dither_apply(pixels, size_t(width), size_t(height), size_t(stride), &params) == 0;
});
if (failed)
+3 -12
View File
@@ -6,10 +6,10 @@
#include <vector>
// Filter › Dither's looks, in the panel's order and DitherPixels.h's.
enum class DitherStyle { atkinson, floydSteinberg, bayer2, bayer4, bayer8, dots, lines, diamonds, patterns, ascii, scanlines };
enum class DitherStyle { atkinson, floydSteinberg, bayer2, bayer4, bayer8, dots, lines, diamonds, patterns, ascii };
inline constexpr std::array allDitherStyles{DitherStyle::atkinson, DitherStyle::floydSteinberg, DitherStyle::bayer2, DitherStyle::bayer4,
DitherStyle::bayer8, DitherStyle::dots, DitherStyle::lines, DitherStyle::diamonds,
DitherStyle::patterns, DitherStyle::ascii, DitherStyle::scanlines};
DitherStyle::patterns, DitherStyle::ascii};
QString rawValue(DitherStyle style);
// Swift's groups: diffusion, ordered, halftone, marks.
int ditherGroup(DitherStyle style);
@@ -19,7 +19,7 @@ bool diffuses(DitherStyle style);
bool hasTones(DitherStyle style);
bool isHalftone(DitherStyle style);
bool drawsMarks(DitherStyle style);
// ASCII and Scanlines draw at full resolution, not chunky.
// ASCII draws at full resolution, not chunky.
bool usesPixelSize(DitherStyle style);
// A chunky pixel: a square, or a dot on dark.
@@ -37,8 +37,6 @@ struct DitherSettings {
static constexpr double cellSizeLow = 4, cellSizeHigh = 64;
static constexpr double textSizeLow = 6, textSizeHigh = 64;
static constexpr double levelsLow = 2, levelsHigh = 8;
static constexpr double lineSpacingLow = 2, lineSpacingHigh = 32;
static constexpr double wobbleLow = 0, wobbleHigh = 64;
static QString defaultCharacters() { return QStringLiteral(" .:-=+*#%@"); }
DitherStyle style = DitherStyle::atkinson;
// Each dithered pixel covers this many layer pixels a side.
@@ -48,12 +46,6 @@ struct DitherSettings {
double cellSize = 8;
// ASCII's line height in layer pixels; letters about 0.6 wide.
double textSize = 14;
// Scanlines: the lines' distance apart, in layer pixels.
double lineSpacing = 4;
// Glow and dots 0–100%; wobble sideways in pixels.
double glow = 35;
double dots = 0;
double wobble = 0;
// The halftone screen's angle, in degrees.
double angle = 45;
// Tones per channel; 2 is 1-bit.
@@ -86,5 +78,4 @@ struct DitherSettings {
private:
QImage dither(const QImage &image) const;
QImage glowing(const QImage &image) const;
};
+6 -2
View File
@@ -28,6 +28,7 @@ QString rawValue(FilterKind kind)
case FilterKind::vignette: return QStringLiteral("Vignette");
case FilterKind::bloomGlow: return QStringLiteral("Bloom / Glow");
case FilterKind::dither: return QStringLiteral("Dither");
case FilterKind::scanlines: return QStringLiteral("Scanlines");
case FilterKind::tonalContrast: return QStringLiteral("Tonal Contrast");
case FilterKind::lensCorrection: return QStringLiteral("Lens Correction");
case FilterKind::cameraRaw: return QStringLiteral("Camera Raw Filter");
@@ -93,6 +94,7 @@ FilterSettings FilterSettings::normalized() const
result.gradientMap = gradientMap.normalized();
result.grain = grain.normalized();
result.dither = dither.normalized();
result.scanlines = scanlines.normalized();
result.cameraRaw = cameraRaw.normalized();
return result;
}
@@ -164,6 +166,7 @@ QImage PixelFilter::run(const FilterJob &job)
// Grain sits in layer pixels; the job's seed patterns it.
case FilterKind::grain: image = settings.grain.apply(job.image, QPointF(), 1 / job.scale, job.seed); break;
case FilterKind::dither: image = settings.dither.apply(job.image); break;
case FilterKind::scanlines: image = settings.scanlines.apply(job.image); break;
case FilterKind::removeBackground: image = SubjectRemoval::run(job.image, settings); break;
case FilterKind::contentAwareFill: image = ContentFill::run(job); break;
// Unclamped: a blur spreads into the room made for it.
@@ -278,8 +281,9 @@ void FilterEdit::prepare(const ImportedImage &source, const LayerTransform &plac
{
const QSize size = source.size();
mapping = BrushRaster::pixelToDocument(placed, size.width(), size.height());
// Noise, grain and dither preview whole: enlarged, they look coarse.
const bool whole = kind == FilterKind::addNoise || kind == FilterKind::grain || kind == FilterKind::dither || kind == FilterKind::contentAwareFill
// Noise, grain, dither, scanlines preview whole: enlarged, they coarsen.
const bool whole = kind == FilterKind::addNoise || kind == FilterKind::grain || kind == FilterKind::dither || kind == FilterKind::scanlines
|| kind == FilterKind::contentAwareFill
|| kind == FilterKind::removeBackground;
const double factor = whole ? 1 : std::min(1.0, previewLimit / std::max(size.width(), size.height()));
if (!(factor < 1)) {
+4 -3
View File
@@ -1,7 +1,7 @@
#pragma once
#include "Document/CameraRaw.h"
#include "Document/Curves.h"
#include "Document/Dither.h"
#include "Document/Scanlines.h"
#include "Document/ImageAdjustments.h"
#include "Document/LayerTransform.h"
#include "Document/Selection.h"
@@ -17,7 +17,7 @@ struct ImageLayer;
// The Filter and Image menus' filters.
enum class FilterKind {
gaussianBlur, motionBlur, addNoise, vignette, bloomGlow, dither, tonalContrast, lensCorrection, cameraRaw, removeBackground, contentAwareFill, curves,
gaussianBlur, motionBlur, addNoise, vignette, bloomGlow, dither, scanlines, tonalContrast, lensCorrection, cameraRaw, removeBackground, contentAwareFill, curves,
exposure, gradientMap, grain, blackWhite, colorBalance
};
// The menu's title, Swift's rawValue.
@@ -31,7 +31,7 @@ bool repeatsAsLastFilter(FilterKind kind);
// Swift's allCases, the menus' order.
inline constexpr std::array allFilterKinds{FilterKind::gaussianBlur, FilterKind::motionBlur, FilterKind::addNoise,
FilterKind::vignette, FilterKind::bloomGlow, FilterKind::dither,
FilterKind::tonalContrast,
FilterKind::scanlines, FilterKind::tonalContrast,
FilterKind::lensCorrection, FilterKind::cameraRaw, FilterKind::removeBackground,
FilterKind::contentAwareFill,
FilterKind::curves, FilterKind::exposure, FilterKind::gradientMap,
@@ -79,6 +79,7 @@ struct FilterSettings {
BlackWhiteSettings blackWhite{};
ColorBalanceSettings colorBalance{};
DitherSettings dither{};
ScanlinesSettings scanlines{};
CameraRawSettings cameraRaw{};
BackgroundQuality backgroundQuality = BackgroundQuality::basic;
// How far, in pixels, the mask is pulled onto edges.
+94
View File
@@ -0,0 +1,94 @@
#include "Document/Scanlines.h"
#include "Document/BrushStroke.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include <algorithm>
#include <cmath>
extern "C" {
#include "DitherPixels.h"
}
// Swift's Scanlines.swift: its settings, the kernel, the glow.
namespace {
std::array<uint8_t, 3> bytes(const AdjustmentColor &color)
{
return {uint8_t(std::lround(color.red * 255)), uint8_t(std::lround(color.green * 255)), uint8_t(std::lround(color.blue * 255))};
}
}
ScanlinesSettings ScanlinesSettings::normalized() const
{
using ImageAdjustmentPixels::clamp;
ScanlinesSettings result = *this;
result.lineSpacing = std::round(clamp(lineSpacing, lineSpacingLow, lineSpacingHigh, 4));
result.thickness = clamp(thickness, 5, 100, 70);
result.glow = clamp(glow, 0, 100, 0);
result.dots = clamp(dots, 0, 100, 0);
result.wobble = clamp(wobble, wobbleLow, wobbleHigh, 0);
result.displace = clamp(displace, displaceLow, displaceHigh, 0);
result.smoothness = clamp(smoothness, 0, 100, 50);
result.threshold = clamp(threshold, 0, 100, 0);
result.split = std::round(clamp(split, splitLow, splitHigh, 0));
result.density = clamp(density, -100, 100, 0);
result.contrast = clamp(contrast, -100, 100, 0);
result.blackLevel = clamp(blackLevel, 0, 100, 0);
result.dark = dark.clamped();
result.light = light.clamped();
return result;
}
QImage ScanlinesSettings::apply(const QImage &image) const
{
const ScanlinesSettings settings = normalized();
const bool two = settings.colors == DitherColors::twoColors;
const std::array<uint8_t, 3> darkColor = two ? bytes(settings.dark) : std::array<uint8_t, 3>{0, 0, 0};
const std::array<uint8_t, 3> lightColor = two ? bytes(settings.light) : std::array<uint8_t, 3>{255, 255, 255};
bool failed = false;
const QImage drawn = ImageAdjustmentPixels::run(image, [&](uchar *pixels, int width, int height, qsizetype stride) {
ScanlinesParams params{.spacing = int(settings.lineSpacing), .thickness = float(settings.thickness / 100), .dots = float(settings.dots / 100),
.wobble = float(settings.wobble), .displace = float(settings.displace), .threshold = float(settings.threshold / 100),
.split = float(settings.split), .density = float(settings.density / 100), .contrast = float(settings.contrast / 100),
.blackLevel = float(settings.blackLevel / 100), .smoothness = float(settings.smoothness / 100),
.originalColors = settings.colors == DitherColors::original ? 1 : 0, .dark = {darkColor[0], darkColor[1], darkColor[2]},
.light = {lightColor[0], lightColor[1], lightColor[2]}};
failed = scanlines_apply(pixels, size_t(width), size_t(height), size_t(stride), &params) == 0;
});
if (failed)
throw ExportError(ExportError::Kind::render);
return settings.glow > 0 ? settings.glowing(drawn) : drawn;
}
// The lines' light blurred wide and added back: phosphor bloom.
QImage ScanlinesSettings::glowing(const QImage &image) const
{
// Blurred small, scaled back up: same light, less work.
const double sigma = lineSpacing * 3 + 3, shrink = std::max(1.0, std::floor(sigma / 4));
const int width = image.width(), height = image.height();
// Swift clamps first: edges repeat outward before shrinking.
const int step = int(shrink), pad = int(std::ceil(sigma * 3 / shrink)) * step;
// Far sides fill whole steps: both scales are exact.
const auto whole = [step](int side) { return (side + step - 1) / step * step; };
QImage padded = BrushRaster::context(whole(width + 2 * pad), whole(height + 2 * pad), false);
for (int y = 0; y < padded.height(); ++y) {
const uchar *source = image.constScanLine(std::clamp(y - pad, 0, height - 1));
uchar *out = padded.scanLine(y);
for (int x = 0; x < padded.width(); ++x)
std::copy_n(source + std::clamp(x - pad, 0, width - 1) * 4, 4, out + x * 4);
}
const int smallWidth = padded.width() / step, smallHeight = padded.height() / step;
const QImage small = padded.scaled(smallWidth, smallHeight, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
if (small.isNull())
throw ExportError(ExportError::Kind::render);
const QImage blurred = PixelAdjust::gaussianBlur(small, sigma / shrink, true)
.scaled(smallWidth * step, smallHeight * step, Qt::IgnoreAspectRatio, Qt::SmoothTransformation)
.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
QImage result = image.copy();
if (blurred.isNull() || result.isNull())
throw ExportError(ExportError::Kind::render);
// The scaled light's own rows, cropped to the image.
QImage bloom = BrushRaster::context(width, height, false);
for (int y = 0; y < height; ++y)
std::copy_n(blurred.constScanLine(y + pad) + pad * 4, width * 4, bloom.scanLine(y));
dither_glow(result.bits(), bloom.constBits(), size_t(width), size_t(height), size_t(result.bytesPerLine()), float(glow / 100 * 2.5));
return result;
}
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include "Document/Dither.h"
// Filter › Scanlines: the picture as a CRT draws it.
struct ScanlinesSettings {
static constexpr double lineSpacingLow = 2, lineSpacingHigh = 32;
static constexpr double wobbleLow = 0, wobbleHigh = 64;
static constexpr double displaceLow = -100, displaceHigh = 100;
static constexpr double splitLow = 0, splitHigh = 16;
// How far apart the lines are, in layer pixels.
double lineSpacing = 4;
// How much of the gap a bright line fills, 5–100%.
double thickness = 70;
// 0–100%: light blooming round the lines.
double glow = 0;
// 0–100%: darker tones bead the lines into dots.
double dots = 0;
// Pixels the lines waver sideways, down the screen.
double wobble = 0;
// Pixels a line rises over brightness; negative, falls.
double displace = 0;
// 0–100%: how far brightness is smoothed before displacing.
double smoothness = 50;
// 0–100%: darker tones draw no line.
double threshold = 0;
// Pixels the red and blue move apart.
double split = 0;
// −100…100: darker or lighter, flatter or punchier.
double density = 0;
double contrast = 0;
// 0–100%: the lines' brightness over black.
double blackLevel = 0;
DitherColors colors = DitherColors::blackWhite;
AdjustmentColor dark = AdjustmentColor(0, 0, 0);
AdjustmentColor light = AdjustmentColor(1, 1, 1);
ScanlinesSettings normalized() const;
QImage apply(const QImage &image) const;
friend bool operator==(const ScanlinesSettings &, const ScanlinesSettings &) = default;
private:
QImage glowing(const QImage &image) const;
};
+3 -49
View File
@@ -52,21 +52,6 @@ void FilterSheet::dither()
control(QStringLiteral("Text Size"), key(&DitherSettings::textSize), DitherSettings::textSizeLow, DitherSettings::textSizeHigh, QStringLiteral("px"), 0, false);
box()->setToolTip(QStringLiteral("The height of each line of characters"));
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return settings.dither.style == DitherStyle::ascii; });
// Scanlines' four rows.
const auto scanlines = [](const FilterSettings &settings) { return settings.dither.style == DitherStyle::scanlines; };
control(QStringLiteral("Line Spacing"), key(&DitherSettings::lineSpacing), DitherSettings::lineSpacingLow, DitherSettings::lineSpacingHigh, QStringLiteral("px"), 0,
false);
box()->setToolTip(QStringLiteral("How far apart the screen's lines are"));
m_shownWhen.emplace_back(box(), scanlines);
control(QStringLiteral("Glow"), key(&DitherSettings::glow), 0, 100, QStringLiteral("%"), 0, false);
box()->setToolTip(QStringLiteral("Light blooming around the lines, like a CRT's phosphors"));
m_shownWhen.emplace_back(box(), scanlines);
control(QStringLiteral("Dots"), key(&DitherSettings::dots), 0, 100, QStringLiteral("%"), 0, false);
box()->setToolTip(QStringLiteral("Break the lines into glowing beads"));
m_shownWhen.emplace_back(box(), scanlines);
control(QStringLiteral("Wobble"), key(&DitherSettings::wobble), DitherSettings::wobbleLow, DitherSettings::wobbleHigh, QStringLiteral("px"), 0, false);
box()->setToolTip(QStringLiteral("Make the lines waver sideways down the screen, like a CRT losing sync"));
m_shownWhen.emplace_back(box(), scanlines);
control(QStringLiteral("Cell Size"), key(&DitherSettings::cellSize), DitherSettings::cellSizeLow, DitherSettings::cellSizeHigh, QStringLiteral("px"), 0, false);
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return isHalftone(settings.dither.style); });
control(QStringLiteral("Angle"), key(&DitherSettings::angle), -90, 90, QStringLiteral("°"), 0, false);
@@ -105,40 +90,9 @@ void FilterSheet::dither()
control(QStringLiteral("Density"), key(&DitherSettings::density), -100, 100, QString(), 0, false);
box()->setToolTip(QStringLiteral("More ink (darker) or less before dithering"));
control(QStringLiteral("Contrast"), key(&DitherSettings::contrast), -100, 100, QString(), 0, false);
// Colors: a menu, as Swift's: the panel keeps its size.
QComboBox *colors = menu(QStringLiteral("Colors"), QStringLiteral("ditherColors"), QString());
for (const DitherColors each : allDitherColors)
colors->addItem(rawValue(each), int(each));
connect(colors, &QComboBox::activated, this, [this](int index) { update([index](FilterSettings &settings) { settings.dither.colors = DitherColors(index); }); });
m_follows.emplace_back([colors](const FilterSettings &settings) {
const QSignalBlocker quiet(colors);
colors->setCurrentIndex(int(settings.dither.colors));
});
// Two Colors: the dark and the light swatch.
auto *pair = new QWidget(this);
auto *swatches = new QHBoxLayout(pair);
swatches->setContentsMargins(0, 0, 0, 0);
swatches->setSpacing(8);
for (const bool light : {false, true}) {
auto *title = new QLabel(light ? QStringLiteral("Light") : QStringLiteral("Dark"), pair);
auto *swatch = new SwatchButton([this, light] {
const AdjustmentColor colour = light ? settings().dither.light : settings().dither.dark;
return PaletteColor{colour.red, colour.green, colour.blue};
}, 6, 1.5, 1, pair);
swatch->setObjectName(light ? QStringLiteral("ditherLight") : QStringLiteral("ditherDark"));
swatch->setFixedSize(24, 24);
swatch->setToolTip(light ? QStringLiteral("Choose the light color") : QStringLiteral("Choose the dark color"));
swatch->setAccessibleName(light ? QStringLiteral("Light color") : QStringLiteral("Dark color"));
connect(swatch, &QAbstractButton::clicked, this, [this, light] { m_session.openDitherColorPicker(light); });
m_follows.emplace_back([swatch](const FilterSettings &) { swatch->update(); });
if (light)
swatches->addSpacing(10);
swatches->addWidget(title);
swatches->addWidget(swatch);
}
swatches->addStretch(1);
m_column->addWidget(pair);
m_shownWhen.emplace_back(pair, [](const FilterSettings &settings) { return settings.dither.colors == DitherColors::twoColors; });
colorChoices(QStringLiteral("dither"), [](FilterSettings &settings) -> DitherColors & { return settings.dither.colors; },
[](const FilterSettings &settings) { return std::pair(settings.dither.dark, settings.dither.light); },
QStringLiteral("Choose the dark color"), QStringLiteral("Choose the light color"));
QComboBox *shape = menu(QStringLiteral("Pixel Shape"), QStringLiteral("ditherPixelShape"),
QStringLiteral("Draw each chunky pixel as a solid square, or as a round dot like a dot-matrix screen"));
for (const DitherPixelShape each : allDitherPixelShapes)
+84
View File
@@ -0,0 +1,84 @@
#include "UI/FilterSheet.h"
#include "UI/ColorPaletteControls.h"
#include <QComboBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QSlider>
#include <QVBoxLayout>
// Swift's scanlinesControls, and the colours Dither shares.
void FilterSheet::scanlines()
{
const auto key = [](double ScanlinesSettings::*field) { return [field](FilterSettings &settings) -> double & { return settings.scanlines.*field; }; };
const auto row = [this, &key](const QString &title, double ScanlinesSettings::*field, double low, double high, const QString &unit, const QString &help) {
control(title, key(field), low, high, unit, 0, false);
if (!help.isEmpty())
m_controls.back().slider->parentWidget()->setToolTip(help);
};
using S = ScanlinesSettings;
const QString px = QStringLiteral("px"), percent = QStringLiteral("%");
row(QStringLiteral("Line Spacing"), &S::lineSpacing, S::lineSpacingLow, S::lineSpacingHigh, px, QStringLiteral("How far apart the screen's lines are"));
row(QStringLiteral("Thickness"), &S::thickness, 5, 100, percent, QStringLiteral("How much of the gap a bright line fills; dimmer parts draw it thinner"));
row(QStringLiteral("Glow"), &S::glow, 0, 100, percent, QStringLiteral("Light blooming around the lines, like a CRT's phosphors"));
row(QStringLiteral("Dots"), &S::dots, 0, 100, percent,
QStringLiteral("Break the lines into beads where the picture is darker than this, blending into solid lines where it's brighter"));
row(QStringLiteral("Displace"), &S::displace, S::displaceLow, S::displaceHigh, px,
QStringLiteral("Lift the lines where the picture is bright, or lower them when negative, so they swell into its shapes"));
row(QStringLiteral("Smoothness"), &S::smoothness, 0, 100, percent,
QStringLiteral("How far the brightness is smoothed before it displaces the lines, from sharp ridges to rounded hills"));
row(QStringLiteral("Threshold"), &S::threshold, 0, 100, percent, QStringLiteral("Draw no line where the picture is darker than this, leaving the screen dark"));
row(QStringLiteral("Wobble"), &S::wobble, S::wobbleLow, S::wobbleHigh, px, QStringLiteral("Make the lines waver sideways down the screen, like a CRT losing sync"));
row(QStringLiteral("Color Split"), &S::split, S::splitLow, S::splitHigh, px, QStringLiteral("Move the red and blue apart, for colored fringes on the lines"));
row(QStringLiteral("Density"), &S::density, -100, 100, QString(), QStringLiteral("Darker or lighter before the lines are drawn"));
row(QStringLiteral("Contrast"), &S::contrast, -100, 100, QString(), QString());
row(QStringLiteral("Black Level"), &S::blackLevel, 0, 100, percent,
QStringLiteral("How bright the lines are where the picture is black, so they show even over black"));
colorChoices(QStringLiteral("scanlines"), [](FilterSettings &settings) -> DitherColors & { return settings.scanlines.colors; },
[](const FilterSettings &settings) { return std::pair(settings.scanlines.dark, settings.scanlines.light); },
QStringLiteral("Choose the screen's color"), QStringLiteral("Choose the lines' color"));
}
void FilterSheet::colorChoices(const QString &prefix, std::function<DitherColors &(FilterSettings &)> colors,
std::function<std::pair<AdjustmentColor, AdjustmentColor>(const FilterSettings &)> pair, const QString &darkHelp,
const QString &lightHelp)
{
// Colors: a menu, as Swift's: the panel keeps its size.
QComboBox *menuBox = menu(QStringLiteral("Colors"), prefix + QStringLiteral("Colors"), QString());
for (const DitherColors each : allDitherColors)
menuBox->addItem(rawValue(each), int(each));
connect(menuBox, &QComboBox::activated, this, [this, colors](int index) { update([&](FilterSettings &settings) { colors(settings) = DitherColors(index); }); });
m_follows.emplace_back([menuBox, colors](const FilterSettings &settings) {
const QSignalBlocker quiet(menuBox);
FilterSettings shown = settings;
menuBox->setCurrentIndex(int(colors(shown)));
});
// Two Colors: the dark and the light swatch.
auto *both = new QWidget(this);
auto *swatches = new QHBoxLayout(both);
swatches->setContentsMargins(0, 0, 0, 0);
swatches->setSpacing(8);
for (const bool light : {false, true}) {
auto *title = new QLabel(light ? QStringLiteral("Light") : QStringLiteral("Dark"), both);
auto *swatch = new SwatchButton([this, light, pair] {
const auto [dark, lit] = pair(settings());
const AdjustmentColor colour = light ? lit : dark;
return PaletteColor{colour.red, colour.green, colour.blue};
}, 6, 1.5, 1, both);
swatch->setObjectName(prefix + (light ? QStringLiteral("Light") : QStringLiteral("Dark")));
swatch->setFixedSize(24, 24);
swatch->setToolTip(light ? lightHelp : darkHelp);
swatch->setAccessibleName(light ? QStringLiteral("Light color") : QStringLiteral("Dark color"));
connect(swatch, &QAbstractButton::clicked, this, [this, light] { m_session.openDitherColorPicker(light); });
m_follows.emplace_back([swatch](const FilterSettings &) { swatch->update(); });
if (light)
swatches->addSpacing(10);
swatches->addWidget(title);
swatches->addWidget(swatch);
}
swatches->addStretch(1);
m_column->addWidget(both);
m_shownWhen.emplace_back(both, [colors](const FilterSettings &settings) {
FilterSettings shown = settings;
return colors(shown) == DitherColors::twoColors;
});
}
+1
View File
@@ -186,6 +186,7 @@ FilterSheet::FilterSheet(EditorSession &session, QWidget *parent)
noise();
break;
case FilterKind::dither: dither(); break;
case FilterKind::scanlines: scanlines(); break;
case FilterKind::vignette:
case FilterKind::bloomGlow:
case FilterKind::tonalContrast: finishing(m_session.filterEdit()->kind); break;
+6
View File
@@ -78,6 +78,12 @@ private:
void finishing(FilterKind kind);
// Swift's ditherControls: style, sizes, tones, colours, shape.
void dither();
// Swift's scanlinesControls: lines, displacement, tone, colours.
void scanlines();
// The Colors menu, then Two Colors' dark and light swatches.
void colorChoices(const QString &prefix, std::function<DitherColors &(FilterSettings &)> colors,
std::function<std::pair<AdjustmentColor, AdjustmentColor>(const FilterSettings &)> pair, const QString &darkHelp,
const QString &lightHelp);
// A titled menu of choices: the row and its box.
QComboBox *menu(const QString &title, const QString &name, const QString &help);
// Remove Background's words, Quality, and Advanced's three rows.
+2 -3
View File
@@ -145,9 +145,8 @@ void AcceptanceFinishTests::cameraRawCurvesAndDither()
const double grown = double(large.height()) / letter.height();
QVERIFY2(grown > 1.6 && grown < 2.4 && inkedLarge < 0.75, qPrintable(QString::number(grown)));
app.action("undo").trigger();
// Scanlines: dark gaps between the beam's rows across the sky.
app.action("dither").trigger();
choose(shownChild<QComboBox>(app, "ditherStyle"), QStringLiteral("Scanlines (CRT)"));
// Scanlines, its own filter: dark gaps between the beam's rows.
app.action("scanlines").trigger();
QVERIFY(committed(app));
const QImage lines = app.session().activeLayer().value().asset.value().image();
int darkest = 255, brightest = 0;
+1 -1
View File
@@ -70,7 +70,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
QStringList filters;
for (QAction *entry : menus[5]->actions())
filters << entry->text();
QCOMPARE(filters, (QStringList{"Last Filter", "", "Gaussian Blur…", "Motion Blur…", "Add Noise…", "Vignette…", "Bloom / Glow…", "Dither…", "Tonal Contrast…", "Lens Correction…", "Camera Raw Filter…", "Remove Background…"}));
QCOMPARE(filters, (QStringList{"Last Filter", "", "Gaussian Blur…", "Motion Blur…", "Add Noise…", "Vignette…", "Bloom / Glow…", "Dither…", "Scanlines…", "Tonal Contrast…", "Lens Correction…", "Camera Raw Filter…", "Remove Background…"}));
QCOMPARE(menus[6]->actions().size(), 23);
}
+5 -5
View File
@@ -98,7 +98,7 @@ void DitherSheetTests::theKindSitsUnderBloomAndPreviewsWhole()
{
QCOMPARE(rawValue(FilterKind::dither), QString("Dither"));
const auto at = std::find(allFilterKinds.begin(), allFilterKinds.end(), FilterKind::dither);
QVERIFY(at != allFilterKinds.end() && *(at - 1) == FilterKind::bloomGlow && *(at + 1) == FilterKind::tonalContrast);
QVERIFY(at != allFilterKinds.end() && *(at - 1) == FilterKind::bloomGlow && *(at + 1) == FilterKind::scanlines);
QVERIFY(!isAutomatic(FilterKind::dither) && !isImageAdjustment(FilterKind::dither));
// Past the preview limit, Dither still previews at full size.
EditorSession session;
@@ -132,7 +132,7 @@ void DitherSheetTests::theTwoColoursHaveTheirPicker()
{
Shown shown;
shown.session.openDitherColorPicker(false);
QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (Dither Dark Color)"));
QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (Dark Color)"));
QVERIFY(shown.session.colorPicker().value().original == PaletteColor::black());
pick(shown.session, PaletteColor{0, 0, 1});
shown.session.previewDitherColor();
@@ -141,7 +141,7 @@ void DitherSheetTests::theTwoColoursHaveTheirPicker()
shown.session.closeColorPicker(false);
QVERIFY(shown.dither().dark == AdjustmentColor(0, 0, 0));
shown.session.openDitherColorPicker(true);
QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (Dither Light Color)"));
QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (Light Color)"));
pick(shown.session, PaletteColor{1, 1, 0});
shown.session.closeColorPicker(true);
QVERIFY(shown.dither().light == AdjustmentColor(1, 1, 0) && shown.dither().dark == AdjustmentColor(0, 0, 0));
@@ -179,13 +179,13 @@ void DitherSheetTests::theControlsFollowTheStyle()
dither.colors = DitherColors::twoColors;
});
QCOMPARE(shown.shown(), sorted(QStringList{"Style", "Pixel Size", "Density", "Contrast", "Colors", "Light on Dark", "Swatches"}));
// Style's menu: eleven looks in four groups.
// Style's menu: ten looks in four groups.
QComboBox &style = shown.child<QComboBox>("ditherStyle");
QStringList items;
for (int index = 0; index < style.count(); ++index)
items << (style.itemText(index).isEmpty() ? QStringLiteral("—") : style.itemText(index));
QCOMPARE(items, (QStringList{"Atkinson (Classic Mac)", "Floyd–Steinberg", "—", "Bayer 2 × 2", "Bayer 4 × 4", "Bayer 8 × 8", "—", "Halftone Dots",
"Halftone Lines", "Halftone Diamonds", "—", "Mac Patterns", "ASCII", "Scanlines (CRT)"}));
"Halftone Lines", "Halftone Diamonds", "—", "Mac Patterns", "ASCII"}));
QCOMPARE(style.currentText(), QString("Mac Patterns"));
// Its quick preview never shows the busy line.
QVERIFY(!shown.child<QProgressBar>("filterSpinner").isVisibleTo(shown.sheet.get()));
+2 -2
View File
@@ -67,11 +67,11 @@ void DitherTests::stylesKeepSwiftsNamesOrderAndGroups()
for (const DitherStyle each : allDitherStyles)
names << rawValue(each);
QCOMPARE(names, (QStringList{"Atkinson (Classic Mac)", "Floyd–Steinberg", "Bayer 2 × 2", "Bayer 4 × 4", "Bayer 8 × 8", "Halftone Dots",
"Halftone Lines", "Halftone Diamonds", "Mac Patterns", "ASCII", "Scanlines (CRT)"}));
"Halftone Lines", "Halftone Diamonds", "Mac Patterns", "ASCII"}));
QString groups;
for (const DitherStyle each : allDitherStyles)
groups += QString::number(ditherGroup(each));
QCOMPARE(groups, QString("00111222333"));
QCOMPARE(groups, QString("0011122233"));
QVERIFY(diffuses(DitherStyle::floydSteinberg) && !diffuses(DitherStyle::bayer2));
QVERIFY(hasTones(DitherStyle::bayer8) && !hasTones(DitherStyle::dots));
QVERIFY(isHalftone(DitherStyle::diamonds) && !isHalftone(DitherStyle::patterns));
+2 -2
View File
@@ -66,7 +66,7 @@ void FilterModelTests::kindsKeepSwiftsNames()
{
const std::pair<FilterKind, const char *> kinds[] = {
{FilterKind::gaussianBlur, "Gaussian Blur"}, {FilterKind::motionBlur, "Motion Blur"}, {FilterKind::addNoise, "Add Noise"},
{FilterKind::vignette, "Vignette"}, {FilterKind::bloomGlow, "Bloom / Glow"}, {FilterKind::dither, "Dither"},
{FilterKind::vignette, "Vignette"}, {FilterKind::bloomGlow, "Bloom / Glow"}, {FilterKind::dither, "Dither"}, {FilterKind::scanlines, "Scanlines"},
{FilterKind::tonalContrast, "Tonal Contrast"},
{FilterKind::lensCorrection, "Lens Correction"}, {FilterKind::cameraRaw, "Camera Raw Filter"},
{FilterKind::removeBackground, "Remove Background"},
@@ -80,7 +80,7 @@ void FilterModelTests::kindsKeepSwiftsNames()
QVERIFY(allFilterKinds[index] == kind);
QCOMPARE(rawValue(kind), QString(name));
QCOMPARE(isAutomatic(kind), kind == FilterKind::contentAwareFill || kind == FilterKind::removeBackground);
QCOMPARE(isImageAdjustment(kind), index >= 11);
QCOMPARE(isImageAdjustment(kind), index >= 12);
}
// Remove Background's qualities, Swift's words and order.
QVERIFY(allBackgroundQualities.size() == 2 && allBackgroundQualities[0] == BackgroundQuality::basic);
+1 -1
View File
@@ -214,7 +214,7 @@ void FinishingFilterTests::settingsKeepSwiftsDefaultsAndBounds()
QCOMPARE(rawValue(FilterKind::bloomGlow), QString("Bloom / Glow"));
QCOMPARE(rawValue(FilterKind::tonalContrast), QString("Tonal Contrast"));
QVERIFY(allFilterKinds[3] == FilterKind::vignette && allFilterKinds[4] == FilterKind::bloomGlow && allFilterKinds[5] == FilterKind::dither
&& allFilterKinds[6] == FilterKind::tonalContrast);
&& allFilterKinds[6] == FilterKind::scanlines && allFilterKinds[7] == FilterKind::tonalContrast);
QVERIFY(!isImageAdjustment(FilterKind::bloomGlow) && !isAutomatic(FilterKind::tonalContrast) && !isAutomatic(FilterKind::vignette));
}
+317 -254
View File
@@ -1,9 +1,10 @@
#include "Document/BrushStroke.h"
#include "Document/Dither.h"
#include "Document/EditorSession.h"
#include "Document/PixelAdjust.h"
#include "Document/Scanlines.h"
#include "UI/ColorPaletteControls.h"
#include "UI/FilterSheet.h"
#include <QCheckBox>
#include <QComboBox>
#include <QLabel>
#include <QLayout>
#include <QLineEdit>
@@ -11,8 +12,11 @@
#include <QSlider>
#include <QtTest>
#include <numeric>
extern "C" {
#include "DitherPixels.h"
}
// Swift 1.4.4's Dither › Scanlines (CRT), on every core.
// Swift 1.4.9's Filter › Scanlines, its own filter.
namespace {
QImage filled(int width, int height, QColor colour)
{
@@ -21,16 +25,6 @@ QImage filled(int width, int height, QColor colour)
return image;
}
DitherSettings scanlines(double spacing, double glow = 0)
{
DitherSettings settings;
settings.style = DitherStyle::scanlines;
settings.lineSpacing = spacing;
settings.glow = glow;
return settings;
}
// One channel of one column, top to bottom.
std::vector<int> column(const QImage &image, int x, int channel = 0)
{
std::vector<int> values;
@@ -47,35 +41,65 @@ std::vector<int> row(const QImage &image, int y, int channel = 0)
return values;
}
// Swift's test: one column of a flat gray's render.
// Swift's render: no glow unless asked.
QImage render(const QImage &image, const std::function<void(ScanlinesSettings &)> &adjust = {})
{
ScanlinesSettings settings;
if (adjust)
adjust(settings);
return settings.apply(image);
}
std::vector<int> gray(int level, double spacing, double glow = 0)
{
return column(scanlines(spacing, glow).apply(filled(16, 32, QColor(level, level, level))), 5);
return column(render(filled(16, 32, QColor(level, level, level)), [&](ScanlinesSettings &s) { s.lineSpacing = spacing, s.glow = glow; }), 5);
}
// Worst difference from Swift's first glow, blurred whole.
int glowError(const QImage &image, double glow)
// The oracle's picture: premultiplied, some clear, some soft.
QImage pattern(int width, int height)
{
const QImage lines = scanlines(8).apply(image), result = scanlines(8, glow).apply(image);
const QImage blurred = PixelAdjust::gaussianBlur(lines, 8 * 3 + 3, true);
const float amount = float(glow / 100 * 2.5);
int worst = 0;
for (int y = 0; y < image.height(); ++y) {
for (int x = 0; x < image.width(); ++x) {
const int plain = lines.constScanLine(y)[x * 4], light = blurred.constScanLine(y)[x * 4];
const int expected = int(std::lround(std::min(255.0f, float(plain) + float(light) * amount)));
worst = std::max(worst, std::abs(result.constScanLine(y)[x * 4] - expected));
QImage image = BrushRaster::context(width, height, false);
for (int y = 0; y < height; ++y) {
uchar *out = image.scanLine(y);
for (int x = 0; x < width; ++x) {
const unsigned alpha = (x * 13 + y * 7) % 4 == 0 ? 0 : 128 + (x * 29 + y * 17) % 128;
for (int c = 0; c < 3; ++c)
out[x * 4 + c] = uchar(((x * 37 + y * 11 + c * 53) % 256) * alpha / 255);
out[x * 4 + 3] = uchar(alpha);
}
}
return worst;
return image;
}
std::vector<int> repeated(const std::vector<int> &band, int times)
quint64 fnv(const QImage &image)
{
std::vector<int> all;
for (int each = 0; each < times; ++each)
all.insert(all.end(), band.begin(), band.end());
return all;
quint64 hash = 1469598103934665603ULL;
for (int y = 0; y < image.height(); ++y)
for (int x = 0; x < image.width() * 4; ++x)
hash = (hash ^ image.constScanLine(y)[x]) * 1099511628211ULL;
return hash;
}
// Swift's params: unset fields zero, white lines unless set.
ScanlinesParams made(const std::function<void(ScanlinesParams &)> &set)
{
ScanlinesParams params{};
params.light[0] = params.light[1] = params.light[2] = 255;
set(params);
return params;
}
void rgb(uint8_t *to, uint8_t red, uint8_t green, uint8_t blue)
{
to[0] = red, to[1] = green, to[2] = blue;
}
QImage kernel(int width, int height, const ScanlinesParams &params)
{
QImage image = pattern(width, height);
if (scanlines_apply(image.bits(), size_t(width), size_t(height), size_t(image.bytesPerLine()), &params) == 0)
throw std::runtime_error("no memory");
return image;
}
struct Shown {
@@ -86,15 +110,11 @@ struct Shown {
session.createDocument(40, 30);
const QImage image = filled(40, 30, QColor(200, 120, 40));
session.insert(ImportedImage(image, image, QStringLiteral("Colour")));
session.beginFilter(FilterKind::dither);
FilterSettings settings = session.filterEdit().value().settings;
settings.dither.style = DitherStyle::scanlines;
session.updateFilter(settings, true);
session.beginFilter(FilterKind::scanlines);
sheet = std::make_unique<FilterSheet>(session);
sheet->show();
}
const DitherSettings &dither() const { return session.filterEdit().value().settings.dither; }
// The shown rows in the column's order, with their help.
const ScanlinesSettings &lines() const { return session.filterEdit().value().settings.scanlines; }
QStringList rows() const
{
QStringList titles;
@@ -103,8 +123,6 @@ struct Shown {
QWidget *each = column->itemAt(index)->widget();
if (!each || each->isHidden())
continue;
if (auto *box = qobject_cast<QCheckBox *>(each); box && box->text() != QLatin1String("Preview"))
titles << box->text();
for (QLabel *label : each->findChildren<QLabel *>(QString(), Qt::FindDirectChildrenOnly)) {
if (label->buddy())
titles << label->text() + u'|' + each->toolTip();
@@ -119,21 +137,22 @@ class ScanlinesTests : public QObject {
Q_OBJECT
private slots:
void scanlinesAreLinesOfLightThatBloomWithBrightness();
void linesShowOnSolidWhiteAndBlack();
void glowLightsBetweenTheLines();
void dotsBreakTheLinesIntoBeads();
void dotsBeadTheLinesBelowTheirLevel();
void wobbleMovesLinesSideways();
void theStyleDrawsAtFullResolutionWithoutMarks();
void normalizingClampsTheFourSettings();
void eachLineIsTheAverageOfItsRows();
void theScreenIsTheDarkColourOrBlack();
void displaceLiftsLinesWhereThePictureIsBright();
void aLiftedLineHidesTheLinesBehindIt();
void thresholdDarkensWhatsBelowIt();
void theKernelDrawsSwiftsOwnBytes();
void theSettingsReachTheKernel();
void normalizingClampsEverySetting();
void clearPixelsStayClear();
void everyBandOfATallImageIsDrawn();
void bandsDrawAsOneBandDoes();
void theGlowIsTheBlurredLightAtItsAmount();
void theGlowScalesBackExactlyOffTheStep();
void theGlowNeverPassesAlpha();
void theSheetShowsScanlinesRows();
void theGlowIsTheBlurredLightEasedNearWhite();
void theFilterIsListedAndPreviewsWhole();
void theSheetShowsSwiftsRows();
void theRowsWriteTheirSettings();
void twoColorsPickTheScreenAndTheLines();
};
void ScanlinesTests::scanlinesAreLinesOfLightThatBloomWithBrightness()
@@ -146,9 +165,15 @@ void ScanlinesTests::scanlinesAreLinesOfLightThatBloomWithBrightness()
}
QVERIFY(std::accumulate(dim.begin(), dim.end(), 0) * 2 < std::accumulate(white.begin(), white.end(), 0));
QVERIFY(std::all_of(black.begin(), black.end(), [](int value) { return value == 0; }));
// The beam's half height, 2.8 white, about 1.98 at 0.35.
QCOMPARE(white, repeated({0, 255, 255, 255, 255, 255, 255, 0}, 4));
QCOMPARE(dim, repeated({0, 0, 118, 120, 120, 118, 0, 0}, 4));
}
void ScanlinesTests::linesShowOnSolidWhiteAndBlack()
{
const std::vector<int> white = gray(255, 8, 100);
QVERIFY(*std::max_element(white.begin(), white.end()) - *std::min_element(white.begin(), white.end()) > 40);
const std::vector<int> black = column(render(filled(16, 32, Qt::black), [](ScanlinesSettings &s) { s.lineSpacing = 8, s.blackLevel = 40; }), 8);
const std::vector<int> band(black.begin(), black.begin() + 8);
QVERIFY(*std::max_element(band.begin(), band.end()) > 60 && *std::min_element(band.begin(), band.end()) < 10);
}
void ScanlinesTests::glowLightsBetweenTheLines()
@@ -157,19 +182,14 @@ void ScanlinesTests::glowLightsBetweenTheLines()
QVERIFY2(glowing[0] > plain[0] + 40, qPrintable(QString::number(glowing[0])));
}
void ScanlinesTests::dotsBreakTheLinesIntoBeads()
void ScanlinesTests::dotsBeadTheLinesBelowTheirLevel()
{
const QImage white = filled(64, 16, Qt::white);
DitherSettings settings = scanlines(8);
QCOMPARE(row(settings.apply(white), 4), std::vector<int>(64, 255));
settings.dots = 100;
// Each bead lit at its middle, dark at its ends.
const std::vector<int> lit = row(settings.apply(white), 4);
QCOMPARE(std::vector<int>(lit.begin(), lit.begin() + 8), (std::vector<int>{0, 255, 255, 255, 255, 255, 255, 0}));
QCOMPARE(std::vector<int>(lit.begin() + 8, lit.begin() + 16), (std::vector<int>{0, 255, 255, 255, 255, 255, 255, 0}));
// Half dots: the bead ends dim off the middle.
settings.dots = 50;
QCOMPARE(row(settings.apply(white), 1)[0], 85);
const auto middle = [](int level) {
return row(render(filled(64, 16, QColor(level, level, level)), [](ScanlinesSettings &s) { s.lineSpacing = 8, s.dots = 70; }), 4);
};
QCOMPARE(middle(255), std::vector<int>(64, 255));
const std::vector<int> grey = middle(102);
QVERIFY(grey[3] > 100 && grey[4] > 100 && grey[0] < 40 && grey[8] < 40);
}
void ScanlinesTests::wobbleMovesLinesSideways()
@@ -177,106 +197,129 @@ void ScanlinesTests::wobbleMovesLinesSideways()
QImage edge = filled(64, 64, Qt::black);
QPainter(&edge).fillRect(QRect(32, 0, 32, 64), Qt::white);
const auto edges = [&edge](double wobble) {
DitherSettings settings = scanlines(8);
settings.wobble = wobble;
const QImage result = settings.apply(edge);
std::vector<int> found;
const QImage result = render(edge, [wobble](ScanlinesSettings &s) { s.lineSpacing = 8, s.wobble = wobble; });
std::set<int> found;
for (int line = 0; line < 8; ++line) {
const std::vector<int> middle = row(result, line * 8 + 4);
found.push_back(int(std::find_if(middle.begin(), middle.end(), [](int value) { return value > 128; }) - middle.begin()));
found.insert(int(std::find_if(middle.begin(), middle.end(), [](int value) { return value > 128; }) - middle.begin()));
}
return found;
};
QCOMPARE(edges(0), std::vector<int>(8, 32));
// Each line's shift: 12 times the two waves, rounded.
QCOMPARE(edges(12), (std::vector<int>{35, 36, 35, 41, 44, 37, 32, 34}));
QCOMPARE(edges(0), std::set<int>{32});
QVERIFY(edges(12).size() >= 3);
}
void ScanlinesTests::theStyleDrawsAtFullResolutionWithoutMarks()
void ScanlinesTests::displaceLiftsLinesWhereThePictureIsBright()
{
QVERIFY(!usesPixelSize(DitherStyle::scanlines) && !usesPixelSize(DitherStyle::ascii) && usesPixelSize(DitherStyle::patterns));
QVERIFY(!drawsMarks(DitherStyle::scanlines) && drawsMarks(DitherStyle::ascii));
QVERIFY(!hasTones(DitherStyle::scanlines) && !isHalftone(DitherStyle::scanlines) && ditherGroup(DitherStyle::scanlines) == 3);
// A pixel size changes nothing: no chunks, no dots.
const QImage image = filled(20, 24, QColor(180, 90, 30));
DitherSettings settings = scanlines(4);
const QImage plain = settings.apply(image);
settings.pixelSize = 5;
settings.pixelShape = DitherPixelShape::dot;
QCOMPARE(settings.apply(image), plain);
// The cell is the line spacing, not the cell size.
settings.cellSize = 32;
QCOMPARE(settings.apply(image), plain);
QImage half = filled(64, 64, Qt::black);
QPainter(&half).fillRect(QRect(32, 0, 32, 64), Qt::white);
const QImage result = render(half, [](ScanlinesSettings &s) { s.lineSpacing = 16, s.displace = 6, s.colors = DitherColors::original, s.blackLevel = 50; });
const auto brightest = [&result](int x) {
const std::vector<int> values = column(result, x);
return int(std::max_element(values.begin() + 16, values.begin() + 32) - values.begin());
};
QVERIFY(brightest(48) < 24);
// Over black the line keeps its middle, row 24.
const std::vector<int> still = column(result, 8);
QVERIFY(still[24] > 0 && still[24] == *std::max_element(still.begin() + 16, still.begin() + 32) && still[16] < still[24]);
}
void ScanlinesTests::normalizingClampsTheFourSettings()
void ScanlinesTests::aLiftedLineHidesTheLinesBehindIt()
{
DitherSettings settings;
QCOMPARE(settings.lineSpacing, 4.0);
QCOMPARE(settings.glow, 35.0);
QCOMPARE(settings.dots, 0.0);
QCOMPARE(settings.wobble, 0.0);
settings.lineSpacing = 1.4;
settings.glow = 150;
settings.dots = -5;
settings.wobble = 3.5;
DitherSettings result = settings.normalized();
QCOMPARE(result.lineSpacing, 2.0);
QCOMPARE(result.glow, 100.0);
QCOMPARE(result.dots, 0.0);
QCOMPARE(result.wobble, 3.5);
settings.lineSpacing = 40;
settings.glow = -1;
settings.dots = 120;
settings.wobble = 70;
result = settings.normalized();
QCOMPARE(result.lineSpacing, 32.0);
QCOMPARE(result.glow, 0.0);
QCOMPARE(result.dots, 100.0);
QCOMPARE(result.wobble, 64.0);
// Rounded within; not a number takes the default.
settings.lineSpacing = 5.5;
settings.glow = std::nan("");
settings.dots = std::nan("");
settings.wobble = std::nan("");
result = settings.normalized();
QCOMPARE(result.lineSpacing, 6.0);
QCOMPARE(result.glow, 35.0);
QCOMPARE(result.dots, 0.0);
QCOMPARE(result.wobble, 0.0);
settings.lineSpacing = std::nan("");
QCOMPARE(settings.normalized().lineSpacing, 4.0);
// Glow and dots keep their fractions.
settings.glow = 12.5;
settings.dots = 33.3;
QCOMPARE(settings.normalized().glow, 12.5);
QCOMPARE(settings.normalized().dots, 33.3);
QImage band = filled(64, 80, QColor(64, 64, 64));
QPainter(&band).fillRect(QRect(16, 32, 32, 16), Qt::white);
const QImage result = render(band, [](ScanlinesSettings &s) { s.lineSpacing = 16, s.displace = 40, s.smoothness = 0; });
QVERIFY(result.constScanLine(14)[4 * 4] > 20);
QVERIFY(result.constScanLine(14)[32 * 4] < 8);
QVERIFY(result.constScanLine(2)[32 * 4] > 200);
}
void ScanlinesTests::eachLineIsTheAverageOfItsRows()
void ScanlinesTests::thresholdDarkensWhatsBelowIt()
{
// Rows of white and black: each line lights at half.
QImage stripes = filled(8, 16, Qt::black);
for (int y = 0; y < 16; y += 2)
QPainter(&stripes).fillRect(QRect(0, y, 8, 1), Qt::white);
QCOMPARE(column(scanlines(8).apply(stripes), 3), repeated({0, 37, 172, 172, 172, 172, 37, 0}, 2));
const QImage grey = filled(32, 32, QColor(77, 77, 77));
const std::vector<int> shown = column(render(grey, [](ScanlinesSettings &s) { s.lineSpacing = 8; }), 16);
const std::vector<int> hidden = column(render(grey, [](ScanlinesSettings &s) { s.lineSpacing = 8, s.threshold = 60; }), 16);
QVERIFY(std::any_of(shown.begin(), shown.end(), [](int value) { return value > 40; }));
QVERIFY(std::none_of(hidden.begin(), hidden.end(), [](int value) { return value > 10; }));
}
void ScanlinesTests::theScreenIsTheDarkColourOrBlack()
// Swift's GCD kernel, built with clang's blocks, gave these.
void ScanlinesTests::theKernelDrawsSwiftsOwnBytes()
{
const QImage image = filled(8, 8, QColor(255, 128, 0));
DitherSettings settings = scanlines(8);
settings.colors = DitherColors::twoColors;
settings.dark = AdjustmentColor(0, 0, 1);
settings.light = AdjustmentColor(1, 1, 0);
const QImage two = settings.apply(image);
// Off the line the dark colour; on it, the mix.
QCOMPARE(two.pixelColor(2, 0), QColor(0, 0, 255));
QCOMPARE(two.pixelColor(2, 4), QColor(197, 197, 147));
settings.colors = DitherColors::original;
const QImage original = settings.apply(image);
QCOMPARE(original.pixelColor(2, 0), QColor(0, 0, 0));
QCOMPARE(original.pixelColor(2, 4), QColor(255, 173, 0));
const QImage a = kernel(37, 53, made([](ScanlinesParams &p) { p.spacing = 4; p.thickness = 0.7f; p.displace = 20; p.smoothness = 0.5f; p.originalColors = 1; }));
QCOMPARE(fnv(a), 14473728681183016108ULL);
QCOMPARE(QByteArray(reinterpret_cast<const char *>(a.constScanLine(2)) + 4, 4), QByteArray("\xbd\xbf\x25\xbf", 4));
const QImage b = kernel(37, 53, made([](ScanlinesParams &p) { p.spacing = 6; p.thickness = 0.9f; p.dots = 0.6f; p.wobble = 6; p.displace = -15; p.threshold = 0.3f; p.split = 3; p.density = 0.2f; p.contrast = -0.3f; p.blackLevel = 0.25f; p.smoothness = 0.8f; }));
QCOMPARE(fnv(b), 3381563246094821038ULL);
const QImage c = kernel(37, 53, made([](ScanlinesParams &p) { p.spacing = 5; p.thickness = 0.4f; p.contrast = 0.6f; p.smoothness = 0.5f; rgb(p.dark, 10, 20, 200); rgb(p.light, 250, 200, 10); }));
QCOMPARE(fnv(c), 11894396318800434257ULL);
QCOMPARE(QByteArray(reinterpret_cast<const char *>(c.constScanLine(2)) + 4, 4), QByteArray("\x55\x4d\x8f\xbf", 4));
// Wide and tall: 32 bands of columns, lines and rows.
QCOMPARE(fnv(kernel(70, 300, made([](ScanlinesParams &p) { p.spacing = 3; p.thickness = 0.7f; p.dots = 0.3f; p.displace = 30; p.split = 2; p.blackLevel = 0.1f; p.smoothness = 1; p.originalColors = 1; }))),
5889846669877741430ULL);
QCOMPARE(fnv(kernel(70, 300, made([](ScanlinesParams &p) { p.spacing = 2; p.thickness = 1; p.wobble = 9; p.displace = -40; p.threshold = 0.5f; p.smoothness = 0.25f; }))),
1843572660750656508ULL);
// The glow eases near full brightness.
QImage lit = pattern(41, 29), glow = pattern(41, 29);
for (int y = 0; y < 29; ++y)
for (int x = 0; x < 41 * 4; ++x)
glow.scanLine(y)[x] = uchar(255 - glow.constScanLine(y)[x]);
dither_glow(lit.bits(), glow.constBits(), 41, 29, size_t(lit.bytesPerLine()), 1.3f);
QCOMPARE(fnv(lit), 14026896863046063157ULL);
}
// Each setting reaches its own field, scaled as Swift's.
void ScanlinesTests::theSettingsReachTheKernel()
{
ScanlinesSettings settings;
settings.lineSpacing = 6, settings.thickness = 90, settings.dots = 60, settings.wobble = 6, settings.displace = -15, settings.threshold = 30;
settings.split = 3, settings.density = 20, settings.contrast = -30, settings.blackLevel = 25, settings.smoothness = 80;
QCOMPARE(settings.apply(pattern(37, 53)), kernel(37, 53, made([](ScanlinesParams &p) { p.spacing = 6; p.thickness = 0.9f; p.dots = 0.6f; p.wobble = 6; p.displace = -15; p.threshold = 0.3f; p.split = 3; p.density = 0.2f; p.contrast = -0.3f; p.blackLevel = 0.25f; p.smoothness = 0.8f; })));
settings = ScanlinesSettings();
settings.lineSpacing = 5, settings.thickness = 40, settings.contrast = 60, settings.colors = DitherColors::twoColors;
settings.dark = AdjustmentColor(10 / 255.0, 20 / 255.0, 200 / 255.0), settings.light = AdjustmentColor(250 / 255.0, 200 / 255.0, 10 / 255.0);
QCOMPARE(settings.apply(pattern(37, 53)), kernel(37, 53, made([](ScanlinesParams &p) { p.spacing = 5; p.thickness = 0.4f; p.contrast = 0.6f; p.smoothness = 0.5f; rgb(p.dark, 10, 20, 200); rgb(p.light, 250, 200, 10); })));
// Picked colours count only for Two Colors.
settings.colors = DitherColors::blackWhite;
QCOMPARE(settings.apply(pattern(37, 53)), kernel(37, 53, made([](ScanlinesParams &p) { p.spacing = 5; p.thickness = 0.4f; p.contrast = 0.6f; p.smoothness = 0.5f; })));
settings.colors = DitherColors::original, settings.displace = 20, settings.lineSpacing = 4, settings.thickness = 70, settings.contrast = 0;
QCOMPARE(fnv(settings.apply(pattern(37, 53))), 14473728681183016108ULL);
}
void ScanlinesTests::normalizingClampsEverySetting()
{
const ScanlinesSettings defaults;
QVERIFY(defaults.lineSpacing == 4 && defaults.thickness == 70 && defaults.glow == 0 && defaults.dots == 0 && defaults.wobble == 0);
QVERIFY(defaults.displace == 0 && defaults.smoothness == 50 && defaults.threshold == 0 && defaults.split == 0 && defaults.blackLevel == 0);
QVERIFY(defaults.colors == DitherColors::blackWhite && defaults.dark == AdjustmentColor(0, 0, 0) && defaults.light == AdjustmentColor(1, 1, 1));
using Field = double ScanlinesSettings::*;
// Below, above, a fraction, not a number.
const std::tuple<Field, double, double, double, double, double> fields[] = {
{&ScanlinesSettings::lineSpacing, 2, 32, 5.5, 6, 4}, {&ScanlinesSettings::thickness, 5, 100, 33.3, 33.3, 70},
{&ScanlinesSettings::glow, 0, 100, 12.5, 12.5, 0}, {&ScanlinesSettings::dots, 0, 100, 33.3, 33.3, 0},
{&ScanlinesSettings::wobble, 0, 64, 3.5, 3.5, 0}, {&ScanlinesSettings::displace, -100, 100, -7.5, -7.5, 0},
{&ScanlinesSettings::smoothness, 0, 100, 12.5, 12.5, 50}, {&ScanlinesSettings::threshold, 0, 100, 12.5, 12.5, 0},
{&ScanlinesSettings::split, 0, 16, 2.5, 3, 0}, {&ScanlinesSettings::density, -100, 100, 12.5, 12.5, 0},
{&ScanlinesSettings::contrast, -100, 100, -12.5, -12.5, 0}, {&ScanlinesSettings::blackLevel, 0, 100, 12.5, 12.5, 0}};
for (const auto &[field, low, high, typed, kept, fallback] : fields) {
ScanlinesSettings settings;
settings.*field = low - 1000;
QCOMPARE(settings.normalized().*field, low);
settings.*field = high + 1000;
QCOMPARE(settings.normalized().*field, high);
settings.*field = typed;
QCOMPARE(settings.normalized().*field, kept);
settings.*field = std::nan("");
QCOMPARE(settings.normalized().*field, fallback);
}
// Applying normalizes first: past the ends draws as the ends.
ScanlinesSettings wild;
wild.lineSpacing = 5.6, wild.thickness = 500, wild.split = 2.4, wild.displace = 300;
QCOMPARE(wild.apply(pattern(37, 53)), wild.normalized().apply(pattern(37, 53)));
QCOMPARE(fnv(wild.apply(pattern(37, 53))), fnv(kernel(37, 53, made([](ScanlinesParams &p) { p.spacing = 6; p.thickness = 1; p.split = 2; p.displace = 100; p.smoothness = 0.5f; }))));
ScanlinesSettings colours;
colours.dark = AdjustmentColor(-1, 2, 0.5), colours.light = AdjustmentColor(3, -2, 0.25);
QVERIFY(colours.normalized().dark == AdjustmentColor(0, 1, 0.5) && colours.normalized().light == AdjustmentColor(1, 0, 0.25));
}
void ScanlinesTests::clearPixelsStayClear()
@@ -286,139 +329,159 @@ void ScanlinesTests::clearPixelsStayClear()
painter.setCompositionMode(QPainter::CompositionMode_Source);
painter.fillRect(QRect(0, 0, 8, 8), Qt::transparent);
painter.end();
const QImage result = scanlines(8, 100).apply(image);
const QImage result = render(image, [](ScanlinesSettings &s) { s.lineSpacing = 8, s.glow = 100; });
QCOMPARE(column(result, 2, 3), (std::vector<int>{0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255}));
QCOMPARE(column(result, 2, 0)[4], 0);
}
void ScanlinesTests::everyBandOfATallImageIsDrawn()
{
// 150 two-row lines: 32 bands of 5, two empty.
const QImage image = filled(3, 300, Qt::white);
QCOMPARE(column(scanlines(2).apply(image), 1), repeated({241, 241}, 150));
// 70 lines of 8: bands of 3, the last one.
const std::vector<int> tall = column(scanlines(8).apply(filled(3, 560, Qt::white)), 1);
QCOMPARE(tall, repeated({0, 255, 255, 255, 255, 255, 255, 0}, 70));
}
void ScanlinesTests::bandsDrawAsOneBandDoes()
{
// One band of 62 rows, 32 of 300: tops agree.
QImage noise = BrushRaster::context(7, 300, false);
for (int y = 0; y < 300; ++y)
for (int x = 0; x < 7; ++x)
noise.setPixelColor(x, y, QColor((x * 37 + y * 11) % 256, (x * 91 + y * 53) % 256, (y * 29) % 256, 128 + (x + y) % 128));
DitherSettings settings = scanlines(2);
settings.colors = DitherColors::original;
settings.dots = 50;
settings.wobble = 5;
const QImage whole = settings.apply(noise), one = settings.apply(noise.copy(0, 0, 7, 62));
QCOMPARE(whole.copy(0, 0, 7, 62), one);
}
void ScanlinesTests::theGlowIsTheBlurredLightAtItsAmount()
// Swift's Core Image blur: the twin's within a few levels.
void ScanlinesTests::theGlowIsTheBlurredLightEasedNearWhite()
{
QImage image = filled(64, 160, Qt::black);
QPainter(&image).fillRect(QRect(0, 0, 32, 40), Qt::white);
for (const double glow : {20.0, 100.0}) {
// Three levels of the shrink's blur, times 2.5.
const int worst = glowError(image, glow);
const QImage lines = render(image, [](ScanlinesSettings &s) { s.lineSpacing = 8; });
const QImage result = render(image, [glow](ScanlinesSettings &s) { s.lineSpacing = 8, s.glow = glow; });
const QImage blurred = PixelAdjust::gaussianBlur(lines, 8 * 3 + 3, true);
const float amount = float(glow / 100 * 2.5);
int worst = 0;
for (int y = 0; y < image.height(); ++y) {
for (int x = 0; x < image.width(); ++x) {
float value = lines.constScanLine(y)[x * 4];
const float added = blurred.constScanLine(y)[x * 4] * amount, room = (255 - value) * 0.7f;
if (room > 0)
value += room * (1 - std::exp(-added / room));
worst = std::max(worst, std::abs(result.constScanLine(y)[x * 4] - int(std::lround(std::min(255.0f, value)))));
}
}
QVERIFY2(worst <= 8, qPrintable(QString::number(worst)));
}
// Below the light, at amount 0.5, it fades.
const std::vector<int> fading = column(scanlines(8, 20).apply(image), 16);
QVERIFY(fading[48] > 20 && fading[48] < fading[40] && fading[120] < 3);
}
void ScanlinesTests::theGlowScalesBackExactlyOffTheStep()
void ScanlinesTests::theFilterIsListedAndPreviewsWhole()
{
// 73 plus two 84-point pads is off the 6 step.
QImage image = filled(73, 73, Qt::black);
QPainter(&image).fillRect(QRect(0, 0, 40, 40), Qt::white);
const int worst = glowError(image, 100);
QVERIFY2(worst <= 8, qPrintable(QString::number(worst)));
QCOMPARE(rawValue(FilterKind::scanlines), QString("Scanlines"));
const auto at = std::find(allFilterKinds.begin(), allFilterKinds.end(), FilterKind::scanlines);
QVERIFY(at != allFilterKinds.end() && *(at - 1) == FilterKind::dither && *(at + 1) == FilterKind::tonalContrast);
QVERIFY(!isImageAdjustment(FilterKind::scanlines) && !isAutomatic(FilterKind::scanlines) && repeatsAsLastFilter(FilterKind::scanlines));
EditorSession session;
session.createDocument(3000, 20);
const QImage wide = filled(3000, 20, Qt::white);
session.insert(ImportedImage(wide, wide, QStringLiteral("Wide")));
session.beginFilter(FilterKind::scanlines);
QCOMPARE(session.filterEdit().value().previewScale, 1.0);
// Its commit is one step that draws the lines.
QVERIFY(session.filterEdit().value().settings.scanlines == ScanlinesSettings());
bool done = false;
session.commitFilter([&done] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(session.history.undoName(), QString("Scanlines"));
const QImage drawn = session.document().value().layers.front().asset.value().image();
// Beam 1.4 round the middle 2: row 0 covers 0.4.
QCOMPARE(column(drawn, 5)[0], int(std::lround(255 * 1.35f * 0.4f)));
QCOMPARE(column(drawn, 5)[2], 255);
}
void ScanlinesTests::theGlowNeverPassesAlpha()
{
QImage image = BrushRaster::context(16, 32, false);
image.fill(QColor(255, 255, 255, 128));
const QImage result = scanlines(4, 100).apply(image);
for (int y = 0; y < 32; ++y) {
for (int x = 0; x < 16; ++x) {
const uchar *at = result.constScanLine(y) + x * 4;
QCOMPARE(int(at[3]), 128);
QVERIFY(at[0] <= 128 && at[1] <= 128 && at[2] <= 128);
}
}
QCOMPARE(int(result.constScanLine(0)[0]), 128);
}
void ScanlinesTests::theSheetShowsScanlinesRows()
void ScanlinesTests::theSheetShowsSwiftsRows()
{
Shown shown;
QCOMPARE(shown.rows(), (QStringList{"Style|", "Line Spacing|How far apart the screen's lines are",
"Glow|Light blooming around the lines, like a CRT's phosphors", "Dots|Break the lines into glowing beads",
"Wobble|Make the lines waver sideways down the screen, like a CRT losing sync",
"Density|More ink (darker) or less before dithering", "Contrast|", "Colors|"}));
// A value mid-range sits mid-travel; each unit is Swift's.
const std::tuple<const char *, double DitherSettings::*, double, QString> middles[] = {
{"lineSpacing", &DitherSettings::lineSpacing, 17, "px"}, {"glow", &DitherSettings::glow, 50, "%"},
{"dots", &DitherSettings::dots, 50, "%"}, {"wobble", &DitherSettings::wobble, 32, "px"}};
for (const auto &[name, setting, middle, unit] : middles) {
FilterSettings settings = shown.session.filterEdit().value().settings;
settings.dither.*setting = middle;
shown.session.updateFilter(settings, true);
QCOMPARE(shown.rows(),
(QStringList{"Line Spacing|How far apart the screen's lines are", "Thickness|How much of the gap a bright line fills; dimmer parts draw it thinner",
"Glow|Light blooming around the lines, like a CRT's phosphors",
"Dots|Break the lines into beads where the picture is darker than this, blending into solid lines where it's brighter",
"Displace|Lift the lines where the picture is bright, or lower them when negative, so they swell into its shapes",
"Smoothness|How far the brightness is smoothed before it displaces the lines, from sharp ridges to rounded hills",
"Threshold|Draw no line where the picture is darker than this, leaving the screen dark",
"Wobble|Make the lines waver sideways down the screen, like a CRT losing sync",
"Color Split|Move the red and blue apart, for colored fringes on the lines", "Density|Darker or lighter before the lines are drawn",
"Contrast|", "Black Level|How bright the lines are where the picture is black, so they show even over black", "Colors|"}));
using Field = double ScanlinesSettings::*;
const std::tuple<const char *, Field, double, double, QString> sliders[] = {
{"lineSpacing", &ScanlinesSettings::lineSpacing, 2, 32, "px"}, {"thickness", &ScanlinesSettings::thickness, 5, 100, "%"},
{"glow", &ScanlinesSettings::glow, 0, 100, "%"}, {"dots", &ScanlinesSettings::dots, 0, 100, "%"},
{"displace", &ScanlinesSettings::displace, -100, 100, "px"}, {"smoothness", &ScanlinesSettings::smoothness, 0, 100, "%"},
{"threshold", &ScanlinesSettings::threshold, 0, 100, "%"}, {"wobble", &ScanlinesSettings::wobble, 0, 64, "px"},
{"colorSplit", &ScanlinesSettings::split, 0, 16, "px"}, {"density", &ScanlinesSettings::density, -100, 100, ""},
{"contrast", &ScanlinesSettings::contrast, -100, 100, ""}, {"blackLevel", &ScanlinesSettings::blackLevel, 0, 100, "%"}};
for (const auto &[name, field, low, high, unit] : sliders) {
QSlider *slider = shown.sheet->findChild<QSlider *>(QString::fromLatin1(name) + "Slider");
QLineEdit *field = shown.sheet->findChild<QLineEdit *>(QString::fromLatin1(name) + "Field");
QVERIFY2(slider && field, name);
QCOMPARE(slider->value(), slider->maximum() / 2);
QCOMPARE(slider->minimum(), 0);
const QList<QLabel *> labels = field->parentWidget()->findChildren<QLabel *>(QString(), Qt::FindDirectChildrenOnly);
QCOMPARE(labels.last()->text(), unit);
}
// Each slider's travel reaches its range's ends.
const std::tuple<const char *, double DitherSettings::*, double, double> sliders[] = {
{"lineSpacingSlider", &DitherSettings::lineSpacing, 2, 32}, {"glowSlider", &DitherSettings::glow, 0, 100},
{"dotsSlider", &DitherSettings::dots, 0, 100}, {"wobbleSlider", &DitherSettings::wobble, 0, 64}};
for (const auto &[name, setting, low, high] : sliders) {
QSlider *slider = shown.sheet->findChild<QSlider *>(QString::fromLatin1(name));
QVERIFY2(slider, name);
QLineEdit *edit = shown.sheet->findChild<QLineEdit *>(QString::fromLatin1(name) + "Field");
QVERIFY2(slider && edit, name);
slider->setValue(slider->maximum());
QCOMPARE(shown.dither().*setting, high);
QCOMPARE(shown.lines().*field, high);
slider->setValue(slider->minimum());
QCOMPARE(shown.dither().*setting, low);
QCOMPARE(shown.lines().*field, low);
QCOMPARE(edit->parentWidget()->findChildren<QLabel *>(QString(), Qt::FindDirectChildrenOnly).last()->text(), unit);
}
// Pixel Size and Pixel Shape stay away under Scanlines.
FilterSettings settings = shown.session.filterEdit().value().settings;
settings.dither.pixelSize = 4;
shown.session.updateFilter(settings, true);
QVERIFY(!shown.rows().join(',').contains("Pixel"));
settings.dither.style = DitherStyle::bayer4;
shown.session.updateFilter(settings, true);
QVERIFY(shown.rows().contains("Pixel Shape|Draw each chunky pixel as a solid square, or as a round dot like a dot-matrix screen"));
QVERIFY(!shown.rows().join(',').contains("Glow"));
}
void ScanlinesTests::theRowsWriteTheirSettings()
{
Shown shown;
const std::tuple<const char *, double DitherSettings::*, const char *, double> rows[] = {
{"lineSpacingField", &DitherSettings::lineSpacing, "12", 12}, {"glowField", &DitherSettings::glow, "60", 60},
{"dotsField", &DitherSettings::dots, "25", 25}, {"wobbleField", &DitherSettings::wobble, "9", 9}};
for (const auto &[name, setting, typed, expected] : rows) {
const DitherSettings before = shown.dither();
QLineEdit *field = shown.sheet->findChild<QLineEdit *>(QString::fromLatin1(name));
QVERIFY2(field, name);
field->setFocus();
field->selectAll();
QTest::keyClicks(field, QString::fromLatin1(typed));
QTest::keyClick(field, Qt::Key_Return);
DitherSettings wanted = before;
wanted.*setting = expected;
QVERIFY2(shown.dither() == wanted, name);
const std::tuple<const char *, double ScanlinesSettings::*, const char *, double> rows[] = {
{"lineSpacingField", &ScanlinesSettings::lineSpacing, "12", 12}, {"displaceField", &ScanlinesSettings::displace, "-30", -30},
{"colorSplitField", &ScanlinesSettings::split, "5", 5}, {"blackLevelField", &ScanlinesSettings::blackLevel, "40", 40},
{"smoothnessField", &ScanlinesSettings::smoothness, "10", 10}, {"thicknessField", &ScanlinesSettings::thickness, "55", 55}};
for (const auto &[name, field, typed, expected] : rows) {
ScanlinesSettings wanted = shown.lines();
QLineEdit *edit = shown.sheet->findChild<QLineEdit *>(QString::fromLatin1(name));
QVERIFY2(edit, name);
edit->setFocus();
edit->selectAll();
QTest::keyClicks(edit, QString::fromLatin1(typed));
QTest::keyClick(edit, Qt::Key_Return);
wanted.*field = expected;
QVERIFY2(shown.lines() == wanted, name);
}
}
void ScanlinesTests::twoColorsPickTheScreenAndTheLines()
{
Shown shown;
auto *colors = shown.sheet->findChild<QComboBox *>(QStringLiteral("scanlinesColors"));
auto *dark = shown.sheet->findChild<SwatchButton *>(QStringLiteral("scanlinesDark"));
auto *light = shown.sheet->findChild<SwatchButton *>(QStringLiteral("scanlinesLight"));
QVERIFY(colors && dark && light && !dark->isVisible());
QVERIFY(dark->toolTip() == QString("Choose the screen's color") && light->toolTip() == QString("Choose the lines' color"));
colors->activated(int(DitherColors::twoColors));
QVERIFY(shown.lines().colors == DitherColors::twoColors && dark->isVisible() && light->isVisible());
// The menu follows the settings, however they change.
FilterSettings original = shown.session.filterEdit().value().settings;
original.scanlines.colors = DitherColors::original;
shown.session.updateFilter(original, true);
QVERIFY(colors->currentIndex() == int(DitherColors::original) && !dark->isVisible());
original.scanlines.colors = DitherColors::twoColors;
shown.session.updateFilter(original, true);
QCOMPARE(colors->currentIndex(), int(DitherColors::twoColors));
// The picker starts from the lines' own colour.
FilterSettings settings = shown.session.filterEdit().value().settings;
settings.scanlines.light = AdjustmentColor(0, 1, 0), settings.scanlines.dark = AdjustmentColor(0.2, 0, 0);
shown.session.updateFilter(settings, true);
light->click();
QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (Light Color)"));
QVERIFY(shown.session.colorPicker().value().original == (PaletteColor{0, 1, 0}));
struct Paints : QObject {
int count = 0;
bool eventFilter(QObject *, QEvent *event) override { return count += event->type() == QEvent::Paint, false; }
} paints;
QCoreApplication::processEvents();
light->installEventFilter(&paints);
shown.session.setColorPickerHSB(PickerHSB(PaletteColor{1, 0, 0}));
shown.session.previewDitherColor();
// A new colour repaints the swatch.
QTRY_VERIFY(paints.count > 0);
QVERIFY(shown.lines().light == AdjustmentColor(1, 0, 0));
QVERIFY(shown.session.filterEdit().value().settings.dither.light == AdjustmentColor(1, 1, 1));
QCOMPARE(light->grab().toImage().pixelColor(12, 12), QColor(255, 0, 0));
shown.session.closeColorPicker(false);
QVERIFY(shown.lines().light == AdjustmentColor(0, 1, 0));
dark->click();
QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (Dark Color)"));
QVERIFY(shown.session.colorPicker().value().original == (PaletteColor{0.2, 0, 0}));
shown.session.setColorPickerHSB(PickerHSB(PaletteColor{0, 0, 1}));
shown.session.closeColorPicker(true);
QVERIFY(shown.lines().dark == AdjustmentColor(0, 0, 1));
}
QTEST_MAIN(ScanlinesTests)
#include "ScanlinesTests.moc"
+2
View File
@@ -183,6 +183,8 @@ compositor_test(ProjectWorkspaceFlowTests)
compositor_test(QuitWhileEditingTextTests)
compositor_test(QuitPendingEditsTests)
compositor_test(ScanlinesTests)
# It checks the kernel against Swift's own bytes.
target_include_directories(ScanlinesTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(CameraRawCurveTests)
compositor_test(CameraRawCurveGraphTests)
compositor_test(OpenRecentTests)