From bc9d43f1e8654e636aaf0c62319768d0ffe02eca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 30 Sep 2026 07:55:57 -0400 Subject: [PATCH] =?UTF-8?q?Filter=20=E2=80=BA=20Dither,=20as=20Compositor?= =?UTF-8?q?=201.3.3=20(12.15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + CMakeLists.txt | 7 +- TASKS.md | 2 +- docs/writing-comp-files.md | 2 +- kernels/DitherPixels.c | 278 ++++++++++++++++++ kernels/DitherPixels.h | 45 +++ src/Document/ColorPalette+Session.h | 4 + src/Document/ColorPalette.cpp | 31 ++ src/Document/ColorPalette.h | 4 +- src/Document/Dither.cpp | 236 +++++++++++++++ src/Document/Dither.h | 73 +++++ src/Document/Filters+Session.cpp | 12 +- src/Document/Filters.cpp | 8 +- src/Document/Filters.h | 7 +- src/UI/FilterSheet+Dither.cpp | 136 +++++++++ src/UI/FilterSheet.cpp | 8 +- src/UI/FilterSheet.h | 7 + tests/CompositorMenusTests.cpp | 2 +- tests/DitherKernelTests.cpp | 282 ++++++++++++++++++ tests/DitherSheetTests.cpp | 440 ++++++++++++++++++++++++++++ tests/DitherTests.cpp | 363 +++++++++++++++++++++++ tests/FilterFailureTests.cpp | 31 ++ tests/FilterModelTests.cpp | 5 +- tests/FinishingFilterTests.cpp | 3 +- tests/Tests.cmake | 3 + 25 files changed, 1973 insertions(+), 17 deletions(-) create mode 100644 kernels/DitherPixels.c create mode 100644 kernels/DitherPixels.h create mode 100644 src/Document/Dither.cpp create mode 100644 src/Document/Dither.h create mode 100644 src/UI/FilterSheet+Dither.cpp create mode 100644 tests/DitherKernelTests.cpp create mode 100644 tests/DitherSheetTests.cpp create mode 100644 tests/DitherTests.cpp diff --git a/AGENTS.md b/AGENTS.md index 77ba28e..d91fdd8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,6 +153,7 @@ Platform adaptations, each forced by a missing Qt analog: - Coloured filter sliders (12.14c, Swift 1.3.2: `6b709b4`, `c8ed9ba`, `0c969af`): `CameraRawSliderTrack` gains Swift's `opposing` (two colours, `from` and `to`) and `spectrum` (thirteen stops every 30 degrees, the hue centred), and `colors` returns a list spaced evenly along the track, where Swift returns an array. `CameraRawSlider::reshape` is Swift's `updateNSView`: new bounds and track, the value shown again unless a drag holds the knob; `track()` reads the track. The filter sheet's `control` takes an optional track (`FilterSheet+Controls.cpp`, since `FilterSheet.cpp` would pass 500 lines): with one the row is a `CameraRawSlider` over the same thousand steps, linear (a logarithmic coloured row is a `logic_error`), helped ". Double-click to reset.", rounding what it sets to the field's decimals as the plain slider does, and a double click on the knob or the title puts that one setting back to the filter's default (`resetting`, Swift's static), Preview as it was; a title resets only for the left button and while enabled, as a SwiftUI tap gesture does; the title's filter is installed after the scrub's, so it runs first and passes the press on to the scrub, as Swift's drag still sees it. Black & White's families take `luminance` at 0, 60, 120, 180, 240 and 300 degrees, Tint's Hue a plain track (still reset by a double click, as Swift's `.plain` is a track) and its Saturation `saturation` at the tint's hue, following it; Color Balance's nine take Swift's three `opposing` tracks (`cyanRedTrack`, `magentaGreenTrack`, `yellowBlueTrack`). Hue/Saturation's three rows are `CameraRawSlider`s whose bounds and tracks follow Colorize and the range: Hue the spectrum round the range's middle (its index in `colorRanges` times 60, Master red; Colorize 180, red to red), Saturation the chroma track for Master, `saturation` at the range's hue, or at the chosen hue under Colorize, Lightness black to white; the slider sets whole numbers, leaving the knob's travel when the whole number is unchanged (as the filter sheet's rows do), so arrow keys walk it, and a double click on the title or the knob puts that value back to Colorize's start or none, Preview as it was. The sheet's spectrum editor moved to `HueSaturationSheet+Spectrum.cpp`, keeping the file under 500 lines. Swift's `LabelWidthKey` is `alignTitles`: on every synchronize each control's title is fixed to the width of the widest title among the rows shown (a row under a hidden `if` does not count), sixty at least. A fix beyond Swift, found by the item's tests: a `PickerField` commits on `FocusOut`, and a sheet destroyed while one of its entries held the keys (a panel shown alone at quit, a test's sheet) sent that `FocusOut` from `~QWidget`, after the sheet's own members were gone, so the commit read freed memory (a crash, or luck). `releaseFocus` (`ColorPickerSheet.h`, beside `PickerField`) takes the keys off the owner's focused entry, which commits as hiding would; every owner of a `PickerField` calls it first in its destructor (`EffectsSheet`, `ColorPickerSheet`, `HueSaturationSheet`, `FilterSheet`, `ImageSizeSheet`, `LevelsSheet`, `CanvasSizeSheet`, `CameraRawRow`), and so does every widget whose entries' callbacks read its own members through rows it makes (`CameraRawControls`, `CameraRawCurveControls`, `CameraRawMixerControls`, `CameraRawDetailControls`, `CameraRawOpticsControls`, `CameraRawGeometryControls`, `CameraRawCalibrationControls`; the grading rows have no entries). `SheetTeardownTests` destroys each with its first entry typed in and names the owner's class as the entry loses the keys: past its own destructor the owner reads as `QWidget`, so a missing release fails whatever the memory holds; the colour sheets' typing lands in the session. A one-off AddressSanitizer build of Debug Qt, not kept as a target, showed each colour sheet fault without the fix (heap use after free in `FilterSheet` and `EffectsSheet`, Qt's "class destructor may have already run" in `HueSaturationSheet`, `LevelsSheet` and `ColorPickerSheet`) and none with it. Tests: `ColoredSliderTests` (Swift's `colorBalanceTracksRunFromEachColorToItsOpposite` and `resettingAColoredFilterSliderRestoresTheDefault`, the spectrum's stops, each row's track and help, the tint's saturation following its hue, a whole value from a drag, double clicks on titles and knobs resetting their own row with Preview left off and a plain filter's title changing nothing, titles aligned with and without Tint's rows, the row's layout, a reset replacing the field's typing, the spectrum's stops painted in place, a new track repainting a still knob, mid-drag and from Tint's hue, Swift's literal Color Balance ends, other buttons and a resting sheet refused, a reset that scrubs on, each of Color Balance's nine rows reset alone with Preview off, titles shrinking back), `HueSaturationSheetTests` (the tracks by range and Colorize, double clicks resetting to none or Colorize's start, a reset replacing the field's typing, arrow keys walking every slider in both modes without dropping typing, resets keeping Preview off, a range under Colorize keeping its tracks, other buttons and a resting sheet refused, a reset that scrubs on, the sliders' thousand steps), `CameraRawSliderTests::aReshapeLeavesADragAlone`, `CameraRawSliderTests` (two-colour tracks read through the list), `FilterControlsTests` (titles one width, sixty at least). - Colouring only the selected letters (12.14d1, Swift 1.3.2: `2bfc4ea`, `4503998`, `b5fe653`, `a1d56ac`): `LayerTextStyle` gains Swift's optional `colorRuns` (`LayerTextColorRun`: location and length in UTF-16 units as `qint64`, Swift's `Int`, and sRGB red, green and blue), valid when sorted, apart, not empty, within the content and within Int, with Swift's `color(at:)` (`color`), `setColor(_:in:)` (an empty span or the whole text recolours the base and drops the runs) and `replaceCharacters(in:withLength:)` (new units take the colour before them, or at the start the first letter's), both working through Swift's per-unit colours and joining neighbours of one colour. `TextSpan` is Swift's `NSRange`; `TextDraft.selection` sits last. `TypeTool.h` declares `PaletteColor` opaquely and `ColorPalette.h` includes it, because `ColorPickerState::EditedText` now holds the text's whole style (Swift's `(draftID, style)`). The manifest writes `colorRuns` only when set, each run's five keys required, and the store refuses runs before version 10, which saves now write (versions 1–10). `TextLines` gives each paragraph its runs as `QTextLayout` formats before laying it out, so a run's letters draw once in their colour, as CoreText's runs do: formats handed to `draw` are an overlay, which would leave the base colour in their antialiased edges. The palette follows Swift's: the foreground swatch and the Type bar's picker paint the draft's selection (`setDraftTextColor`), previews too; Cancel puts back the base and the runs the text had when the picker opened (`restoreDraftTextColors`), the runs only while the content is unchanged; the Type bar's picker now keeps that style as the palette's does; `typeColor` is the first selected letter's colour, or with a caret the letter before it; Fill (`recolorText`) recolours every letter, runs gone, even when the base colour already matches; the text defaults never keep runs, so new text starts in one colour. Swift's clearing of the runs when new text begins is left out as dead, since the defaults it starts from never hold runs. The inline editor is the twin's own, so Swift's `shouldChangeTextIn` bookkeeping sits in `publish`, which takes each edit's exact replacements (`Step`: the range taken, the new length) in order: typing and deleting, the clipboard (the preedit out, the selection replaced, the preedit back), input methods (the old preedit out, the selection taken and the commit's replacement as one step when the commit lands where the selection was, as NSTextView replaces it, the new preedit in, and a lone preedit over the selection one replacement for the colours, as `setMarkedText` makes it, the selection's removal still history; an update, a cursor's move or a commit of the preedit where it stands is one replacement of the marked text for the colours, as `setMarkedText` and `insertText` replace it whole, even with the same string, so a leading preedit keeps the colour it took) and undo and redo; each step moves the runs against the text's length as the steps before it left it, as Swift's content changes between its calls. Each undo entry keeps the plain steps from its text to the next newer state (`since`: preedits never count, a preedit left as text by a key does), and undo and redo replay their inverses newest first, as NSTextView undoes each replacement, so a letter put back takes the colour before it, as Swift's does; a guess from the text alone would put colours on the wrong twin of a repeated letter. The draft learns the selection on every publish and every move (`noteSelection`, Swift's `textViewDidChangeSelection`). Swift's reset of runs that no longer fit after an edit is left out: every edit moves them. The editor lays its lines out from the style without runs: its glyphs are clear, and the formats would paint them in their colours over the layer beneath. The caret takes the colour of the letter before it (Swift's `insertionPointColor`). Swift's see-through selection under the picker (`b5fe653`) needs nothing: the twin draws the selection translucent, in the inactive colour when the canvas lacks the keys, and `4503998` only adds a test. Equivalent mutants: the inherited colour's `colors[start]` for `colors.front()` (at the start they are one) and `TextLines`' clip of a run to its paragraph (Qt clips a format to its layout's text). Docs: `project-format.md` and `writing-comp-files.md` say version 10, as Swift's. Tests: `TextColorModelTests` (Swift's model and invalid runs, every channel checked, runs of one colour joining only where they meet), `TextColorRunsTests` (Swift's other two: the selection painted and reopened with version 9 refused, Cancel restoring; the swatch's colour, the Type bar's picker, Fill and new text, the manifest's keys, runs across paragraphs, the run's Codable refusals, Cancel over mixed colours for both pickers), `TextColorEditorTests` (the selection reaching the draft, runs through typing, deleting, undo and paste, twins of one letter through paste, commit, preedit, cut, undo, redo, a replacement by itself, paste and commit around a preedit, a commit over a selection and undo after a preedit, a coloured tail through redo, undone deletions, a commit with a preedit and a paste around one, a preedit over the first letter updated, moved and committed, the first letter's colour at the start for the swatch and the caret, the caret's colour, Swift's swatch keeping the selection, each run drawn without a fringe, the overlay drawing no glyphs, selected or not, with the keys or without), coloured rows in `TypedTextCanvasTests::textLooksTheSameWhileEditingAndOnceCommitted`, the store's version 10 rows in `ProjectManifestTests`, and version 10 across the store's tests. - Sampling and open text (12.14d2, Swift 1.3.2: `810c88a`, `d448625`): closing the colour picker, by OK or Cancel, while text is open asks for canvas focus (`closeColorPicker` counts `canvasFocusRequest` before its final signal), and `CanvasView::consumeFocusRequest` now hands the keys to the canvas with a draft open too, where the editor takes them, as Swift makes the text view first responder, so typing and Ctrl+Return reach the text after sampling from the canvas. Swift also takes a sampling click for an open panel before its window sees it, so the panel stays key; a window manager activates whatever window is clicked, and handing the panel the keys back at the press would take the canvas's focus and end the sampling it began, so the twin hands them back at the release, as Swift did before that commit (known difference). `canAdjust` (so `canAdjustColors`, `canVignette` and the fill) and `canInvert` refuse while text is open, since the editor draws that text, not its layer, and a preview of it would be wrong; the Image and Filter menus follow. Tests: `PickingTests::openTextTakesTheKeysBackFromThePicker` (the foreground, background and Type bar pickers, new and existing text, OK and Cancel, one request each, typing after and Ctrl+Return applying, any focus request, no request without text for any picker by OK or Cancel, the owner's field keeping the keys), `InlineTextDrawTests::aFreshDraftTakesTheKeysButSparesAField` (a request now takes the keys from a button, a modal dialog keeping them with text open, a tool panel's field keeping them while the canvas waits for its window's turn), `TextEditingGatesTests` (the gates and the entries with new and reopened text, open and closed, Levels, Hue/Saturation, filters and Invert refused with nothing changed, content-aware fill and Camera Raw, Vignette on an empty layer). +- The Dither filter (12.15, Swift 1.3.3: `f3cd4e9`, `71635e2`, `d0aae29`, `09ab78a`): `kernels/DitherPixels.c/.h` are Swift's, unchanged, built with the other kernels. `Document/Dither.h/.cpp` twin Swift's file: `DitherStyle`, `DitherPixelShape` and `DitherColors` by raw value in Swift's order (`allDitherStyles`, `allDitherPixelShapes`, `allDitherColors`; `ditherGroup` stands for Swift's `groups`, with `diffuses`, `hasTones`, `isHalftone` and `drawsMarks`); `DitherSettings` keeps Swift's fields, defaults and ranges, `normalized` rounds and clamps as Swift's (a value that is no number falls back) and keeps the first 64 graphemes of the characters that are no line break (Swift's `Character.isNewline`: LF, CR, CRLF, VT, FF, NEL, U+2028, U+2029). `apply` converts the image to premultiplied RGBA first. Chunky pixels are each block's area averaged exactly, a block past the image's edge counting its missing pixels as clear, where CoreGraphics draws the image at its high quality into a fractional rectangle (known difference: a box average, not CoreGraphics' filter); the small image is dithered and blown back up pixel for pixel, and a dot shape runs `dither_dots` with the dark colour or black for its gaps. `glyphs` draws each distinct grapheme of the characters into a gray map of the cell's size, white on black, centred on its ink (`QPainterPath::boundingRect`, Swift's `CTLineGetImageBounds`), in fontconfig's bold monospace at a whole pixel size of 1.15 cells (known difference: Swift's bold SF Mono at the exact size), filled by the winding rule so overlapping outlines (a letter and its combining accent) stay inked, as text rendering fills them (Qt's `addText` paths are odd-even), sorted by coverage, stably. A kernel that finds no memory is `ExportError(render)`, as is a source whose conversion finds none (`FilterFailureTests`); a glyph map (4 KB at most) cannot be starved alone under the address-space harness, so its refusal is untested, as is the glyphs' order when two coverages tie (no two characters are known to ink alike in every font). Equivalent mutant: the conversion's own null check (the small or working context of a null source refuses the same way). Making the small image gives the same pixels without it (averaged into the top left of a full-size copy whose clear rest the kernel skips), but its kernel planes would then be full size: `FilterFailureTests` dithers chunky 4000-pixel Original colours within the limit the unchunky case exceeds. Swift's `Set<Character>` treats canonically equivalent graphemes as one: the glyphs' seen set compares their NFC forms, and draws the first. Leaving the Characters field (`editingFinished`) shows what the settings kept of the typing. `FilterKind::dither` sits after Bloom / Glow, as Swift's menu has it; `FilterSettings.dither` normalizes with the rest; `PixelFilter::run` applies it at the job's own pixels; its previews are made at full size, as noise's and grain's are. The picker's `ColorPickerTarget` gains the `dither` kind and a `light` flag (Swift's `.dither(light:)`, titled "Color Picker (Dither Dark Color)" or "Light"), with `openDitherColorPicker`, `previewDitherColor` and `setDitherColor`; Cancel and OK of the filter close it, as they close the Gradient Map's and the Vignette's (`filterColour`). The sheet's `dither` (`UI/FilterSheet+Dither.cpp`) is Swift's `ditherControls`: Style as a menu with separators between its groups, Pixel Size, Cell Size (halftone and ASCII), Angle (halftone), Characters (ASCII: a monospace field that writes as typed and shows the settings while unfocused), Tones (diffusion and ordered), Diffusion (diffusion), Density, Contrast, Colors as a menu, the Dark and Light swatches (Two Colors), Pixel Shape (chunky pixels) and Light on Dark (marks), each with Swift's help; menus and swatches follow the settings through `m_follows`. The busy line now shows only for a commit or an automatic kind's preparation, Swift's rule against a quick preview's flicker. `e70da06` needs nothing: `main` shows the window before it hands the files named at launch to the workspace. `29f0bea`'s guide sentence joins `writing-comp-files.md`. Swift has no Dither tests: `DitherTests` (styles, groups and defaults, normalization, Bayer's exact pattern and tones, diffusion, two colours and the original ones, alpha, chunky blocks and their cut edges, dots, marks either way, glyphs) `DitherKernelTests` (each diffusion, Bayer matrix, halftone shape and Mac pattern by hand-worked pixels, Original and Two Colors marks either way, a premultiplied block average, colour-byte rounding, Original dots' black gaps and smoothed rims, each range's clamp, rounding and fallback, a lone CR, a straight-alpha source premultiplied first, a non-default cell size on the screen and in ASCII, an accent's overlap inked as text is) and `DitherSheetTests` (the kind's place and full-size preview, the commit, an ASCII commit on the worker, the two pickers and their refusals, the controls by style, the sliders' ends and a linear interior, the rows' order and help, a commit's busy line, the menus and characters) pin the code, with chunky pixels against an independent box average under diffusion, exact Bayer counts for Density and Contrast, the halftone angle, custom, blank and missing alphabets, the glyphs' size, smoothing, centring and canonical duplicates, the live picker preview, both swatches, the filter's OK with a picker open, each numeric row and the Characters field on leaving, and `CompositorMenusTests` the Filter menu. - 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`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/CMakeLists.txt b/CMakeLists.txt index 5af307a..844a827 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -56,6 +56,7 @@ add_library(compositor_core STATIC src/Document/Guides.cpp src/Document/ToolDefaults.cpp src/Document/ObjectSelection.cpp + src/Document/Dither.cpp src/Document/Filters.cpp src/Document/CameraRaw.cpp src/Document/CameraRaw+Session.cpp @@ -138,6 +139,7 @@ add_library(compositor_core STATIC src/UI/FilterSheet.cpp src/UI/FilterSheet+Adjustments.cpp src/UI/FilterSheet+Controls.cpp + src/UI/FilterSheet+Dither.cpp src/UI/FilterSheet+Finishing.cpp src/UI/FloatingPanel.cpp src/UI/KeyboardShortcuts.cpp @@ -221,9 +223,10 @@ add_library(compositor_core STATIC # Shared macOS pixel kernels, always optimized. set(OMAPHOTO_KERNELS ${CMAKE_CURRENT_SOURCE_DIR}/kernels) target_sources(compositor_core PRIVATE ${OMAPHOTO_KERNELS}/AdjustPixels.c ${OMAPHOTO_KERNELS}/BrushPixels.c ${OMAPHOTO_KERNELS}/WandPixels.c ${OMAPHOTO_KERNELS}/ContentFill.c - ${OMAPHOTO_KERNELS}/HealPixels.c ${OMAPHOTO_KERNELS}/LevelsPixels.c ${OMAPHOTO_KERNELS}/NoisePixels.c ${OMAPHOTO_KERNELS}/LensPixels.c) -set_source_files_properties(${OMAPHOTO_KERNELS}/AdjustPixels.c ${OMAPHOTO_KERNELS}/BrushPixels.c ${OMAPHOTO_KERNELS}/WandPixels.c ${OMAPHOTO_KERNELS}/ContentFill.c ${OMAPHOTO_KERNELS}/HealPixels.c ${OMAPHOTO_KERNELS}/LevelsPixels.c ${OMAPHOTO_KERNELS}/NoisePixels.c ${OMAPHOTO_KERNELS}/LensPixels.c + ${OMAPHOTO_KERNELS}/DitherPixels.c) +set_source_files_properties(${OMAPHOTO_KERNELS}/AdjustPixels.c ${OMAPHOTO_KERNELS}/BrushPixels.c ${OMAPHOTO_KERNELS}/WandPixels.c ${OMAPHOTO_KERNELS}/ContentFill.c + ${OMAPHOTO_KERNELS}/HealPixels.c ${OMAPHOTO_KERNELS}/LevelsPixels.c ${OMAPHOTO_KERNELS}/NoisePixels.c ${OMAPHOTO_KERNELS}/LensPixels.c ${OMAPHOTO_KERNELS}/DitherPixels.c PROPERTIES COMPILE_OPTIONS -O2) # Swift's kernel warns so through 1.3.3, unchanged. set_property(SOURCE ${OMAPHOTO_KERNELS}/AdjustPixels.c APPEND PROPERTY COMPILE_OPTIONS -Wno-unused-parameter -Wno-misleading-indentation) diff --git a/TASKS.md b/TASKS.md index e1ea8f8..385995e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -394,7 +394,7 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i - [x] 12.14c Coloured slider tracks with double-click reset in Black & White, Color Balance and Hue/Saturation, filter sliders lined up (`6b709b4`, `c8ed9ba`, `0c969af`). A fix beyond Swift found by its tests: a sheet or Camera Raw group destroyed while an entry held the keys committed into freed members (`releaseFocus`). Codex review: passed in round 3; round 1 found Camera Raw groups still unsafe to destroy, Hue/Saturation's arrow keys stuck on rounding and right double clicks resetting, with test gaps; round 2 an untested repaint and two stale notes. - [x] 12.14d1 Colouring only the selected letters (`2bfc4ea`, `4503998`, `b5fe653`, `a1d56ac`): colour runs in the text style, the manifest's version 10, the runs drawn and kept through edits, the palette, picker and Type bar paint the selection, the swatch follows the caret, the selection stays see-through under the picker. Codex review: 3 rounds; round 1 found colours guessed onto the wrong twin letter (now exact steps through every path, undo included) and run glyphs drawn in the clear overlay, with test gaps; round 2 a coloured tail lost across multi-step edits and a preedit over a selection inheriting wrongly; round 3 a leading preedit losing its colour on update or commit and an untested start position; fixed and closed after the third. - [x] 12.14d2 Sampling keeping a panel's focus and the keys going back to open text (`810c88a`), filters, adjustments and Invert waiting for text editing to finish (`d448625`). Sampling's panel focus is a known difference: the window manager activates the clicked window, and refocusing at the press would end the sampling, so the keys go back at the release. Codex review: 3 rounds, test gaps only (reopened text, empty-layer Vignette, both pickers and the background's, the modal guard, a tool panel keeping its keys, no request without text); fixed and closed after the third. -- [ ] 12.15 Version 1.3.3: the Dither filter. +- [x] 12.15 Version 1.3.3: the Dither filter (`f3cd4e9`, `71635e2`, `d0aae29`, `09ab78a`): `DitherPixels.c`, `DitherSettings` with its styles, chunky pixels and their shape, colours and ASCII glyphs, the filter kind in the Filter menu under Bloom / Glow, full-size previews, the picker's two Dither colours, the sheet's controls and swatches, the busy line shown only for the slow filters; the guide's reload note (`29f0bea`); `e70da06` (a launch to open a file shows its window) judged. Codex review: 3 rounds; round 1 found the Characters field stale after normalization, canonically equal characters drawn twice and a stale test, with test gaps; round 3 found glyph paths filled odd-even (overlapping outlines lost their ink); the rest test gaps; fixed and closed after the third. - [ ] 12.16 Release OmaPhoto 1.3.3, the version Compositor carries (the user's decision, 2026-09-26: OmaPhoto's version follows Compositor's): README's features, AGENTS.md, the distro checks, packages, the install scripts rerun. - [ ] 11.5 End-to-end acceptance run with screenshots, README Linux section, `AGENTS.md` sync, MVP acceptance table. diff --git a/docs/writing-comp-files.md b/docs/writing-comp-files.md index c12b581..877ece7 100644 --- a/docs/writing-comp-files.md +++ b/docs/writing-comp-files.md @@ -89,7 +89,7 @@ Remove images you no longer reference once the manifest no longer lists them. - A reload keeps the zoom, scroll and selection, but clears undo, as reopening a file does. - If the person has unsaved changes of their own, OmaPhoto asks them to revert to your version or keep theirs, and never replaces their work silently. - A write that fails to load is ignored until the next change, so a mistake you then fix will still show up. -- Changes are noticed from the manifest's contents and from each image's name and size. Rewriting a PNG with different pixels changes its size in practice, but if you replace an image with one of exactly the same byte size, also change the manifest's bytes (any valid change will do, even added whitespace): rewriting identical bytes is not noticed. +- Changes are noticed from the manifest's contents and from each image's name and size, not from when files were written. Rewriting a PNG with different pixels changes its size in practice. If you replace an image with one of exactly the same byte size, also make a change to the manifest, such as renaming the layer (any change to its bytes will do, even added whitespace); writing identical manifest bytes back isn't enough. - Replacing the whole project folder at once is noticed too. A populated folder cannot be renamed over another on Linux; exchange the two instead with `renameat2(RENAME_EXCHANGE)` (Python's `os.rename` cannot), then remove the old one. ## Masks diff --git a/kernels/DitherPixels.c b/kernels/DitherPixels.c new file mode 100644 index 0000000..bd871bb --- /dev/null +++ b/kernels/DitherPixels.c @@ -0,0 +1,278 @@ +#include "DitherPixels.h" +#include <math.h> +#include <stdlib.h> + +static inline float clamp01(float v) { return v < 0 ? 0 : v > 1 ? 1 : v; } + +// Density darkens (positive) or lightens as a gamma, so black and white stay put; contrast pivots on mid gray. +static inline float adjust_tone(float v, float gamma, float contrast) { + v = powf(clamp01(v), gamma); + return clamp01((v - 0.5f) * contrast + 0.5f); +} + +// One error-diffusion kernel: neighbors to the right on this row and below, with their weights over `divisor`. +typedef struct { int dx, dy, weight; } Tap; +typedef struct { const Tap *taps; int count; float divisor; } Kernel; + +static const Tap atkinson[] = { {1,0,1}, {2,0,1}, {-1,1,1}, {0,1,1}, {1,1,1}, {0,2,1} }; +static const Tap floyd[] = { {1,0,7}, {-1,1,3}, {0,1,5}, {1,1,1} }; + +// Atkinson passes on only six eighths of the error, which is what gives the Mac's crisp, contrasty look. +static Kernel kernel_for(int style) { + return style == DITHER_ATKINSON ? (Kernel){ atkinson, 6, 8 } : (Kernel){ floyd, 4, 16 }; +} + +static inline float quantize(float v, int levels) { + float steps = (float)(levels - 1); + return roundf(clamp01(v) * steps) / steps; +} + +// Diffuses each plane in serpentine order, so the error's drift doesn't streak to one side. +static void diffuse(float *plane, const uint8_t *alpha, size_t width, size_t height, const DitherParams *p) { + Kernel k = kernel_for(p->style); + for (size_t y = 0; y < height; ++y) { + int reverse = (int)(y & 1); + for (size_t i = 0; i < width; ++i) { + size_t x = reverse ? width - 1 - i : i; + size_t at = y * width + x; + if (!alpha[at]) continue; + float old = plane[at], q = quantize(old, p->levels); + plane[at] = q; + float error = (old - q) * p->diffusion / k.divisor; + for (int t = 0; t < k.count; ++t) { + long nx = (long)x + (reverse ? -k.taps[t].dx : k.taps[t].dx), ny = (long)y + k.taps[t].dy; + if (nx < 0 || nx >= (long)width || ny >= (long)height) continue; + plane[(size_t)ny * width + (size_t)nx] += error * (float)k.taps[t].weight; + } + } + } +} + +static const uint8_t bayer8[64] = { + 0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26, + 12, 44, 4, 36, 14, 46, 6, 38, 60, 28, 52, 20, 62, 30, 54, 22, + 3, 35, 11, 43, 1, 33, 9, 41, 51, 19, 59, 27, 49, 17, 57, 25, + 15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29, 53, 21, +}; + +// The ordered threshold for a pixel, in [0, 1). Smaller Bayer matrices are the top-left corners of the 8 × 8 one, +// rescaled, which is how the recursive construction nests them. +static inline float ordered_threshold(int style, size_t x, size_t y) { + switch (style) { + case DITHER_BAYER_2: { static const uint8_t m[4] = { 0, 2, 3, 1 }; return ((float)m[(y & 1) * 2 + (x & 1)] + 0.5f) / 4; } + case DITHER_BAYER_4: { + static const uint8_t m[16] = { 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5 }; + return ((float)m[(y & 3) * 4 + (x & 3)] + 0.5f) / 16; + } + default: return ((float)bayer8[(y & 7) * 8 + (x & 7)] + 0.5f) / 64; + } +} + +static inline float ordered(float v, float threshold, int levels) { + float steps = (float)(levels - 1); + float q = floorf(clamp01(v) * steps + threshold); + return (q > steps ? steps : q) / steps; +} + +// How much of a halftone cell a point must be covered by before it's marked, for each screen shape. `u` and `v` +// run from −0.5 to 0.5 across the cell; the shapes grow from its middle as coverage rises. +static inline float spot(int style, float u, float v) { + float au = fabsf(u), av = fabsf(v); + switch (style) { + case DITHER_DOTS: return 3.14159265f * (u * u + v * v); + case DITHER_LINES: return av * 2; + default: return au + av; + } +} + +// Old Mac fill patterns, 8 × 8, one byte per row with the leftmost pixel in the top bit, from sparsest to fullest. +static const uint8_t patterns[][8] = { + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, + { 0x80, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00 }, + { 0x88, 0x00, 0x22, 0x00, 0x88, 0x00, 0x22, 0x00 }, + { 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01 }, + { 0x88, 0x22, 0x88, 0x22, 0x88, 0x22, 0x88, 0x22 }, + { 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00 }, + { 0x11, 0x22, 0x44, 0x88, 0x11, 0x22, 0x44, 0x88 }, + { 0xAA, 0x00, 0xAA, 0x00, 0xAA, 0x00, 0xAA, 0x00 }, + { 0x88, 0x55, 0x22, 0x55, 0x88, 0x55, 0x22, 0x55 }, + { 0xFF, 0x80, 0x80, 0x80, 0xFF, 0x08, 0x08, 0x08 }, + { 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55 }, + { 0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81 }, + { 0x77, 0xAA, 0xDD, 0xAA, 0x77, 0xAA, 0xDD, 0xAA }, + { 0xEE, 0xDD, 0xBB, 0x77, 0xEE, 0xDD, 0xBB, 0x77 }, + { 0x77, 0xFF, 0xDD, 0xFF, 0x77, 0xFF, 0xDD, 0xFF }, + { 0x7F, 0xFF, 0xFF, 0xFF, 0xF7, 0xFF, 0xFF, 0xFF }, + { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }, +}; +static const int patternCount = (int)(sizeof patterns / sizeof patterns[0]); + +static inline void write_pixel(uint8_t *px, float r, float g, float b) { + float a = (float)px[3] / 255.0f; + px[0] = (uint8_t)lroundf(clamp01(r) * a * 255.0f); + px[1] = (uint8_t)lroundf(clamp01(g) * a * 255.0f); + px[2] = (uint8_t)lroundf(clamp01(b) * a * 255.0f); +} + +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; + int planes = p->originalColors ? 3 : 1; + float *tone = malloc(count * sizeof(float) * (size_t)planes); + uint8_t *alpha = malloc(count); + // The image's own colors, unadjusted: halftone dots and glyphs take them in Original mode. + float *source = p->originalColors ? malloc(count * sizeof(float) * 3) : NULL; + if (!tone || !alpha || (p->originalColors && !source)) { free(tone); free(alpha); free(source); 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; + for (size_t y = 0; y < height; ++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 (p->originalColors) { + tone[at] = adjust_tone(r, gamma, contrast); + tone[count + at] = adjust_tone(g, gamma, contrast); + tone[2 * count + at] = adjust_tone(b, gamma, contrast); + source[at * 3] = r; source[at * 3 + 1] = g; source[at * 3 + 2] = b; + } else { + tone[at] = adjust_tone(0.2126f * r + 0.7152f * g + 0.0722f * b, gamma, contrast); + } + } + } + + 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 }; + int style = p->style; + int levels = p->levels < 2 ? 2 : p->levels > 16 ? 16 : p->levels; + + if (style <= DITHER_BAYER_8) { + // Diffusion and ordered dithering: each plane is quantized to `levels` tones, then mapped to colors. + if (style <= DITHER_FLOYD_STEINBERG) { + DitherParams local = *p; + local.levels = levels; + for (int c = 0; c < planes; ++c) diffuse(tone + (size_t)c * count, alpha, width, height, &local); + } else { + for (int c = 0; c < planes; ++c) { + float *plane = tone + (size_t)c * count; + for (size_t y = 0; y < height; ++y) + for (size_t x = 0; x < width; ++x) { + size_t at = y * width + x; + if (alpha[at]) plane[at] = ordered(plane[at], ordered_threshold(style, x, y), levels); + } + } + } + for (size_t y = 0; y < height; ++y) { + uint8_t *row = rgba + y * stride; + for (size_t x = 0; x < width; ++x) { + size_t at = y * width + x; + if (!alpha[at]) continue; + if (p->originalColors) { + write_pixel(row + x * 4, tone[at], tone[count + at], tone[2 * count + at]); + } else { + float t = tone[at]; + write_pixel(row + x * 4, dark[0] + (light[0] - dark[0]) * t, dark[1] + (light[1] - dark[1]) * t, + dark[2] + (light[2] - dark[2]) * t); + } + } + } + } 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. + float *marks = p->originalColors ? malloc(count * sizeof(float)) : tone; + if (!marks) { free(tone); free(alpha); free(source); return 0; } + if (p->originalColors) + for (size_t i = 0; i < count; ++i) + marks[i] = 0.2126f * tone[i] + 0.7152f * tone[count + i] + 0.0722f * tone[2 * count + i]; + int cell = p->cell < 2 ? 2 : p->cell; + float cosA = cosf(p->angle), sinA = sinf(p->angle); + float *ink = p->lightOnDark ? light : dark, *paper = p->lightOnDark ? dark : light; + // Glyphs: each cell shares one, picked from the cell's average tone, worked out once per cell. + size_t columns = (width + (size_t)cell - 1) / (size_t)cell, cellRows = (height + (size_t)cell - 1) / (size_t)cell; + int *picked = NULL; + if (style == DITHER_GLYPHS && p->glyphCount > 0) { + picked = malloc(columns * cellRows * sizeof(int)); + if (!picked) { if (marks != tone) free(marks); free(tone); free(alpha); free(source); return 0; } + for (size_t row = 0; row < cellRows; ++row) + for (size_t column = 0; column < columns; ++column) { + float sum = 0; int n = 0; + for (size_t yy = row * (size_t)cell; yy < (row + 1) * (size_t)cell && yy < height; ++yy) + for (size_t xx = column * (size_t)cell; xx < (column + 1) * (size_t)cell && xx < width; ++xx) { + size_t i = yy * width + xx; + if (alpha[i]) { sum += marks[i]; ++n; } + } + float t = n ? sum / (float)n : 1; + float wanted = (p->lightOnDark ? t : 1 - t) * p->glyphCoverage[p->glyphCount - 1]; + int best = 0; + float bestDistance = 2; + for (int g = 0; g < p->glyphCount; ++g) { + float d = fabsf(p->glyphCoverage[g] - wanted); + if (d < bestDistance) { bestDistance = d; best = g; } + } + picked[row * columns + column] = best; + } + } + // Original colors: marks take the pixel's own color, on black (light on dark) or white. + float paperOriginal = p->lightOnDark ? 0.0f : 1.0f; + for (size_t y = 0; y < height; ++y) { + uint8_t *row = rgba + y * stride; + for (size_t x = 0; x < width; ++x) { + size_t at = y * width + x; + if (!alpha[at]) continue; + float amount; + if (picked) { + int glyph = picked[(y / (size_t)cell) * columns + x / (size_t)cell]; + amount = p->glyphs[(size_t)glyph * (size_t)(cell * cell) + (y % (size_t)cell) * (size_t)cell + x % (size_t)cell] / 255.0f; + } else if (style == DITHER_PATTERNS) { + float t = marks[at]; + float coverage = p->lightOnDark ? t : 1 - t; + int index = (int)lroundf(coverage * (float)(patternCount - 1)); + amount = (patterns[index][y & 7] >> (7 - (x & 7))) & 1; + } else { + float fx = (float)x + 0.5f, fy = (float)y + 0.5f; + float u = (fx * cosA + fy * sinA) / (float)cell, v = (-fx * sinA + fy * cosA) / (float)cell; + u -= floorf(u) + 0.5f; v -= floorf(v) + 0.5f; + float t = marks[at]; + amount = (p->lightOnDark ? t : 1 - t) > spot(style, u, v) ? 1 : 0; + } + if (p->originalColors) { + const float *s = source + at * 3; + write_pixel(row + x * 4, paperOriginal + (s[0] - paperOriginal) * amount, + paperOriginal + (s[1] - paperOriginal) * amount, paperOriginal + (s[2] - paperOriginal) * amount); + } else { + write_pixel(row + x * 4, paper[0] + (ink[0] - paper[0]) * amount, paper[1] + (ink[1] - paper[1]) * amount, + paper[2] + (ink[2] - paper[2]) * amount); + } + } + } + free(picked); + if (marks != tone) free(marks); + } + free(tone); free(alpha); free(source); + return 1; +} + +void dither_dots(uint8_t *rgba, size_t width, size_t height, size_t stride, int block, const uint8_t *gap) { + if (block < 2) return; + float radius = (float)block * 0.42f, middle = (float)block / 2; + for (size_t y = 0; y < height; ++y) { + uint8_t *row = rgba + y * stride; + float dy = (float)(y % (size_t)block) + 0.5f - middle; + for (size_t x = 0; x < width; ++x) { + uint8_t *px = row + x * 4; + if (!px[3]) continue; + float dx = (float)(x % (size_t)block) + 0.5f - middle; + float cover = clamp01(radius - sqrtf(dx * dx + dy * dy) + 0.5f); + if (cover >= 1) continue; + for (int c = 0; c < 3; ++c) + px[c] = (uint8_t)lroundf((float)px[c] * cover + (float)gap[c] * (float)px[3] / 255.0f * (1 - cover)); + } + } +} diff --git a/kernels/DitherPixels.h b/kernels/DitherPixels.h new file mode 100644 index 0000000..4b54533 --- /dev/null +++ b/kernels/DitherPixels.h @@ -0,0 +1,45 @@ +#ifndef DitherPixels_h +#define DitherPixels_h +#include <stdint.h> +#include <stddef.h> + +// The dither styles, in the order the Filter panel lists them. +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 +}; + +typedef struct { + int style; + // Tones per channel for diffusion and ordered styles, 2–8. Two is pure 1-bit. + int levels; + // How much of each pixel's error diffusion passes on, 0–1. + float diffusion; + // −1…1: darker (more ink) or lighter, and flatter or punchier, before dithering. + float density; + float contrast; + // 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. + int lightOnDark; + // 0: the result is made of `dark` and `light` (straight sRGB). 1: it keeps the image's own colors. + int originalColors; + uint8_t dark[3]; + uint8_t light[3]; + // Glyphs: `glyphCount` coverage maps of `cell` × `cell` bytes (255 is fully inked), from least inked to most, + // with each map's mean coverage (0–1) in `glyphCoverage`. + const uint8_t *glyphs; + const float *glyphCoverage; + int glyphCount; +} DitherParams; + +// 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); +#endif diff --git a/src/Document/ColorPalette+Session.h b/src/Document/ColorPalette+Session.h index b69eccd..08b3f08 100644 --- a/src/Document/ColorPalette+Session.h +++ b/src/Document/ColorPalette+Session.h @@ -21,6 +21,9 @@ public: // The picker on the open Vignette's colour, which follows it. void openVignetteColorPicker(); void previewVignetteColor(); + // The picker on Dither's dark or light colour. + void openDitherColorPicker(bool light); + void previewDitherColor(); // The picker on an effect of the open panel. void openEffectColorPicker(LayerEffectKind kind); // The effect follows the picker's working colour. @@ -40,3 +43,4 @@ private: void restoreDraftTextColors(const LayerTextStyle &original); void setGradientMapColor(const PaletteColor &color, bool highlights); void setVignetteColor(const PaletteColor &color); + void setDitherColor(const PaletteColor &color, bool light); diff --git a/src/Document/ColorPalette.cpp b/src/Document/ColorPalette.cpp index 9a94b5f..df923ec 100644 --- a/src/Document/ColorPalette.cpp +++ b/src/Document/ColorPalette.cpp @@ -107,6 +107,8 @@ QString ColorPickerTarget::title() const return QStringLiteral("Color Picker (%1 Color)").arg(rawValue(effect)); 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 background ? QStringLiteral("Color Picker (Background Color)") : QStringLiteral("Color Picker (Foreground Color)"); } @@ -253,6 +255,8 @@ void EditorSession::closeColorPicker(bool commit) setGradientMapColor(commit ? color : picker.original, picker.target.highlights); } else if (picker.target.kind == ColorPickerTarget::Kind::vignette) { setVignetteColor(commit ? color : picker.original); + } else if (picker.target.kind == ColorPickerTarget::Kind::dither) { + setDitherColor(commit ? color : picker.original, picker.target.light); } else if (picker.target.kind == ColorPickerTarget::Kind::effect) { const PaletteColor chosen = commit ? color : picker.original; changeEffects([&](LayerEffects &effects) { effects.setColor(chosen, picker.target.effect); }); @@ -283,6 +287,33 @@ void EditorSession::openVignetteColorPicker() notify(); } +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) + return; + const AdjustmentColor value = light ? m_filterEdit->settings.dither.light : m_filterEdit->settings.dither.dark; + m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::dither, false, std::nullopt, false, LayerEffectKind{}, light}, + PaletteColor{value.red, value.green, value.blue}); + notify(); +} + +void EditorSession::previewDitherColor() +{ + if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::dither) + setDitherColor(m_colorPicker->color(), m_colorPicker->target.light); +} + +// Its picker lives only while its Dither edit 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 (settings == m_filterEdit->settings) + return; + updateFilter(settings, m_filterEdit->preview); +} + void EditorSession::openEffectColorPicker(LayerEffectKind kind) { if (!canEditPalette() || m_colorPicker || !m_effectsEditing) diff --git a/src/Document/ColorPalette.h b/src/Document/ColorPalette.h index b1c6da5..d327ede 100644 --- a/src/Document/ColorPalette.h +++ b/src/Document/ColorPalette.h @@ -38,7 +38,7 @@ enum class LayerEffectKind; // The picker's targets: palette, text, effect, a filter's colour. struct ColorPickerTarget { - enum class Kind { palette, text, gradientMap, effect, vignette }; + enum class Kind { palette, text, gradientMap, effect, vignette, dither }; Kind kind; bool background = false; std::optional<QUuid> draftID = std::nullopt; @@ -46,6 +46,8 @@ struct ColorPickerTarget { bool highlights = false; // An effect's colour: which effect. LayerEffectKind effect{}; + // Dither's Two Colors: the light one, else the dark. + bool light = false; QString title() const; friend bool operator==(const ColorPickerTarget &, const ColorPickerTarget &) = default; }; diff --git a/src/Document/Dither.cpp b/src/Document/Dither.cpp new file mode 100644 index 0000000..801ace2 --- /dev/null +++ b/src/Document/Dither.cpp @@ -0,0 +1,236 @@ +#include "Document/Dither.h" +#include "Document/BrushStroke.h" +#include "IO/ImageExporter.h" +#include <QPainter> +#include <QPainterPath> +#include <QTextBoundaryFinder> +#include <algorithm> +#include <cmath> +#include <set> +extern "C" { +#include "DitherPixels.h" +} + +// Swift's Dither.swift: the settings, the chunky pixels, the glyphs. +namespace { +// Swift's Characters: the text's graphemes. +QStringList graphemes(const QString &text) +{ + QStringList found; + QTextBoundaryFinder finder(QTextBoundaryFinder::Grapheme, text); + qsizetype start = 0; + while (finder.toNextBoundary() >= 0) { + found << text.mid(start, finder.position() - start); + start = finder.position(); + } + return found; +} + +// Swift's Character.isNewline. +bool isNewline(const QString &grapheme) +{ + static const QString breaks = QStringLiteral("\n\r\u000B\u000C\u0085

"); + return grapheme == QStringLiteral("\r\n") || (grapheme.size() == 1 && breaks.contains(grapheme.front())); +} + +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))}; +} +} + +QString rawValue(DitherStyle style) +{ + switch (style) { + case DitherStyle::atkinson: return QStringLiteral("Atkinson (Classic Mac)"); + case DitherStyle::floydSteinberg: return QStringLiteral("Floyd–Steinberg"); + case DitherStyle::bayer2: return QStringLiteral("Bayer 2 × 2"); + case DitherStyle::bayer4: return QStringLiteral("Bayer 4 × 4"); + case DitherStyle::bayer8: return QStringLiteral("Bayer 8 × 8"); + case DitherStyle::dots: return QStringLiteral("Halftone Dots"); + case DitherStyle::lines: return QStringLiteral("Halftone Lines"); + case DitherStyle::diamonds: return QStringLiteral("Halftone Diamonds"); + case DitherStyle::patterns: return QStringLiteral("Mac Patterns"); + case DitherStyle::ascii: return QStringLiteral("ASCII"); + } + throw std::logic_error("unknown dither style"); +} + +int ditherGroup(DitherStyle style) +{ + const int index = int(style); + return index < 2 ? 0 : index < 5 ? 1 : index < 8 ? 2 : 3; +} + +bool diffuses(DitherStyle style) +{ + return ditherGroup(style) == 0; +} + +bool hasTones(DitherStyle style) +{ + return ditherGroup(style) < 2; +} + +bool isHalftone(DitherStyle style) +{ + return ditherGroup(style) == 2; +} + +bool drawsMarks(DitherStyle style) +{ + return !hasTones(style); +} + +QString rawValue(DitherPixelShape shape) +{ + return shape == DitherPixelShape::square ? QStringLiteral("Square") : QStringLiteral("Dot"); +} + +QString rawValue(DitherColors colors) +{ + switch (colors) { + case DitherColors::blackWhite: return QStringLiteral("Black & White"); + case DitherColors::twoColors: return QStringLiteral("Two Colors"); + case DitherColors::original: return QStringLiteral("Original"); + } + throw std::logic_error("unknown dither colours"); +} + +DitherSettings DitherSettings::normalized() const +{ + using ImageAdjustmentPixels::clamp; + DitherSettings result = *this; + result.pixelSize = std::round(clamp(pixelSize, pixelSizeLow, pixelSizeHigh, 2)); + result.cellSize = std::round(clamp(cellSize, cellSizeLow, cellSizeHigh, 8)); + result.angle = clamp(angle, -90, 90, 45); + result.levels = std::round(clamp(levels, levelsLow, levelsHigh, 2)); + result.diffusion = clamp(diffusion, 0, 100, 100); + result.density = clamp(density, -100, 100, 0); + result.contrast = clamp(contrast, -100, 100, 0); + result.dark = dark.clamped(); + result.light = light.clamped(); + // Line breaks go; 64 characters at most. + QStringList kept; + for (const QString &grapheme : graphemes(characters)) { + if (!isNewline(grapheme) && kept.size() < 64) + kept << grapheme; + } + result.characters = kept.join(QString()); + return result; +} + +QImage DitherSettings::apply(const QImage &image) const +{ + const DitherSettings settings = normalized(); + const int block = int(settings.pixelSize); + const QImage source = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + if (source.isNull()) + throw ExportError(ExportError::Kind::render); + const int width = source.width(), height = source.height(); + QImage working = source; + if (block > 1) { + // Chunky pixels: each block's area averaged, then dithered small. + working = BrushRaster::context((width + block - 1) / block, (height + block - 1) / block, false); + const int area = block * block; + for (int y = 0; y < working.height(); ++y) { + uchar *out = working.scanLine(y); + for (int x = 0; x < working.width(); ++x) { + std::array<int, 4> sum{}; + for (int sy = y * block; sy < std::min(height, (y + 1) * block); ++sy) { + const uchar *row = source.constScanLine(sy); + for (int sx = x * block; sx < std::min(width, (x + 1) * block); ++sx) { + for (int channel = 0; channel < 4; ++channel) + sum[size_t(channel)] += row[sx * 4 + channel]; + } + } + for (int channel = 0; channel < 4; ++channel) + out[x * 4 + channel] = uchar((sum[size_t(channel)] + area / 2) / area); + } + } + } + const QImage dithered = settings.dither(working); + if (block <= 1) + return dithered; + // Blown back up without smoothing. + QImage full = BrushRaster::context(width, height, false); + for (int y = 0; y < height; ++y) { + const uchar *row = dithered.constScanLine(y / block); + uchar *out = full.scanLine(y); + for (int x = 0; x < width; ++x) + std::copy_n(row + (x / block) * 4, 4, out + x * 4); + } + if (settings.pixelShape == DitherPixelShape::dot) { + // The gaps are the dark colour: black, or picked. + const std::array<uint8_t, 3> gap = settings.colors == DitherColors::twoColors ? bytes(settings.dark) : std::array<uint8_t, 3>{0, 0, 0}; + dither_dots(full.bits(), size_t(width), size_t(height), size_t(full.bytesPerLine()), block, gap.data()); + } + return full; +} + +QImage DitherSettings::dither(const QImage &image) const +{ + const int cell = int(cellSize); + const Glyphs glyphs = style == DitherStyle::ascii ? DitherSettings::glyphs(characters.isEmpty() ? defaultCharacters() : characters, cell) : 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}; + const std::array<uint8_t, 3> lightColor = two ? bytes(light) : std::array<uint8_t, 3>{255, 255, 255}; + bool failed = false; + const QImage result = ImageAdjustmentPixels::run(image, [&](uchar *pixels, int width, int height, qsizetype stride) { + DitherParams params{.style = int(style), .levels = int(levels), .diffusion = float(diffusion / 100), .density = float(density / 100), + .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]}, .glyphs = glyphs.maps.data(), + .glyphCoverage = glyphs.coverage.data(), .glyphCount = int(glyphs.coverage.size())}; + failed = dither_apply(pixels, size_t(width), size_t(height), size_t(stride), ¶ms) == 0; + }); + if (failed) + throw ExportError(ExportError::Kind::render); + return result; +} + +DitherSettings::Glyphs DitherSettings::glyphs(const QString &characters, int cell) +{ + // Swift's bold system monospace, at a whole pixel size. + QFont font(QStringLiteral("monospace")); + font.setWeight(QFont::Bold); + font.setPixelSize(std::max(1, int(std::lround(cell * 1.15)))); + std::vector<std::pair<std::vector<uint8_t>, float>> drawn; + // Swift's Set<Character>: canonically equivalent graphemes are one. + std::set<QString> seen; + for (const QString &character : graphemes(characters)) { + if (!seen.insert(character.normalized(QString::NormalizationForm_C)).second) + continue; + QImage map(cell, cell, QImage::Format_Grayscale8); + if (map.isNull()) + throw ExportError(ExportError::Kind::render); + map.fill(0); + QPainterPath path; + path.addText(0, 0, font, character); + // Overlapping outlines stay inked, as text rendering fills them. + path.setFillRule(Qt::WindingFill); + const QRectF bounds = path.boundingRect(); + { + // Centred on its ink, as CTLineGetImageBounds centres it. + QPainter painter(&map); + painter.setRenderHint(QPainter::Antialiasing); + painter.translate((cell - bounds.width()) / 2 - bounds.left(), (cell - bounds.height()) / 2 - bounds.top()); + painter.fillPath(path, Qt::white); + } + std::vector<uint8_t> bytes(size_t(cell * cell)); + long sum = 0; + for (int y = 0; y < cell; ++y) { + std::copy_n(map.constScanLine(y), cell, bytes.begin() + y * cell); + for (int x = 0; x < cell; ++x) + sum += map.constScanLine(y)[x]; + } + drawn.emplace_back(std::move(bytes), float(sum) / float(255 * cell * cell)); + } + std::stable_sort(drawn.begin(), drawn.end(), [](const auto &a, const auto &b) { return a.second < b.second; }); + Glyphs result; + for (const auto &[map, coverage] : drawn) { + result.maps.insert(result.maps.end(), map.begin(), map.end()); + result.coverage.push_back(coverage); + } + return result; +} diff --git a/src/Document/Dither.h b/src/Document/Dither.h new file mode 100644 index 0000000..a1a5e29 --- /dev/null +++ b/src/Document/Dither.h @@ -0,0 +1,73 @@ +#pragma once +#include "Document/ImageAdjustments.h" +#include <QImage> +#include <QString> +#include <array> +#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 }; +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}; +QString rawValue(DitherStyle style); +// Swift's groups: diffusion, ordered, halftone, marks. +int ditherGroup(DitherStyle style); +// Each pixel's rounding error passes to its neighbours. +bool diffuses(DitherStyle style); +// Diffusion and ordered styles have tones; the rest draw marks. +bool hasTones(DitherStyle style); +bool isHalftone(DitherStyle style); +bool drawsMarks(DitherStyle style); + +// A chunky pixel: a square, or a dot on dark. +enum class DitherPixelShape { square, dot }; +inline constexpr std::array allDitherPixelShapes{DitherPixelShape::square, DitherPixelShape::dot}; +QString rawValue(DitherPixelShape shape); + +enum class DitherColors { blackWhite, twoColors, original }; +inline constexpr std::array allDitherColors{DitherColors::blackWhite, DitherColors::twoColors, DitherColors::original}; +QString rawValue(DitherColors colors); + +// Swift's DitherSettings: the filter's look, in layer pixels. +struct DitherSettings { + static constexpr double pixelSizeLow = 1, pixelSizeHigh = 32; + static constexpr double cellSizeLow = 4, cellSizeHigh = 64; + static constexpr double levelsLow = 2, levelsHigh = 8; + static QString defaultCharacters() { return QStringLiteral(" .:-=+*#%@"); } + DitherStyle style = DitherStyle::atkinson; + // Each dithered pixel covers this many layer pixels a side. + double pixelSize = 2; + DitherPixelShape pixelShape = DitherPixelShape::square; + // Halftone and character cells, in dithered pixels. + double cellSize = 8; + // The halftone screen's angle, in degrees. + double angle = 45; + // Tones per channel; 2 is 1-bit. + double levels = 2; + // How much error passes on, 0–100%. + double diffusion = 100; + // −100…100: more ink or less, flatter or punchier. + double density = 0; + double contrast = 0; + DitherColors colors = DitherColors::blackWhite; + AdjustmentColor dark = AdjustmentColor(0, 0, 0); + AdjustmentColor light = AdjustmentColor(1, 1, 1); + // Marks stand for the light tones, lit on the dark. + bool lightOnDark = true; + // ASCII's characters, sorted by their ink. + QString characters = defaultCharacters(); + DitherSettings normalized() const; + QImage apply(const QImage &image) const; + friend bool operator==(const DitherSettings &, const DitherSettings &) = default; + + // Each distinct character's coverage map, least ink first. + struct Glyphs { + std::vector<uint8_t> maps; + std::vector<float> coverage; + }; + static Glyphs glyphs(const QString &characters, int cell); + +private: + QImage dither(const QImage &image) const; +}; diff --git a/src/Document/Filters+Session.cpp b/src/Document/Filters+Session.cpp index d855550..fe4dbe9 100644 --- a/src/Document/Filters+Session.cpp +++ b/src/Document/Filters+Session.cpp @@ -180,10 +180,18 @@ void EditorSession::finishFilterPreview() } } +namespace { +// A filter's own colours: their pickers go with its panel. +bool filterColour(ColorPickerTarget::Kind kind) +{ + return kind == ColorPickerTarget::Kind::gradientMap || kind == ColorPickerTarget::Kind::vignette || kind == ColorPickerTarget::Kind::dither; +} +} + void EditorSession::cancelFilter() { // A filter colour being picked goes with the panel. - if (m_colorPicker && (m_colorPicker->target.kind == ColorPickerTarget::Kind::gradientMap || m_colorPicker->target.kind == ColorPickerTarget::Kind::vignette)) + if (m_colorPicker && filterColour(m_colorPicker->target.kind)) closeColorPicker(false); if (finishAdjustmentEditing(false) || !m_filterEdit || m_filterEdit->committing) return; @@ -199,7 +207,7 @@ void EditorSession::commitFilter(std::function<void()> done) if (done) QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); }; - if (m_colorPicker && (m_colorPicker->target.kind == ColorPickerTarget::Kind::gradientMap || m_colorPicker->target.kind == ColorPickerTarget::Kind::vignette)) + if (m_colorPicker && filterColour(m_colorPicker->target.kind)) closeColorPicker(true); if (finishAdjustmentEditing(true) || !m_filterEdit || m_filterEdit->committing) { finish(); diff --git a/src/Document/Filters.cpp b/src/Document/Filters.cpp index 5997054..a1330be 100644 --- a/src/Document/Filters.cpp +++ b/src/Document/Filters.cpp @@ -27,6 +27,7 @@ QString rawValue(FilterKind kind) case FilterKind::addNoise: return QStringLiteral("Add Noise"); case FilterKind::vignette: return QStringLiteral("Vignette"); case FilterKind::bloomGlow: return QStringLiteral("Bloom / Glow"); + case FilterKind::dither: return QStringLiteral("Dither"); case FilterKind::tonalContrast: return QStringLiteral("Tonal Contrast"); case FilterKind::lensCorrection: return QStringLiteral("Lens Correction"); case FilterKind::cameraRaw: return QStringLiteral("Camera Raw Filter"); @@ -86,6 +87,7 @@ FilterSettings FilterSettings::normalized() const result.exposure = exposure.normalized(); result.gradientMap = gradientMap.normalized(); result.grain = grain.normalized(); + result.dither = dither.normalized(); result.cameraRaw = cameraRaw.normalized(); return result; } @@ -156,6 +158,7 @@ QImage PixelFilter::run(const FilterJob &job) break; // 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::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. @@ -271,8 +274,9 @@ void FilterEdit::prepare(const ImportedImage &source, const LayerTransform &plac const QSize size = source.size(); ++previewSourceVersion; mapping = BrushRaster::pixelToDocument(placed, size.width(), size.height()); - // Noise and grain preview whole: enlarged, grain looks coarse. - const bool whole = kind == FilterKind::addNoise || kind == FilterKind::grain || kind == FilterKind::contentAwareFill || kind == FilterKind::removeBackground; + // 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 + || kind == FilterKind::removeBackground; const double factor = whole ? 1 : std::min(1.0, previewLimit / std::max(size.width(), size.height())); if (!(factor < 1)) { previewSource = source.image(); diff --git a/src/Document/Filters.h b/src/Document/Filters.h index e61ea38..c301f4d 100644 --- a/src/Document/Filters.h +++ b/src/Document/Filters.h @@ -1,6 +1,7 @@ #pragma once #include "Document/CameraRaw.h" #include "Document/Curves.h" +#include "Document/Dither.h" #include "Document/ImageAdjustments.h" #include "Document/LayerTransform.h" #include "Document/Selection.h" @@ -16,7 +17,7 @@ struct ImageLayer; // The Filter and Image menus' filters. enum class FilterKind { - gaussianBlur, motionBlur, addNoise, vignette, bloomGlow, tonalContrast, lensCorrection, cameraRaw, removeBackground, contentAwareFill, curves, + gaussianBlur, motionBlur, addNoise, vignette, bloomGlow, dither, tonalContrast, lensCorrection, cameraRaw, removeBackground, contentAwareFill, curves, exposure, gradientMap, grain, blackWhite, colorBalance }; // The menu's title, Swift's rawValue. @@ -27,7 +28,8 @@ bool isAutomatic(FilterKind kind); bool isImageAdjustment(FilterKind kind); // Swift's allCases, the menus' order. inline constexpr std::array allFilterKinds{FilterKind::gaussianBlur, FilterKind::motionBlur, FilterKind::addNoise, - FilterKind::vignette, FilterKind::bloomGlow, FilterKind::tonalContrast, + FilterKind::vignette, FilterKind::bloomGlow, FilterKind::dither, + FilterKind::tonalContrast, FilterKind::lensCorrection, FilterKind::cameraRaw, FilterKind::removeBackground, FilterKind::contentAwareFill, FilterKind::curves, FilterKind::exposure, FilterKind::gradientMap, @@ -74,6 +76,7 @@ struct FilterSettings { GrainSettings grain{}; BlackWhiteSettings blackWhite{}; ColorBalanceSettings colorBalance{}; + DitherSettings dither{}; CameraRawSettings cameraRaw{}; BackgroundQuality backgroundQuality = BackgroundQuality::basic; // How far, in pixels, the mask is pulled onto edges. diff --git a/src/UI/FilterSheet+Dither.cpp b/src/UI/FilterSheet+Dither.cpp new file mode 100644 index 0000000..9e34525 --- /dev/null +++ b/src/UI/FilterSheet+Dither.cpp @@ -0,0 +1,136 @@ +#include "UI/FilterSheet.h" +#include "UI/ColorPaletteControls.h" +#include <QCheckBox> +#include <QComboBox> +#include <QFontDatabase> +#include <QHBoxLayout> +#include <QLabel> +#include <QLineEdit> +#include <QSlider> +#include <QVBoxLayout> + +// Swift's ditherControls, beside the filter sheet's other kinds. +QComboBox *FilterSheet::menu(const QString &title, const QString &name, const QString &help) +{ + auto *box = new QWidget(this); + auto *row = new QHBoxLayout(box); + row->setContentsMargins(0, 0, 0, 0); + row->setSpacing(10); + auto *label = new QLabel(title, box); + auto *choices = new QComboBox(box); + choices->setObjectName(name); + choices->setSizeAdjustPolicy(QComboBox::AdjustToContents); + label->setBuddy(choices); + box->setToolTip(help); + row->addWidget(label); + row->addWidget(choices); + row->addStretch(1); + m_column->addWidget(box); + return choices; +} + +void FilterSheet::dither() +{ + const auto key = [](double DitherSettings::*field) { return [field](FilterSettings &settings) -> double & { return settings.dither.*field; }; }; + const auto box = [this] { return m_controls.back().slider->parentWidget(); }; + // Style: grouped as Swift's menu, a rule between groups. + QComboBox *style = menu(QStringLiteral("Style"), QStringLiteral("ditherStyle"), QString()); + for (const DitherStyle each : allDitherStyles) { + if (style->count() > 0 && ditherGroup(each) != ditherGroup(DitherStyle(style->itemData(style->count() - 1).toInt()))) + style->insertSeparator(style->count()); + style->addItem(rawValue(each), int(each)); + } + connect(style, &QComboBox::activated, this, + [this, style](int index) { update([&](FilterSettings &settings) { settings.dither.style = DitherStyle(style->itemData(index).toInt()); }); }); + m_follows.emplace_back([style](const FilterSettings &settings) { + const QSignalBlocker quiet(style); + style->setCurrentIndex(style->findData(int(settings.dither.style))); + }); + control(QStringLiteral("Pixel Size"), key(&DitherSettings::pixelSize), DitherSettings::pixelSizeLow, DitherSettings::pixelSizeHigh, QStringLiteral("px"), 0, false); + box()->setToolTip(QStringLiteral("Make each dithered pixel this many pixels across, for a chunky old-screen look")); + 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) || settings.dither.style == DitherStyle::ascii; }); + control(QStringLiteral("Angle"), key(&DitherSettings::angle), -90, 90, QStringLiteral("°"), 0, false); + m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return isHalftone(settings.dither.style); }); + // ASCII's characters, typed as they will draw. + auto *characters = new QWidget(this); + auto *row = new QHBoxLayout(characters); + row->setContentsMargins(0, 0, 0, 0); + row->setSpacing(10); + auto *label = new QLabel(QStringLiteral("Characters"), characters); + auto *field = new QLineEdit(characters); + field->setObjectName(QStringLiteral("ditherCharacters")); + field->setPlaceholderText(QStringLiteral("Characters")); + field->setAccessibleName(QStringLiteral("Characters")); + field->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + label->setBuddy(field); + characters->setToolTip(QStringLiteral("The characters to draw with, in any order: each spot gets the one whose ink best matches its tone")); + row->addWidget(label); + row->addWidget(field, 1); + m_column->addWidget(characters); + connect(field, &QLineEdit::textEdited, this, [this](const QString &text) { update([&text](FilterSettings &settings) { settings.dither.characters = text; }); }); + // Left, it shows what the settings kept of the typing. + connect(field, &QLineEdit::editingFinished, this, [this, field] { field->setText(settings().dither.characters); }); + m_follows.emplace_back([field](const FilterSettings &settings) { + // Typing keeps its own text; the settings hold it normalized. + if (!field->hasFocus() && field->text() != settings.dither.characters) + field->setText(settings.dither.characters); + }); + m_shownWhen.emplace_back(characters, [](const FilterSettings &settings) { return settings.dither.style == DitherStyle::ascii; }); + control(QStringLiteral("Tones"), key(&DitherSettings::levels), DitherSettings::levelsLow, DitherSettings::levelsHigh, QString(), 0, false); + box()->setToolTip(QStringLiteral("Tones per channel: 2 is pure black and white")); + m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return hasTones(settings.dither.style); }); + control(QStringLiteral("Diffusion"), key(&DitherSettings::diffusion), 0, 100, QStringLiteral("%"), 0, false); + box()->setToolTip(QStringLiteral("How much of each pixel's error spreads to its neighbors. Less gives flatter areas")); + m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return diffuses(settings.dither.style); }); + 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; }); + 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) + shape->addItem(rawValue(each), int(each)); + connect(shape, &QComboBox::activated, this, [this](int index) { update([index](FilterSettings &settings) { settings.dither.pixelShape = DitherPixelShape(index); }); }); + m_follows.emplace_back([shape](const FilterSettings &settings) { + const QSignalBlocker quiet(shape); + shape->setCurrentIndex(int(settings.dither.pixelShape)); + }); + m_shownWhen.emplace_back(shape->parentWidget(), [](const FilterSettings &settings) { return settings.dither.pixelSize > 1; }); + flag(QStringLiteral("Light on Dark"), QStringLiteral("Draw the marks for the light tones on the dark color, like a glowing screen"), + [](FilterSettings &settings) -> bool & { return settings.dither.lightOnDark; }); + m_shownWhen.emplace_back(m_flags.back().first, [](const FilterSettings &settings) { return drawsMarks(settings.dither.style); }); +} diff --git a/src/UI/FilterSheet.cpp b/src/UI/FilterSheet.cpp index c873fae..c67c85f 100644 --- a/src/UI/FilterSheet.cpp +++ b/src/UI/FilterSheet.cpp @@ -185,6 +185,7 @@ FilterSheet::FilterSheet(EditorSession &session, QWidget *parent) control(QStringLiteral("Amount"), key(&FilterSettings::amount), 0.1, 400, QStringLiteral("%"), 1, true); noise(); break; + case FilterKind::dither: dither(); break; case FilterKind::vignette: case FilterKind::bloomGlow: case FilterKind::tonalContrast: finishing(m_session.filterEdit()->kind); break; @@ -317,12 +318,15 @@ void FilterSheet::synchronize() m_pickerColour = picked; m_session.previewGradientMapColor(); m_session.previewVignetteColor(); + m_session.previewDitherColor(); } if (m_vignetteSwatch) m_vignetteSwatch->update(); const FilterEdit &edit = m_session.filterEdit().value(); for (Control &control : m_controls) showControl(control, edit.settings); + for (const auto &follow : m_follows) + follow(edit.settings); if (m_curves) m_curves->synchronize(); if (m_gradientMap) @@ -345,8 +349,8 @@ void FilterSheet::synchronize() m_error->setVisible(edit.previewError.has_value()); // An adjustment layer's editor ignores the selection. m_limited->setVisible(!m_session.adjustmentOriginal() && m_session.selection()); - // While the result is made, OK waits and says so. - const bool working = edit.committing || edit.preparing; + // Slow filters say they work; a quick preview would flicker. + const bool working = edit.committing || (edit.preparing && isAutomatic(edit.kind)); m_spinner->setVisible(working); m_activity->setVisible(working); m_activity->setText(edit.committing ? QStringLiteral("Applying…") : QStringLiteral("Working…")); diff --git a/src/UI/FilterSheet.h b/src/UI/FilterSheet.h index c4b76e0..73521f3 100644 --- a/src/UI/FilterSheet.h +++ b/src/UI/FilterSheet.h @@ -7,6 +7,7 @@ class CurvesControls; class PickerField; class QButtonGroup; class QCheckBox; +class QComboBox; class QLabel; class QProgressBar; class QPushButton; @@ -75,6 +76,10 @@ private: void noise(); // Vignette's colour and rows, Bloom / Glow's, Tonal Contrast's. void finishing(FilterKind kind); + // Swift's ditherControls: style, sizes, tones, colours, shape. + void dither(); + // 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. void background(); // Black & White's six families and Tint; Color Balance's nine. @@ -101,6 +106,8 @@ private: std::vector<std::pair<QCheckBox *, std::function<bool &(FilterSettings &)>>> m_flags; // Rows under Swift's `if`: Advanced's, Tint's. std::vector<std::pair<QWidget *, std::function<bool(const FilterSettings &)>>> m_shownWhen; + // Controls that show the settings as they stand: Dither's menus. + std::vector<std::function<void(const FilterSettings &)>> m_follows; QCheckBox *const m_preview; QLabel *const m_error; QLabel *const m_limited; diff --git a/tests/CompositorMenusTests.cpp b/tests/CompositorMenusTests.cpp index ce4677d..b13d1cc 100644 --- a/tests/CompositorMenusTests.cpp +++ b/tests/CompositorMenusTests.cpp @@ -68,7 +68,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand() QStringList filters; for (QAction *entry : menus[5]->actions()) filters << entry->text(); - QCOMPARE(filters, (QStringList{"Gaussian Blur…", "Motion Blur…", "Add Noise…", "Vignette…", "Bloom / Glow…", "Tonal Contrast…", "Lens Correction…", "Camera Raw Filter…", "Remove Background…"})); + QCOMPARE(filters, (QStringList{"Gaussian Blur…", "Motion Blur…", "Add Noise…", "Vignette…", "Bloom / Glow…", "Dither…", "Tonal Contrast…", "Lens Correction…", "Camera Raw Filter…", "Remove Background…"})); QCOMPARE(menus[6]->actions().size(), 22); } diff --git a/tests/DitherKernelTests.cpp b/tests/DitherKernelTests.cpp new file mode 100644 index 0000000..18245d6 --- /dev/null +++ b/tests/DitherKernelTests.cpp @@ -0,0 +1,282 @@ +#include "Document/BrushStroke.h" +#include "Document/Dither.h" +#include <QPainter> +#include <QPainterPath> +#include <QtTest> +#include <set> + +// Dither's styles and colours, pixel for pixel, by hand. +namespace { +QImage filled(int width, int height, QColor colour) +{ + QImage image = BrushRaster::context(width, height, false); + image.fill(colour); + return image; +} + +DitherSettings style(DitherStyle each, double pixelSize = 1) +{ + DitherSettings settings; + settings.style = each; + settings.pixelSize = pixelSize; + return settings; +} + +// White pixels as ones, row by row. +QString bits(const QImage &image) +{ + QString text; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) + text += qRed(image.pixel(x, y)) == 255 ? u'1' : u'0'; + text += u'/'; + } + return text; +} + +std::array<int, 4> bytes(const QImage &image, int x, int y) +{ + const uchar *pixel = image.constScanLine(y) + x * 4; + return {pixel[0], pixel[1], pixel[2], pixel[3]}; +} +} + +class DitherKernelTests : public QObject { + Q_OBJECT +private slots: + void eachDiffusionPassesItsOwnShare(); + void eachBayerMatrixHasItsOwnPattern(); + void eachHalftoneShapeMarksItsOwnPixels(); + void macPatternsFillByTheirTone(); + void marksTakeTheOriginalOrTwoColours(); + void aBlockAveragesPremultipliedPixels(); + void coloursRoundAndOriginalDotsGapBlack(); + void eachRangeClampsAndRounds(); + void aStraightSourceIsPremultipliedFirst(); + void theCellSizeReachesTheScreen(); + void overlappingOutlinesStayInked(); +}; + +void DitherKernelTests::eachDiffusionPassesItsOwnShare() +{ + // Half gray: Atkinson passes 1/8 on, Floyd–Steinberg 7/16. + QCOMPARE(bits(style(DitherStyle::atkinson).apply(filled(3, 1, QColor(128, 128, 128)))), QString("100/")); + QCOMPARE(bits(style(DitherStyle::floydSteinberg).apply(filled(3, 1, QColor(128, 128, 128)))), QString("101/")); +} + +void DitherKernelTests::eachBayerMatrixHasItsOwnPattern() +{ + // Half gray: white where the threshold passes one half. + QCOMPARE(bits(style(DitherStyle::bayer4).apply(filled(4, 4, QColor(128, 128, 128)))), QString("0101/1010/0101/1010/")); + QCOMPARE(bits(style(DitherStyle::bayer8).apply(filled(8, 2, QColor(128, 128, 128)))), QString("01010101/10101010/")); + // Gray 96 tells the nested matrices apart. + QCOMPARE(bits(style(DitherStyle::bayer2).apply(filled(4, 4, QColor(96, 96, 96)))), QString("0101/1010/0101/1010/")); + QCOMPARE(bits(style(DitherStyle::bayer4).apply(filled(4, 4, QColor(96, 96, 96)))), QString("0001/1010/0100/1010/")); +} + +void DitherKernelTests::eachHalftoneShapeMarksItsOwnPixels() +{ + // Gray 87, 0°, cells of 8: dots, diamonds, lines differ. + const QImage source = filled(8, 8, QColor(87, 87, 87)); + const auto lit = [&source](DitherStyle each, int x, int y) { + DitherSettings halftone = style(each); + halftone.angle = 0; + halftone.cellSize = 8; + return qRed(halftone.apply(source).pixel(x, y)) == 255; + }; + QVERIFY(lit(DitherStyle::dots, 1, 4) && lit(DitherStyle::dots, 4, 1)); + QVERIFY(!lit(DitherStyle::diamonds, 1, 4) && !lit(DitherStyle::diamonds, 4, 1)); + QVERIFY(lit(DitherStyle::lines, 1, 4) && !lit(DitherStyle::lines, 4, 1)); +} + +void DitherKernelTests::macPatternsFillByTheirTone() +{ + // Half gray lights pattern 8: rows 0x88, 0x55, 0x22. + QCOMPARE(bits(style(DitherStyle::patterns).apply(filled(8, 4, QColor(128, 128, 128)))), QString("10001000/01010101/00100010/01010101/")); +} + +void DitherKernelTests::marksTakeTheOriginalOrTwoColours() +{ + const QImage source = filled(8, 8, QColor(200, 100, 50)); + // Original: marks in the pixel's colour on black or white. + for (const bool glowing : {true, false}) { + DitherSettings dots = style(DitherStyle::dots); + dots.colors = DitherColors::original; + dots.lightOnDark = glowing; + const QImage out = dots.apply(source); + std::set<QRgb> found; + for (int y = 0; y < 8; ++y) + for (int x = 0; x < 8; ++x) + found.insert(out.pixel(x, y)); + QCOMPARE(found, (std::set<QRgb>{qRgb(200, 100, 50), glowing ? qRgb(0, 0, 0) : qRgb(255, 255, 255)})); + } + // Two Colors: light ink on dark, dark on light. + DitherSettings two = style(DitherStyle::dots); + two.colors = DitherColors::twoColors; + two.dark = AdjustmentColor(0, 0, 1); + two.light = AdjustmentColor(1, 1, 0); + two.angle = 0; + const QImage glowing = two.apply(filled(8, 8, QColor(87, 87, 87))); + two.lightOnDark = false; + const QImage inked = two.apply(filled(8, 8, QColor(87, 87, 87))); + QCOMPARE(glowing.pixel(4, 4), qRgb(255, 255, 0)); + QCOMPARE(glowing.pixel(0, 0), qRgb(0, 0, 255)); + QCOMPARE(inked.pixel(4, 4), qRgb(0, 0, 255)); + QCOMPARE(inked.pixel(0, 0), qRgb(255, 255, 0)); +} + +void DitherKernelTests::aBlockAveragesPremultipliedPixels() +{ + // A straight (64, 128, 192, 128) beside three clear. + QImage source = BrushRaster::context(2, 2, false); + source.setPixelColor(0, 0, QColor(64, 128, 192, 128)); + QCOMPARE(bytes(source, 0, 0), (std::array<int, 4>{32, 64, 96, 128})); + DitherSettings original = style(DitherStyle::atkinson, 2); + original.colors = DitherColors::original; + original.levels = 8; + original.diffusion = 0; + // Averaged (8, 16, 24, 32); tones 2/7, 4/7, 5/7. + const QImage out = original.apply(source); + for (int y = 0; y < 2; ++y) + for (int x = 0; x < 2; ++x) + QCOMPARE(bytes(out, x, y), (std::array<int, 4>{9, 18, 23, 32})); +} + +void DitherKernelTests::coloursRoundAndOriginalDotsGapBlack() +{ + // (0.5, 0.1, 0.3) is (128, 26, 77), rounded. + DitherSettings two = style(DitherStyle::bayer2); + two.colors = DitherColors::twoColors; + two.dark = AdjustmentColor(0.5, 0.1, 0.3); + QCOMPARE(two.apply(filled(2, 2, Qt::black)).pixel(0, 0), qRgb(128, 26, 77)); + // Original dots gap black, whatever dark colour is kept. + DitherSettings dots = style(DitherStyle::bayer2, 8); + dots.pixelShape = DitherPixelShape::dot; + dots.colors = DitherColors::original; + dots.dark = AdjustmentColor(1, 0, 0); + const QImage out = dots.apply(filled(8, 8, Qt::white)); + QCOMPARE(out.pixel(0, 0), qRgb(0, 0, 0)); + QCOMPARE(out.pixel(4, 4), qRgb(255, 255, 255)); + // A dot's rim is smoothed: part colour, part gap. + const int rim = qRed(out.pixel(0, 4)); + QVERIFY2(rim > 0 && rim < 255, qPrintable(QString::number(rim))); + // One-pixel dots are squares: no gaps. + dots.pixelSize = 1; + QCOMPARE(dots.apply(filled(8, 8, Qt::white)).pixel(0, 0), qRgb(255, 255, 255)); +} + +void DitherKernelTests::eachRangeClampsAndRounds() +{ + const auto normal = [](const std::function<void(DitherSettings &)> &change) { + DitherSettings settings; + change(settings); + return settings.normalized(); + }; + QCOMPARE(normal([](DitherSettings &d) { d.pixelSize = 2.5; }).pixelSize, 3.0); + QCOMPARE(normal([](DitherSettings &d) { d.pixelSize = 32; }).pixelSize, 32.0); + QCOMPARE(normal([](DitherSettings &d) { d.pixelSize = 0; }).pixelSize, 1.0); + QCOMPARE(normal([](DitherSettings &d) { d.pixelSize = -std::numeric_limits<double>::infinity(); }).pixelSize, 2.0); + QCOMPARE(normal([](DitherSettings &d) { d.cellSize = 64; }).cellSize, 64.0); + QCOMPARE(normal([](DitherSettings &d) { d.cellSize = 65; }).cellSize, 64.0); + QCOMPARE(normal([](DitherSettings &d) { d.cellSize = 12.5; }).cellSize, 13.0); + QCOMPARE(normal([](DitherSettings &d) { d.levels = 8; }).levels, 8.0); + QCOMPARE(normal([](DitherSettings &d) { d.levels = 9; }).levels, 8.0); + QCOMPARE(normal([](DitherSettings &d) { d.levels = 1; }).levels, 2.0); + // Angle, diffusion, density and contrast keep their fractions. + const DitherSettings fractions = normal([](DitherSettings &d) { + d.angle = 12.25; + d.diffusion = 33.5; + d.density = -7.75; + d.contrast = 99.5; + }); + QVERIFY(fractions.angle == 12.25 && fractions.diffusion == 33.5 && fractions.density == -7.75 && fractions.contrast == 99.5); + QCOMPARE(normal([](DitherSettings &d) { d.angle = -91; }).angle, -90.0); + QCOMPARE(normal([](DitherSettings &d) { d.angle = 180; }).angle, 90.0); + QCOMPARE(normal([](DitherSettings &d) { d.angle = std::numeric_limits<double>::infinity(); }).angle, 45.0); + QCOMPARE(normal([](DitherSettings &d) { d.cellSize = std::nan(""); }).cellSize, 8.0); + QCOMPARE(normal([](DitherSettings &d) { d.cellSize = 1; }).cellSize, 4.0); + QCOMPARE(normal([](DitherSettings &d) { d.levels = std::nan(""); }).levels, 2.0); + QCOMPARE(normal([](DitherSettings &d) { d.diffusion = std::nan(""); }).diffusion, 100.0); + QCOMPARE(normal([](DitherSettings &d) { d.diffusion = 101; }).diffusion, 100.0); + QCOMPARE(normal([](DitherSettings &d) { d.density = std::nan(""); }).density, 0.0); + QCOMPARE(normal([](DitherSettings &d) { d.density = -101; }).density, -100.0); + QCOMPARE(normal([](DitherSettings &d) { d.contrast = -200; }).contrast, -100.0); + QCOMPARE(normal([](DitherSettings &d) { d.contrast = 101; }).contrast, 100.0); + // A lone CR is a line break too. + QCOMPARE(normal([](DitherSettings &d) { d.characters = QStringLiteral("a\rb"); }).characters, QString("ab")); +} + +void DitherKernelTests::aStraightSourceIsPremultipliedFirst() +{ + // Straight alpha, clear neighbours hiding green: premultiplied first. + QImage straight(2, 2, QImage::Format_RGBA8888); + straight.fill(QColor(0, 255, 0, 0)); + uchar *first = straight.scanLine(0); + first[0] = 64; + first[1] = 128; + first[2] = 192; + first[3] = 128; + DitherSettings original = style(DitherStyle::atkinson, 2); + original.colors = DitherColors::original; + original.levels = 8; + original.diffusion = 0; + const QImage out = original.apply(straight); + for (int y = 0; y < 2; ++y) + for (int x = 0; x < 2; ++x) + QCOMPARE(bytes(out, x, y), (std::array<int, 4>{9, 18, 23, 32})); +} + +void DitherKernelTests::theCellSizeReachesTheScreen() +{ + // Lines at gray 87, 0°: cells 12 and 8 differ. + const auto lit = [](double cell, int x, int y) { + DitherSettings lines = style(DitherStyle::lines); + lines.angle = 0; + lines.cellSize = cell; + return qRed(lines.apply(filled(16, 16, QColor(87, 87, 87))).pixel(x, y)) == 255; + }; + QVERIFY(!lit(12, 4, 3) && lit(12, 4, 7)); + QVERIFY(lit(8, 4, 3) && !lit(8, 4, 7)); + // ASCII: one character tiled at its own cell size. + DitherSettings ascii = style(DitherStyle::ascii); + ascii.cellSize = 12; + ascii.characters = QStringLiteral("#"); + const QImage out = ascii.apply(filled(24, 24, Qt::white)); + for (int y = 0; y < 12; ++y) + for (int x = 0; x < 12; ++x) + QCOMPARE(out.pixel(x + 12, y + 12), out.pixel(x, y)); + const std::vector<uint8_t> map = DitherSettings::glyphs(QStringLiteral("#"), 12).maps; + for (int index = 0; index < 144; ++index) + QCOMPARE(qRed(out.pixel(index % 12, index / 12)), int(std::lround(map[size_t(index)] / 255.0f * 255.0f))); +} + +void DitherKernelTests::overlappingOutlinesStayInked() +{ + // An accent over a letter: text rendering inks their overlap. + const QString character = QStringLiteral("A\u0340"); + const int cell = 64; + const std::vector<uint8_t> map = DitherSettings::glyphs(character, cell).maps; + QFont font(QStringLiteral("monospace")); + font.setWeight(QFont::Bold); + font.setPixelSize(74); + QPainterPath path; + path.addText(0, 0, font, character); + const QRectF bounds = path.boundingRect(); + QImage text(cell, cell, QImage::Format_Grayscale8); + text.fill(0); + { + QPainter painter(&text); + painter.setRenderHint(QPainter::Antialiasing); + painter.setFont(font); + painter.setPen(Qt::white); + painter.drawText(QPointF((cell - bounds.width()) / 2 - bounds.left(), (cell - bounds.height()) / 2 - bounds.top()), character); + } + int holes = 0; + for (int y = 0; y < cell; ++y) + for (int x = 0; x < cell; ++x) + holes += text.constScanLine(y)[x] > 200 && map[size_t(y * cell + x)] < 50; + QVERIFY2(holes <= 2, qPrintable(QString::number(holes))); +} + +QTEST_MAIN(DitherKernelTests) +#include "DitherKernelTests.moc" diff --git a/tests/DitherSheetTests.cpp b/tests/DitherSheetTests.cpp new file mode 100644 index 0000000..3d5f4d1 --- /dev/null +++ b/tests/DitherSheetTests.cpp @@ -0,0 +1,440 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "UI/ColorPaletteControls.h" +#include "UI/FilterSheet.h" +#include <QCheckBox> +#include <QComboBox> +#include <QLabel> +#include <QLayout> +#include <QLineEdit> +#include <QProgressBar> +#include <QSlider> +#include <QtTest> + +// Swift 1.3.3's Dither: the kind, its colours, its sheet. +namespace { +struct Shown { + EditorSession session; + QImage image; + std::unique_ptr<FilterSheet> sheet; + explicit Shown(int width = 40, int height = 30) + { + session.createDocument(width, height); + image = BrushRaster::context(width, height, false); + for (int y = 0; y < height; ++y) + for (int x = 0; x < width; ++x) + image.setPixelColor(x, y, QColor(x * 255 / std::max(1, width - 1), 120, 200)); + session.insert(ImportedImage(image, image, QStringLiteral("Colour"))); + session.beginFilter(FilterKind::dither); + sheet = std::make_unique<FilterSheet>(session); + sheet->show(); + } + const DitherSettings &dither() const { return session.filterEdit().value().settings.dither; } + void set(const std::function<void(DitherSettings &)> &change) + { + FilterSettings settings = session.filterEdit().value().settings; + change(settings.dither); + session.updateFilter(settings, true); + } + template <typename Widget> Widget &child(const char *name) const + { + auto *found = sheet->findChild<Widget *>(QString::fromLatin1(name)); + if (!found) + throw std::runtime_error(name); + return *found; + } + // The rows shown, by their titles, sorted. + QStringList shown() const + { + QStringList titles; + for (const QLabel *label : sheet->findChildren<QLabel *>()) { + if (label->buddy() && !label->parentWidget()->isHidden() && label->isVisibleTo(sheet.get())) + titles << label->text(); + } + for (const QCheckBox *box : sheet->findChildren<QCheckBox *>()) { + if (box->isVisibleTo(sheet.get()) && box->text() != QLatin1String("Preview")) + titles << box->text(); + } + if (child<QWidget>("ditherDark").isVisibleTo(sheet.get())) + titles << QStringLiteral("Swatches"); + titles.sort(); + return titles; + } +}; + +QStringList sorted(QStringList titles) +{ + titles.sort(); + return titles; +} + +void pick(EditorSession &session, const PaletteColor &color) +{ + PickerHSB hsb = session.colorPicker().value().hsb; + hsb.setRGB(color); + session.setColorPickerHSB(hsb); +} +} + +class DitherSheetTests : public QObject { + Q_OBJECT +private slots: + void theKindSitsUnderBloomAndPreviewsWhole(); + void itsCommitIsTheSettingsApplied(); + void theTwoColoursHaveTheirPicker(); + void theControlsFollowTheStyle(); + void theMenusAndFieldsWriteTheSettings(); + void theSheetPreviewsThePickersColour(); + void eachNumberWritesItsOwnSetting(); + void leftTheCharactersShowWhatWasKept(); + void theSlidersReachTheirEnds(); + void aCommitSaysItIsApplying(); + void thePickerRefusesWhenItCannot(); + void anAsciiCommitDrawsOnTheWorker(); + void rowsKeepSwiftsOrderHelpAndScale(); +}; + +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(!isAutomatic(FilterKind::dither) && !isImageAdjustment(FilterKind::dither)); + // Past the preview limit, Dither still previews at full size. + EditorSession session; + session.createDocument(3000, 10); + const QImage wide = BrushRaster::context(3000, 10, false); + session.insert(ImportedImage(wide, wide, QStringLiteral("Wide"))); + session.beginFilter(FilterKind::dither); + QCOMPARE(session.filterEdit().value().previewScale, 1.0); + QCOMPARE(session.filterEdit().value().previewSource.size(), QSize(3000, 10)); + session.cancelFilter(); + session.beginFilter(FilterKind::gaussianBlur); + QVERIFY(session.filterEdit().value().previewScale < 1); +} + +void DitherSheetTests::itsCommitIsTheSettingsApplied() +{ + Shown shown; + shown.set([](DitherSettings &dither) { + dither.style = DitherStyle::bayer4; + dither.pixelSize = 3; + }); + const DitherSettings settings = shown.dither(); + bool done = false; + shown.session.commitFilter([&done] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(shown.session.history.undoName(), QString("Dither")); + QCOMPARE(shown.session.activeLayer().value().asset.value().image(), settings.apply(shown.image)); +} + +void DitherSheetTests::theTwoColoursHaveTheirPicker() +{ + Shown shown; + shown.session.openDitherColorPicker(false); + QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (Dither Dark Color)")); + QVERIFY(shown.session.colorPicker().value().original == PaletteColor::black()); + pick(shown.session, PaletteColor{0, 0, 1}); + shown.session.previewDitherColor(); + QVERIFY(shown.dither().dark == AdjustmentColor(0, 0, 1)); + // Cancel puts it back; OK on the light keeps it. + 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)")); + 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)); + // The swatches open it; the panel's close takes it along. + shown.set([](DitherSettings &dither) { dither.colors = DitherColors::twoColors; }); + QTest::mouseClick(&shown.child<SwatchButton>("ditherDark"), Qt::LeftButton); + QVERIFY(shown.session.colorPicker().has_value() && !shown.session.colorPicker().value().target.light); + pick(shown.session, PaletteColor{1, 0, 0}); + shown.session.previewDitherColor(); + shown.session.cancelFilter(); + QVERIFY(!shown.session.colorPicker() && !shown.session.filterEdit()); + // Not over another filter. + shown.session.beginFilter(FilterKind::gaussianBlur); + shown.session.openDitherColorPicker(true); + QVERIFY(!shown.session.colorPicker()); +} + +void DitherSheetTests::theControlsFollowTheStyle() +{ + Shown shown; + const QStringList common{"Style", "Pixel Size", "Density", "Contrast", "Colors", "Pixel Shape"}; + QCOMPARE(shown.shown(), sorted(common + QStringList{"Tones", "Diffusion"})); + shown.set([](DitherSettings &dither) { dither.style = DitherStyle::bayer8; }); + QCOMPARE(shown.shown(), sorted(common + QStringList{"Tones"})); + shown.set([](DitherSettings &dither) { dither.style = DitherStyle::lines; }); + QCOMPARE(shown.shown(), sorted(common + QStringList{"Cell Size", "Angle", "Light on Dark"})); + shown.set([](DitherSettings &dither) { dither.style = DitherStyle::ascii; }); + QCOMPARE(shown.shown(), sorted(common + QStringList{"Cell Size", "Characters", "Light on Dark"})); + shown.set([](DitherSettings &dither) { dither.style = DitherStyle::patterns; }); + QCOMPARE(shown.shown(), sorted(common + QStringList{"Light on Dark"})); + // Pixel Shape needs chunky pixels; the swatches, Two Colors. + shown.set([](DitherSettings &dither) { + dither.pixelSize = 1; + dither.colors = DitherColors::twoColors; + }); + QCOMPARE(shown.shown(), sorted(QStringList{"Style", "Pixel Size", "Density", "Contrast", "Colors", "Light on Dark", "Swatches"})); + // 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"})); + QCOMPARE(style.currentText(), QString("Mac Patterns")); + // Its quick preview never shows the busy line. + QVERIFY(!shown.child<QProgressBar>("filterSpinner").isVisibleTo(shown.sheet.get())); +} + +void DitherSheetTests::theMenusAndFieldsWriteTheSettings() +{ + Shown shown; + QComboBox &style = shown.child<QComboBox>("ditherStyle"); + const int ascii = style.findData(int(DitherStyle::ascii)); + style.setCurrentIndex(ascii); + emit style.activated(ascii); + QVERIFY(shown.dither().style == DitherStyle::ascii); + QComboBox &colors = shown.child<QComboBox>("ditherColors"); + colors.setCurrentIndex(2); + emit colors.activated(2); + QVERIFY(shown.dither().colors == DitherColors::original); + QComboBox &shape = shown.child<QComboBox>("ditherPixelShape"); + shape.setCurrentIndex(1); + emit shape.activated(1); + QVERIFY(shown.dither().pixelShape == DitherPixelShape::dot); + // Characters as typed, stored without line breaks. + QLineEdit &characters = shown.child<QLineEdit>("ditherCharacters"); + QCOMPARE(characters.text(), DitherSettings::defaultCharacters()); + characters.setFocus(); + characters.selectAll(); + QTest::keyClicks(&characters, QStringLiteral("#o.")); + QCOMPARE(shown.dither().characters, QString("#o.")); + // Unfocused, it shows the settings as they stand. + characters.clearFocus(); + shown.set([](DitherSettings &dither) { dither.characters = QStringLiteral("xy"); }); + QCOMPARE(characters.text(), QString("xy")); +} + +void DitherSheetTests::theSheetPreviewsThePickersColour() +{ + Shown shown; + shown.set([](DitherSettings &dither) { dither.colors = DitherColors::twoColors; }); + for (const bool light : {false, true}) { + QTest::mouseClick(&shown.child<SwatchButton>(light ? "ditherLight" : "ditherDark"), Qt::LeftButton); + QVERIFY(shown.session.colorPicker().has_value() && shown.session.colorPicker().value().target.light == light); + // The sheet previews the working colour itself. + pick(shown.session, PaletteColor{0, 1, 0}); + QVERIFY((light ? shown.dither().light : shown.dither().dark) == AdjustmentColor(0, 1, 0)); + shown.session.closeColorPicker(false); + QVERIFY(shown.dither().dark == AdjustmentColor(0, 0, 0) && shown.dither().light == AdjustmentColor(1, 1, 1)); + } + // The filter's OK takes an open picker's colour along. + QTest::mouseClick(&shown.child<SwatchButton>("ditherLight"), Qt::LeftButton); + pick(shown.session, PaletteColor{1, 0, 0}); + DitherSettings expected = shown.dither(); + expected.light = AdjustmentColor(1, 0, 0); + bool done = false; + shown.session.commitFilter([&done] { done = true; }); + QTRY_VERIFY(done); + QVERIFY(!shown.session.colorPicker()); + QCOMPARE(shown.session.activeLayer().value().asset.value().image(), expected.apply(shown.image)); +} + +void DitherSheetTests::eachNumberWritesItsOwnSetting() +{ + Shown shown; + shown.set([](DitherSettings &dither) { dither.style = DitherStyle::lines; }); + struct Row { + const char *field; + double DitherSettings::*setting; + const char *typed; + double expected; + }; + const Row rows[] = {{"pixelSizeField", &DitherSettings::pixelSize, "5", 5}, {"cellSizeField", &DitherSettings::cellSize, "12", 12}, + {"angleField", &DitherSettings::angle, "-30", -30}, {"densityField", &DitherSettings::density, "25", 25}, + {"contrastField", &DitherSettings::contrast, "-40", -40}}; + for (const Row &row : rows) { + const DitherSettings before = shown.dither(); + QLineEdit &field = shown.child<QLineEdit>(row.field); + field.setFocus(); + field.selectAll(); + QTest::keyClicks(&field, QString::fromLatin1(row.typed)); + QTest::keyClick(&field, Qt::Key_Return); + DitherSettings expected = before; + expected.*row.setting = row.expected; + QVERIFY2(shown.dither() == expected, row.field); + } + // Tones and Diffusion under a diffusion style. + shown.set([](DitherSettings &dither) { dither.style = DitherStyle::floydSteinberg; }); + for (const auto &[name, setting, value] : {std::tuple("tonesField", &DitherSettings::levels, 4.0), std::tuple("diffusionField", &DitherSettings::diffusion, 60.0)}) { + QLineEdit &field = shown.child<QLineEdit>(name); + field.setFocus(); + field.selectAll(); + QTest::keyClicks(&field, QString::number(value)); + QTest::keyClick(&field, Qt::Key_Return); + QCOMPARE(shown.dither().*setting, value); + } + // Light on Dark, under marks. + shown.set([](DitherSettings &dither) { dither.style = DitherStyle::dots; }); + QCheckBox *glow = nullptr; + for (QCheckBox *box : shown.sheet->findChildren<QCheckBox *>()) + glow = box->text() == QLatin1String("Light on Dark") ? box : glow; + QVERIFY(glow && glow->isChecked()); + glow->click(); + QVERIFY(!shown.dither().lightOnDark); +} + +void DitherSheetTests::leftTheCharactersShowWhatWasKept() +{ + Shown shown; + QVERIFY(QTest::qWaitForWindowActive(shown.sheet.get())); + shown.session.updateFilter(shown.session.filterEdit().value().settings, false); + shown.set([](DitherSettings &dither) { dither.style = DitherStyle::ascii; }); + shown.session.updateFilter(shown.session.filterEdit().value().settings, false); + QLineEdit &characters = shown.child<QLineEdit>("ditherCharacters"); + characters.setFocus(); + QTRY_VERIFY(characters.hasFocus()); + characters.selectAll(); + QTest::keyClicks(&characters, QString(70, u'x')); + // Kept to 64; the typing shows until left. + QCOMPARE(shown.dither().characters, QString(64, u'x')); + QCOMPARE(characters.text(), QString(70, u'x')); + shown.child<QLineEdit>("densityField").setFocus(); + QTRY_VERIFY(!characters.hasFocus()); + QCOMPARE(characters.text(), QString(64, u'x')); + QVERIFY(!shown.session.filterEdit().value().preview); +} + +void DitherSheetTests::theSlidersReachTheirEnds() +{ + Shown shown; + shown.set([](DitherSettings &dither) { dither.style = DitherStyle::lines; }); + const std::tuple<const char *, double DitherSettings::*, double, double> rows[] = { + {"pixelSizeSlider", &DitherSettings::pixelSize, 1, 32}, {"cellSizeSlider", &DitherSettings::cellSize, 4, 64}, + {"angleSlider", &DitherSettings::angle, -90, 90}, {"densitySlider", &DitherSettings::density, -100, 100}, + {"contrastSlider", &DitherSettings::contrast, -100, 100}}; + for (const auto &[name, setting, low, high] : rows) { + QSlider &slider = shown.child<QSlider>(name); + slider.setValue(slider.maximum()); + QCOMPARE(shown.dither().*setting, high); + slider.setValue(slider.minimum()); + QCOMPARE(shown.dither().*setting, low); + } + QCOMPARE(shown.child<QSlider>("pixelSizeSlider").parentWidget()->toolTip(), + QString("Make each dithered pixel this many pixels across, for a chunky old-screen look")); + shown.set([](DitherSettings &dither) { dither.style = DitherStyle::atkinson; }); + for (const auto &[name, setting, low, high] : {std::tuple("tonesSlider", &DitherSettings::levels, 2.0, 8.0), + std::tuple("diffusionSlider", &DitherSettings::diffusion, 0.0, 100.0)}) { + QSlider &slider = shown.child<QSlider>(name); + slider.setValue(slider.maximum()); + QCOMPARE(shown.dither().*setting, high); + slider.setValue(slider.minimum()); + QCOMPARE(shown.dither().*setting, low); + } +} + +void DitherSheetTests::aCommitSaysItIsApplying() +{ + // Quick previews never show the line; a commit does. + Shown shown; + bool done = false; + shown.session.commitFilter([&done] { done = true; }); + QVERIFY(shown.child<QProgressBar>("filterSpinner").isVisibleTo(shown.sheet.get())); + QCOMPARE(shown.child<QLabel>("filterActivity").text(), QString("Applying…")); + QTRY_VERIFY(done); +} + +void DitherSheetTests::thePickerRefusesWhenItCannot() +{ + Shown shown; + // Busy, the palette rests. + shown.session.setIsProjectBusy(true); + shown.session.openDitherColorPicker(false); + QVERIFY(!shown.session.colorPicker()); + shown.session.setIsProjectBusy(false); + // Another picker open stays so. + shown.session.openDitherColorPicker(true); + shown.session.openDitherColorPicker(false); + QVERIFY(shown.session.colorPicker().value().target.light); + shown.session.closeColorPicker(false); + // A style change keeps an open picker on its colour. + shown.session.openDitherColorPicker(false); + shown.set([](DitherSettings &dither) { dither.style = DitherStyle::ascii; }); + pick(shown.session, PaletteColor{1, 0, 1}); + shown.session.previewDitherColor(); + QVERIFY(shown.dither().dark == AdjustmentColor(1, 0, 1) && shown.dither().style == DitherStyle::ascii); + shown.session.closeColorPicker(false); + QVERIFY(shown.dither().dark == AdjustmentColor(0, 0, 0)); +} + +void DitherSheetTests::anAsciiCommitDrawsOnTheWorker() +{ + // Glyphs are drawn off the UI thread, in the commit. + Shown shown(96, 48); + shown.set([](DitherSettings &dither) { + dither.style = DitherStyle::ascii; + dither.cellSize = 12; + dither.pixelSize = 1; + }); + const DitherSettings settings = shown.dither(); + bool done = false; + shown.session.commitFilter([&done] { done = true; }); + QTRY_VERIFY(done); + QCOMPARE(shown.session.activeLayer().value().asset.value().image(), settings.apply(shown.image)); +} + +void DitherSheetTests::rowsKeepSwiftsOrderHelpAndScale() +{ + Shown shown; + // The shown rows, in the column's order, with their help. + const auto rows = [&shown] { + QStringList titles; + QLayout *column = shown.sheet->layout(); + for (int index = 0; index < column->count(); ++index) { + QWidget *row = column->itemAt(index)->widget(); + if (!row || row->isHidden()) + continue; + if (auto *box = qobject_cast<QCheckBox *>(row); box && box->text() != QLatin1String("Preview")) + titles << box->text() + u'|' + box->toolTip(); + for (QLabel *label : row->findChildren<QLabel *>(QString(), Qt::FindDirectChildrenOnly)) { + if (label->buddy()) + titles << label->text() + u'|' + row->toolTip(); + } + } + return titles; + }; + QCOMPARE(rows(), (QStringList{"Style|", "Pixel Size|Make each dithered pixel this many pixels across, for a chunky old-screen look", + "Tones|Tones per channel: 2 is pure black and white", + "Diffusion|How much of each pixel's error spreads to its neighbors. Less gives flatter areas", + "Density|More ink (darker) or less before dithering", "Contrast|", "Colors|", + "Pixel Shape|Draw each chunky pixel as a solid square, or as a round dot like a dot-matrix screen"})); + shown.set([](DitherSettings &dither) { dither.style = DitherStyle::ascii; }); + QCOMPARE(rows().mid(2, 2), (QStringList{"Cell Size|", "Characters|The characters to draw with, in any order: each spot gets the one whose ink best matches its tone"})); + QCOMPARE(rows().last(), QString("Light on Dark|Draw the marks for the light tones on the dark color, like a glowing screen")); + // Linear: half the Pixel Size travel is 16.5, so 17. + QSlider &pixels = shown.child<QSlider>("pixelSizeSlider"); + pixels.setValue(pixels.maximum() / 2); + QCOMPARE(shown.dither().pixelSize, 17.0); + // Across Colors a picker keeps on; without an edit, none. + shown.set([](DitherSettings &dither) { dither.colors = DitherColors::twoColors; }); + shown.session.updateFilter(shown.session.filterEdit().value().settings, false); + QTest::mouseClick(&shown.child<SwatchButton>("ditherDark"), Qt::LeftButton); + FilterSettings original = shown.session.filterEdit().value().settings; + original.dither.colors = DitherColors::original; + shown.session.updateFilter(original, false); + pick(shown.session, PaletteColor{0, 1, 1}); + QVERIFY(shown.dither().dark == AdjustmentColor(0, 1, 1) && shown.dither().colors == DitherColors::original); + QVERIFY(!shown.session.filterEdit().value().preview); + shown.session.closeColorPicker(false); + QVERIFY(shown.dither().dark == AdjustmentColor(0, 0, 0)); + shown.session.cancelFilter(); + shown.session.openDitherColorPicker(false); + QVERIFY(!shown.session.colorPicker()); +} + +QTEST_MAIN(DitherSheetTests) +#include "DitherSheetTests.moc" diff --git a/tests/DitherTests.cpp b/tests/DitherTests.cpp new file mode 100644 index 0000000..cebbf43 --- /dev/null +++ b/tests/DitherTests.cpp @@ -0,0 +1,363 @@ +#include "Document/BrushStroke.h" +#include "Document/Dither.h" +#include "Document/EditorSession.h" +#include <QtTest> +#include <set> + +// Swift 1.3.3's Dither: the model and the kernel through `apply`. +namespace { +QImage filled(int width, int height, QColor colour) +{ + QImage image = BrushRaster::context(width, height, false); + image.fill(colour); + return image; +} + +// A horizontal ramp of grays, left black, right white. +QImage ramp(int width, int height) +{ + QImage image = BrushRaster::context(width, height, false); + for (int y = 0; y < height; ++y) + for (int x = 0; x < width; ++x) + image.setPixelColor(x, y, QColor::fromRgbF(float(x) / float(width - 1), float(x) / float(width - 1), float(x) / float(width - 1))); + return image; +} + +std::set<QRgb> colours(const QImage &image) +{ + std::set<QRgb> found; + for (int y = 0; y < image.height(); ++y) + for (int x = 0; x < image.width(); ++x) + found.insert(image.pixel(x, y)); + return found; +} + +DitherSettings style(DitherStyle each, double pixelSize = 1) +{ + DitherSettings settings; + settings.style = each; + settings.pixelSize = pixelSize; + return settings; +} +} + +class DitherTests : public QObject { + Q_OBJECT +private slots: + void stylesKeepSwiftsNamesOrderAndGroups(); + void normalizingClampsRoundsAndTrimsTheCharacters(); + void bayerThresholdsAGrayIntoItsPattern(); + void diffusionMakesTwoTonesOrMore(); + void twoColorsAndTheOriginalOnes(); + void alphaIsKeptAndClearPixelsLeftAlone(); + void chunkyPixelsFillTheirBlocks(); + void dotsLeaveTheirCornersToTheDarkColour(); + void marksDrawLightOnDarkOrDarkOnLight(); + void glyphsAreSortedByTheirInk(); + void densityAndContrastMoveTheThreshold(); + void theScreenTurnsWithItsAngle(); + void asciiDrawsWithItsOwnCharacters(); +}; + +void DitherTests::stylesKeepSwiftsNamesOrderAndGroups() +{ + QStringList names; + 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"})); + QString groups; + for (const DitherStyle each : allDitherStyles) + groups += QString::number(ditherGroup(each)); + 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)); + QVERIFY(drawsMarks(DitherStyle::ascii) && drawsMarks(DitherStyle::lines) && !drawsMarks(DitherStyle::atkinson)); + QCOMPARE(rawValue(DitherPixelShape::dot), QString("Dot")); + QCOMPARE(rawValue(DitherColors::twoColors), QString("Two Colors")); + // Chunky square pixels, 1-bit Atkinson, glowing marks by default. + const DitherSettings defaults; + QVERIFY(defaults.style == DitherStyle::atkinson && defaults.pixelSize == 2 && defaults.pixelShape == DitherPixelShape::square); + QVERIFY(defaults.levels == 2 && defaults.lightOnDark && defaults.colors == DitherColors::blackWhite); +} + +void DitherTests::normalizingClampsRoundsAndTrimsTheCharacters() +{ + DitherSettings wild; + wild.pixelSize = 40.6; + wild.cellSize = 2; + wild.angle = std::nan(""); + wild.levels = 4.5; + wild.diffusion = -5; + wild.density = 300; + wild.contrast = std::numeric_limits<double>::infinity(); + wild.dark = AdjustmentColor(-1, 0.5, 2); + wild.light = AdjustmentColor(3, -2, 0.25); + wild.characters = QStringLiteral("ab\ncd\r\ne\u2028f\u000Bg\u000Ch\u0085i\u2029\t j") + QString(80, u'x'); + const DitherSettings normal = wild.normalized(); + QCOMPARE(normal.pixelSize, 32.0); + QCOMPARE(normal.cellSize, 4.0); + QCOMPARE(normal.angle, 45.0); + QCOMPARE(normal.levels, 5.0); + QCOMPARE(normal.diffusion, 0.0); + QCOMPARE(normal.density, 100.0); + QCOMPARE(normal.contrast, 0.0); + QVERIFY(normal.dark == AdjustmentColor(0, 0.5, 1) && normal.light == AdjustmentColor(1, 0, 0.25)); + // Every line break goes, tabs and spaces stay; 64 kept. + QCOMPARE(normal.characters, QStringLiteral("abcdefghi\t j") + QString(52, u'x')); + // A grapheme of many units counts once, never split. + DitherSettings emoji; + emoji.characters = QString(63, u'.') + QStringLiteral("\U0001F44D\U0001F3FD") + QStringLiteral("zz"); + QCOMPARE(emoji.normalized().characters, QString(63, u'.') + QStringLiteral("\U0001F44D\U0001F3FD")); + QCOMPARE(DitherSettings().normalized(), DitherSettings()); +} + +void DitherTests::bayerThresholdsAGrayIntoItsPattern() +{ + // Half gray over Bayer 2 × 2: high thresholds light. + const QImage out = style(DitherStyle::bayer2).apply(filled(4, 4, QColor(128, 128, 128))); + const QRgb black = qRgb(0, 0, 0), white = qRgb(255, 255, 255); + const std::array<QRgb, 4> pattern{black, white, white, black}; + for (int y = 0; y < 4; ++y) + for (int x = 0; x < 4; ++x) + QCOMPARE(out.pixel(x, y), pattern[size_t((y & 1) * 2 + (x & 1))]); + // Four tones on a ramp: 0, 85, 170, 255 alone. + DitherSettings tones = style(DitherStyle::bayer4); + tones.levels = 4; + std::set<int> levels; + const QImage toned = tones.apply(ramp(64, 8)); + for (int x = 0; x < 64; ++x) + levels.insert(qRed(toned.pixel(x, 3))); + QCOMPARE(levels, (std::set<int>{0, 85, 170, 255})); +} + +void DitherTests::diffusionMakesTwoTonesOrMore() +{ + for (const DitherStyle each : {DitherStyle::atkinson, DitherStyle::floydSteinberg}) { + const QImage out = style(each).apply(ramp(64, 16)); + QCOMPARE(colours(out), (std::set<QRgb>{qRgb(0, 0, 0), qRgb(255, 255, 255)})); + // More white towards the light end. + int left = 0, right = 0; + for (int y = 0; y < 16; ++y) { + for (int x = 0; x < 16; ++x) { + left += qRed(out.pixel(x, y)) == 255; + right += qRed(out.pixel(63 - x, y)) == 255; + } + } + QVERIFY2(left < 40 && right > 216, qPrintable(QStringLiteral("%1 %2").arg(left).arg(right))); + } + // No diffusion: a flat mid gray rounds whole. + DitherSettings flat = style(DitherStyle::atkinson); + flat.diffusion = 0; + QCOMPARE(colours(flat.apply(filled(8, 8, QColor(140, 140, 140)))), (std::set<QRgb>{qRgb(255, 255, 255)})); +} + +void DitherTests::twoColorsAndTheOriginalOnes() +{ + DitherSettings two = style(DitherStyle::bayer8); + two.colors = DitherColors::twoColors; + two.dark = AdjustmentColor(0.2, 0, 0.4); + two.light = AdjustmentColor(1, 0.8, 0.6); + QCOMPARE(colours(two.apply(ramp(32, 8))), (std::set<QRgb>{qRgb(51, 0, 102), qRgb(255, 204, 153)})); + // Original: red stays red, dithered on its own channels. + DitherSettings original = style(DitherStyle::bayer8); + original.colors = DitherColors::original; + for (const QRgb each : colours(original.apply(filled(8, 8, QColor(255, 0, 0))))) + QCOMPARE(each, qRgb(255, 0, 0)); +} + +void DitherTests::alphaIsKeptAndClearPixelsLeftAlone() +{ + QImage image = ramp(16, 4); + image.setPixelColor(0, 0, Qt::transparent); + image.setPixelColor(5, 1, QColor::fromRgbF(1, 1, 1, 0.5f)); + const QImage out = style(DitherStyle::floydSteinberg).apply(image); + QCOMPARE(out.pixelColor(0, 0).alpha(), 0); + QCOMPARE(out.pixelColor(5, 1).alpha(), 128); + for (int x = 1; x < 16; ++x) + QCOMPARE(out.pixelColor(x, 3).alpha(), 255); +} + +void DitherTests::chunkyPixelsFillTheirBlocks() +{ + const QImage out = style(DitherStyle::bayer8, 4).apply(ramp(18, 10)); + QCOMPARE(out.size(), QSize(18, 10)); + // One colour a 4 × 4 block; edge blocks cut. + for (int y = 0; y < 10; ++y) + for (int x = 0; x < 18; ++x) + QCOMPARE(out.pixel(x, y), out.pixel(x / 4 * 4, y / 4 * 4)); + QCOMPARE(colours(out.copy(0, 0, 16, 8)), (std::set<QRgb>{qRgb(0, 0, 0), qRgb(255, 255, 255)})); + // A cut block averages in the clear beyond the edge. + QCOMPARE(out.pixelColor(17, 0).alpha(), 128); + QCOMPARE(out.pixelColor(0, 9).alpha(), 128); + QCOMPARE(out.pixelColor(17, 9).alpha(), 64); + // Diffused small: each block's exact average, then its pixel. + const QImage source = ramp(18, 10); + QImage averaged = BrushRaster::context(5, 3, false); + for (int y = 0; y < 3; ++y) { + for (int x = 0; x < 5; ++x) { + std::array<int, 4> sum{}; + for (int sy = y * 4; sy < std::min(10, y * 4 + 4); ++sy) + for (int sx = x * 4; sx < std::min(18, x * 4 + 4); ++sx) + for (int channel = 0; channel < 4; ++channel) + sum[size_t(channel)] += source.constScanLine(sy)[sx * 4 + channel]; + for (int channel = 0; channel < 4; ++channel) + averaged.scanLine(y)[x * 4 + channel] = uchar((sum[size_t(channel)] + 8) / 16); + } + } + const QImage small = style(DitherStyle::floydSteinberg).apply(averaged); + const QImage chunky = style(DitherStyle::floydSteinberg, 4).apply(source); + for (int y = 0; y < 10; ++y) + for (int x = 0; x < 18; ++x) + QCOMPARE(chunky.pixel(x, y), small.pixel(x / 4, y / 4)); + // A block's average: half black, half white reads as gray. + QImage halves = BrushRaster::context(8, 8, false); + for (int y = 0; y < 8; ++y) + for (int x = 0; x < 8; ++x) + halves.setPixelColor(x, y, x % 2 ? Qt::white : Qt::black); + DitherSettings levels = style(DitherStyle::bayer2, 8); + levels.levels = 3; + QCOMPARE(colours(levels.apply(halves)), (std::set<QRgb>{qRgb(128, 128, 128)})); +} + +void DitherTests::dotsLeaveTheirCornersToTheDarkColour() +{ + DitherSettings dots = style(DitherStyle::bayer2, 8); + dots.pixelShape = DitherPixelShape::dot; + const QImage out = dots.apply(filled(16, 16, Qt::white)); + // White dots on black, each centred in its block. + QCOMPARE(out.pixel(0, 0), qRgb(0, 0, 0)); + QCOMPARE(out.pixel(4, 4), qRgb(255, 255, 255)); + QCOMPARE(out.pixel(8, 15), qRgb(0, 0, 0)); + QCOMPARE(out.pixel(12, 12), qRgb(255, 255, 255)); + // Two Colors: the gaps are the dark one. + dots.colors = DitherColors::twoColors; + dots.dark = AdjustmentColor(0, 0, 1); + QCOMPARE(dots.apply(filled(16, 16, Qt::white)).pixel(0, 0), qRgb(0, 0, 255)); + // Square pixels have no gaps. + dots.pixelShape = DitherPixelShape::square; + QCOMPARE(dots.apply(filled(16, 16, Qt::white)).pixel(0, 0), qRgb(255, 255, 255)); +} + +void DitherTests::marksDrawLightOnDarkOrDarkOnLight() +{ + for (const DitherStyle each : {DitherStyle::dots, DitherStyle::lines, DitherStyle::diamonds, DitherStyle::patterns, DitherStyle::ascii}) { + DitherSettings marks = style(each); + const QImage glowing = marks.apply(ramp(64, 64)); + // Hard marks, but for the characters' smoothed ink. + if (each == DitherStyle::ascii) { + for (const QRgb colour : colours(glowing)) + QVERIFY(qRed(colour) == qGreen(colour) && qGreen(colour) == qBlue(colour)); + } else { + QCOMPARE(colours(glowing), (std::set<QRgb>{qRgb(0, 0, 0), qRgb(255, 255, 255)})); + } + marks.lightOnDark = false; + const QImage inked = marks.apply(ramp(64, 64)); + QVERIFY2(inked != glowing, qPrintable(rawValue(each))); + // Black stays black, white white, either way. + QCOMPARE(qRed(glowing.pixel(0, 32)), 0); + QCOMPARE(qRed(inked.pixel(63, 32)), 255); + } +} + +void DitherTests::glyphsAreSortedByTheirInk() +{ + const DitherSettings::Glyphs glyphs = DitherSettings::glyphs(QStringLiteral("@. .#"), 8); + // Four distinct: space, dot, hash, at; 8 × 8 maps. + QCOMPARE(glyphs.coverage.size(), size_t(4)); + QCOMPARE(glyphs.maps.size(), size_t(4 * 64)); + QCOMPARE(glyphs.coverage[0], 0.0f); + QVERIFY(glyphs.coverage[1] > 0 && glyphs.coverage[1] < glyphs.coverage[2] && glyphs.coverage[2] <= glyphs.coverage[3]); + QVERIFY(*std::max_element(glyphs.maps.begin() + 192, glyphs.maps.end()) > 200); + QCOMPARE(DitherSettings::glyphs(DitherSettings::defaultCharacters(), 12).coverage.size(), size_t(10)); + // A 20-pixel cell: M at 23 pixels, 15–19 tall. + const std::vector<uint8_t> m = DitherSettings::glyphs(QStringLiteral("M"), 20).maps; + int top = 20, bottom = -1; + bool smooth = false; + for (int index = 0; index < 400; ++index) { + if (m[size_t(index)] > 128) { + top = std::min(top, index / 20); + bottom = std::max(bottom, index / 20); + } + smooth = smooth || (m[size_t(index)] > 0 && m[size_t(index)] < 255); + } + QVERIFY2(bottom - top + 1 >= 15 && bottom - top + 1 <= 19, qPrintable(QString::number(bottom - top + 1))); + QVERIFY(smooth); + // Centred on its ink both ways: a dot mid cell. + const std::vector<uint8_t> dot = DitherSettings::glyphs(QStringLiteral("."), 20).maps; + double sum = 0, sumX = 0, sumY = 0; + for (int index = 0; index < 400; ++index) { + sum += dot[size_t(index)]; + sumX += dot[size_t(index)] * (index % 20 + 0.5); + sumY += dot[size_t(index)] * (index / 20 + 0.5); + } + QVERIFY2(std::abs(sumX / sum - 10) < 0.6 && std::abs(sumY / sum - 10) < 0.6, qPrintable(QStringLiteral("%1 %2").arg(sumX / sum).arg(sumY / sum))); + // Canonically equal graphemes are one, as Swift's Characters. + QCOMPARE(DitherSettings::glyphs(QStringLiteral("\u00E9e\u0301"), 20).coverage.size(), size_t(1)); +} + +void DitherTests::densityAndContrastMoveTheThreshold() +{ + // Bayer 8 × 8 on flat gray: whites follow tone. + const auto whites = [](int gray, double density, double contrast) { + DitherSettings settings = style(DitherStyle::bayer8); + settings.density = density; + settings.contrast = contrast; + const QImage out = settings.apply(filled(8, 8, QColor(gray, gray, gray))); + int count = 0; + for (int y = 0; y < 8; ++y) + for (int x = 0; x < 8; ++x) + count += qRed(out.pixel(x, y)) == 255; + return count; + }; + // Density: gamma 2^(1.5 d), more ink as it rises. + QCOMPARE(whites(128, 0, 0), 32); + QCOMPARE(whites(128, 50, 0), 20); + QCOMPARE(whites(128, -50, 0), 42); + // Contrast pivots on mid gray, steeper as it rises. + QCOMPARE(whites(153, 0, 0), 38); + QCOMPARE(whites(153, 0, 50), 44); + QCOMPARE(whites(153, 0, -50), 35); +} + +void DitherTests::theScreenTurnsWithItsAngle() +{ + // Halftone lines at 0°: rows alike; at 90°: columns alike. + for (const double angle : {0.0, 90.0}) { + DitherSettings lines = style(DitherStyle::lines); + lines.angle = angle; + lines.cellSize = 8; + const QImage out = lines.apply(filled(32, 32, QColor(100, 100, 100))); + bool alike = true, varied = false; + for (int a = 0; a < 32; ++a) { + for (int b = 0; b < 32; ++b) { + const QRgb here = angle == 0 ? out.pixel(b, a) : out.pixel(a, b); + alike = alike && here == (angle == 0 ? out.pixel(0, a) : out.pixel(a, 0)); + varied = varied || here != out.pixel(0, 0); + } + } + QVERIFY2(alike && varied, qPrintable(QString::number(angle))); + } +} + +void DitherTests::asciiDrawsWithItsOwnCharacters() +{ + DitherSettings ascii = style(DitherStyle::ascii); + ascii.cellSize = 12; + const QImage source = ramp(48, 24); + // A blank alphabet marks nothing: all the dark paper. + ascii.characters = QStringLiteral(" "); + QCOMPARE(colours(ascii.apply(source)), (std::set<QRgb>{qRgb(0, 0, 0)})); + // None at all: Swift's default alphabet. + ascii.characters = QString(); + const QImage fallback = ascii.apply(source); + ascii.characters = DitherSettings::defaultCharacters(); + QCOMPARE(fallback, ascii.apply(source)); + // Another alphabet draws otherwise. + ascii.characters = QStringLiteral(" #"); + QVERIFY(ascii.apply(source) != fallback); +} + +QTEST_MAIN(DitherTests) +#include "DitherTests.moc" diff --git a/tests/FilterFailureTests.cpp b/tests/FilterFailureTests.cpp index 302ac09..56db939 100644 --- a/tests/FilterFailureTests.cpp +++ b/tests/FilterFailureTests.cpp @@ -12,6 +12,7 @@ class FilterFailureTests : public QObject { private slots: void theBlursFailAsAContextWhenTheirRowsFindNoMemory(); void aTrimThatFindsNoMemoryIsARenderError(); + void aDitherThatFindsNoMemoryIsARenderError(); }; void FilterFailureTests::theBlursFailAsAContextWhenTheirRowsFindNoMemory() @@ -44,5 +45,35 @@ void FilterFailureTests::aTrimThatFindsNoMemoryIsARenderError() QVERIFY_THROWS_EXCEPTION(ExportError, PixelFilter::trimmed(framed, LayerTransform{.origin = {0, 0}, .size = {6000, 4000}})); } +void FilterFailureTests::aDitherThatFindsNoMemoryIsARenderError() +{ + DitherSettings dither; + dither.pixelSize = 1; + // Another format: its conversion finds no memory. + QImage plain(4000, 4000, QImage::Format_RGB32); + plain.fill(Qt::gray); + malloc_trim(0); + { + const AddressSpaceLimit limit(16ll * 1024 * 1024); + QVERIFY_THROWS_EXCEPTION(ExportError, dither.apply(plain)); + } + // The 64 MB copy fits; the kernel's planes do not. + const QImage premultiplied = plain.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + plain = QImage(); + malloc_trim(0); + { + const AddressSpaceLimit limit(96ll * 1024 * 1024); + QVERIFY_THROWS_EXCEPTION(ExportError, dither.apply(premultiplied)); + } + // Chunky pixels dither a small copy: the same limit holds. + dither.pixelSize = 8; + dither.colors = DitherColors::original; + malloc_trim(0); + { + const AddressSpaceLimit limit(96ll * 1024 * 1024); + QCOMPARE(dither.apply(premultiplied).size(), QSize(4000, 4000)); + } +} + QTEST_GUILESS_MAIN(FilterFailureTests) #include "FilterFailureTests.moc" diff --git a/tests/FilterModelTests.cpp b/tests/FilterModelTests.cpp index 5e4de3a..0f78b12 100644 --- a/tests/FilterModelTests.cpp +++ b/tests/FilterModelTests.cpp @@ -66,7 +66,8 @@ 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::tonalContrast, "Tonal Contrast"}, + {FilterKind::vignette, "Vignette"}, {FilterKind::bloomGlow, "Bloom / Glow"}, {FilterKind::dither, "Dither"}, + {FilterKind::tonalContrast, "Tonal Contrast"}, {FilterKind::lensCorrection, "Lens Correction"}, {FilterKind::cameraRaw, "Camera Raw Filter"}, {FilterKind::removeBackground, "Remove Background"}, {FilterKind::contentAwareFill, "Content-Aware Fill"}, {FilterKind::curves, "Curves"}, {FilterKind::exposure, "Exposure"}, @@ -79,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 >= 10); + QCOMPARE(isImageAdjustment(kind), index >= 11); } // Remove Background's qualities, Swift's words and order. QVERIFY(allBackgroundQualities.size() == 2 && allBackgroundQualities[0] == BackgroundQuality::basic); diff --git a/tests/FinishingFilterTests.cpp b/tests/FinishingFilterTests.cpp index 3bbf7e4..988c7dd 100644 --- a/tests/FinishingFilterTests.cpp +++ b/tests/FinishingFilterTests.cpp @@ -213,7 +213,8 @@ void FinishingFilterTests::settingsKeepSwiftsDefaultsAndBounds() QCOMPARE(rawValue(FilterKind::vignette), QString("Vignette")); 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::tonalContrast); + QVERIFY(allFilterKinds[3] == FilterKind::vignette && allFilterKinds[4] == FilterKind::bloomGlow && allFilterKinds[5] == FilterKind::dither + && allFilterKinds[6] == FilterKind::tonalContrast); QVERIFY(!isImageAdjustment(FilterKind::bloomGlow) && !isAutomatic(FilterKind::tonalContrast) && !isAutomatic(FilterKind::vignette)); } diff --git a/tests/Tests.cmake b/tests/Tests.cmake index ec0b717..76ab63c 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -173,6 +173,9 @@ compositor_test(TextColorRunsTests) compositor_test(TextColorModelTests) compositor_test(TextColorEditorTests) compositor_test(TextEditingGatesTests) +compositor_test(DitherTests) +compositor_test(DitherSheetTests) +compositor_test(DitherKernelTests) compositor_test(RecentProjectsCoreTests) compositor_test(ProjectWorkspaceSignalTests) compositor_test(ProjectWorkspaceTests)