Blur and noise adjustment layers, Image Trim, Inner Glow (12.8c)
This commit is contained in:
1 parent
7ae263de80
commit
31e2dd3864
60 files changed
+1673
-105
No files matched your search
@@ -127,6 +127,7 @@ Platform adaptations, each forced by a missing Qt analog:
|
||||
- The Camera Raw panel (12.8b1, Swift 1.2.3): Swift docks the filter's `NSPanel` to the document window's right edge, full height, following it (`dockedToMainWindowRight`). Wayland lets no client place a window, and a tiling compositor would tile a second one, so the twin docks inside the window: `FloatingPanel::show` takes a dock slot, `ContentView`'s `panelDock` at the right of the middle row, `FloatingPanel::dockedWidth` (Swift's 440) wide, and there the panel is its own `PanelWindow` made a plain child (`Qt::Widget`), so `QDialog` still takes Escape as Cancel and Return as the default button; a header holds the title and a close button, which cancels. A docked frame is never remembered (`moveEvent` asks `isWindow`), so Gaussian Blur and the rest reopen where they were left, Swift's `dockedPlacementLeavesTheSavedFilterPosition`; `refocus` hands a docked panel the keys through `setFocus`, looking past the hidden floating panel of the same name, which is the window's child too. The dock takes room beside the layers panel and spans the middle row alone, so the tool bar and status bar keep their width, where Swift's panel lies over the window's whole height (known difference). Refocus hands the keys to the docked panel itself, not the field last typed in (known difference: `makeKey` keeps its first responder). `UI/CameraRawSlider` twins Swift's file: `CameraRawSliderTrack` with Swift's colours, the gradient drawn 4 points high across the groove under the style's knob, a double click on the knob (its rect two points out) resetting; a press on the track is `SliderSnap`'s (10.5): Swift's 0.18 s glide has no twin, and a drag goes on from the snapped knob, as every slider of the twin does (known differences); `display` keeps a knob under a drag, Swift's `isTrackingValue`; the track is fixed at construction: Swift's track update in `updateNSView` has no twin, since the rows whose tracks change (12.8b2's mixer) are made anew. `UI/CameraRawControls.h/.cpp` and `CameraRawControls+Basic.cpp` (Light, Color, Effects) twin Swift's view: the scope 110 high on black at 35%, the three ribbons at 55% in SwiftUI's dark red, green and blue (the Levels histogram's values) or the vectorscope's cells, square by the width and counted up from the bottom, so a 110-point graph shows its lowest 18 rows, as Swift's does, its context menu choosing the mode, the two clipping triangles lit in that blue and red, the readout; the words say Alt for Option and a right click for Control-click; the ten groups, Light and Color open, each eye shown while its group adjusts; a row's title (96 points at least, a double click resetting), `CameraRawSlider` and a 56-point `PickerField` showing Swift's `.number` to the row's decimals. A row's object name is its settings field (`vignetteAmountSlider`), since titles repeat. Alt is read from `QGuiApplication::keyboardModifiers`, Swift's `NSEvent.modifierFlags`, and its release (auto-repeat aside), from any window, ends the clipping and sharpen-mask views (Swift's local monitor). Curve to Calibration fill their groups in 12.8b2, and the Filter menu's entry waits for them. On the canvas (`EditorCanvas+CameraRaw.cpp`) a hover reads the readout (Swift's `mouseMoved`, never a drag), leaving clears it, the three eyedroppers sample and hand the panel the keys, the guide and the targeted drag begin, go on and end on release, and a move without the left button ends either, since Qt can lose a release; the four tools are `picking`, so the eyedropper shows. `EyedropperIcon::icon` came from `LevelsSheet`, which shares it. Left out as dead: the canvas's `kind == cameraRaw` and edit terms before the readout (the session asks), a hide of the other placement's panel in `show` (every edit closes its panel first), Swift's clipping reset before a row's reset (`assign` clears it), and the docked panel's own hide on close (its slot hides it). Equivalent mutants: the titles' minimum width, 96 points (a layout sizes labels offscreen alike), and the unlit triangle's 55% white (cosmetic, over whatever the scope shows). Two consequences of the named differences: a double click on a slider's track resets it, since the first press puts the knob under the second; and a canvas press takes the keys, so a field still being typed in commits at the click, where Swift's panel keeps its field editor. Tests: `CameraRawSliderTests` (Swift's three), `CameraRawPanelTests`, `CameraRawCanvasTests` (the dock in a real window among them), `FloatingPanelTests::dockedPlacementLeavesTheSavedFilterPosition`.
|
||||
- The Camera Raw panel's other groups (12.8b2, Swift 1.2.3): `UI/CameraRawColorControls.h/.cpp` (Curve), `+Mixer.cpp` and `+Grading.cpp`, `UI/CameraRawDetailOpticsControls` and `UI/CameraRawGeometryCalibrationControls` twin Swift's three files, each view a `QWidget` over the session, synchronized on `changed()`. `UI/CameraRawRow` is Linux-only: the row each Swift view builds for itself (`sharpenSlider`, `opticsSlider`, `geometrySlider`, `calibrationSlider`, the curve's and mixer's sliders, 12.8b1's `slider`): a title of at least or exactly a width, `CameraRawSlider`, a field or none, a double click on the title resetting where Swift's main groups do; each view rounds the slider's value as its Swift twin does (Detail to whole numbers, Optics when its range starts at zero, Geometry and Calibration always, the rest not). Swift's segmented pickers are `cameraRawSegments`, touching checkable buttons in an exclusive `QButtonGroup`. The curve graph: a still press and release adds a point, a drag past two points moves the nearest divider or inner point, a double click removes the nearest inner point within 0.04 and its trailing release adds nothing, so the net is Swift's: SwiftUI adds on each click, `repair` drops the second as a duplicate and the double tap removes one. The graph is `CameraRawCurveGraph`: a class defined in a `.cpp` outside an anonymous namespace is global, and the Curves sheet's `CurveGraph` would share its vtable across the link. The mixer's rows whose help or track follow its tab, swatch or chosen point are made anew when those change, as SwiftUI renders them anew; a picked colour's swatch is shown at once, since a layout shows late children only later. The grading wheel fills a `QConicalGradient`, which runs counterclockwise from the right as the hue does (1.2.9's `96edd5f`), takes presses and double clicks inside its circle alone (Swift's `contentShape`), and Swift's `theDockedPanelFitsTheGradingWheels` holds; its stops are 96edd5f's own, pure HSV hues every 30 degrees (v1.2.3's six SwiftUI system colours predate that fix). Detail's idle noise rows are disabled under a 45% `QGraphicsOpacityEffect`, Swift's `.opacity(0.45).disabled`. Targeted Adjustment and Draw Guides are checkable push buttons, checked while armed, where Swift tints them (known difference); their SF Symbols, `scope` and `line.diagonal`, are `LayerIcons` paths. The Filter menu lists Camera Raw Filter… after Lens Correction, as Swift's. Known differences: a mixer family's field still being typed in loses its typing when the tab changes (the row is made anew), where SwiftUI's field would commit into the new tab; Equivalent mutants: the wheel's saturation cap (`normalized` clamps it), the picked swatches made anew only when their count changes (it spares rebuilds; Qt guards a swatch deleted in its own click) and the page menu's size policy (layout). The point-colour eyedropper is flat like the other two, where Swift leaves it bordered (cosmetic). Cosmetic, Swift's values and unpinned: the swatches' 18 points, Detail's idle 45%, the hue ranges' inset (title width and 10), the rows' spacing of 10 and the 11-pixel subheadlines. Tests: `CameraRawRowTable.h` walked by `CameraRawGroupRowsTests` (every row against Swift's values), `CameraRawGroupDrawTests` (drawing, pages, gestures, the panel through `FilterSheet`), `CameraRawGroupWordsTests` (every text, captions, buddies, the pickers' states, the mixer's pages, the wheel's lower half and rim, the curve's line, repaint and selected point), `CameraRawCurveMixerTests`, `CameraRawGradingLensTests`, the menu's entries in `CompositorMenusTests`.
|
||||
- Hash containers on the render path (12.8e): Qt 6.4 declares `QHashPrivate::Data::findOrInsert` `noexcept` while it allocates, so a `QHash` or `QSet` insert that finds no memory calls `std::terminate`, past every catch (found by a backtrace of `ProjectExportFailureTests` under load: the JPEG worker's render aborted in `LiveMaskGraph::validate`). Code a worker runs under a catch that promises to survive memory running out uses `std::map` and `std::set`, whose inserts throw: `LiveMaskGraph::validate`, `FolderMaskClip::draw` and `LiveMaskRenderer`'s caches (and `LiveMaskBaker::bake` beside `validate`, though its workers catch `ExportError` alone, so a `bad_alloc` there still ends the app, by design), as `ImageExporter::render` already keyed its records. The UI thread's own `QHash`es stay: a failure there ends in Qt's event loop either way. `RenderStarvationTests` replaces `operator new` on its thread and fails each allocation of `validate`, `prepareStacks` and `FolderMaskClip::draw` in turn: each must throw `std::bad_alloc`; the three aborted with the old containers. Painting is left out of it: Qt's painter is not exception-safe, and a failure injected inside it can leave a lock held.
|
||||
- Blur and noise adjustment layers, Image Trim and Inner Glow (12.8c, Swift 1.2.3: `98ad817`, `24052b5`, `feb39f3`, `cd0ca3c`). `AdjustmentKind` gains Add Noise, Gaussian Blur and Motion Blur after Grain, v1.2.3's order (it moved Add Noise ahead of the blurs), with `circle.dotted`, `drop.fill` and `wind` as `LayerIcons` paths. `LayerAdjustment` keeps Swift's seven optionals (`blurRadius`, `motionAngle`, `motionDistance`, `noiseAmount`, `noiseGaussian`, `noiseMonochromatic`, `noiseSeed`), their resolved getters and setters with Swift's defaults (10, 0, 10, 10, off, off, 0), `isValid`'s bounds on every kind, as Swift's, and `samplingMargin`; the manifest writes each only when set, the seed a `UInt32` read as Grain's; the store takes the three kinds from version 9, which it now writes (versions 1–9). `apply` runs the three through `PixelFilter::run` with the layer's own seed; `FilterJob::noiseOrigin` sits last, so the positional initialisers keep their order, and `kernels/NoisePixels.c` is v1.2.3's (`noise_add_at`, whose position hash reshuffles the Add Noise filter's grain too). A new Add Noise layer rolls its seed, as Grain does; the editor opens the kind's filter panel on the layer's settings and writes back that kind's alone. Adaptation: Swift's canvas surface holds a pixel a point, so its region's origin and `scale` are already the surface's pixels; the twin's surfaces hold device pixels, so `LiveMaskRenderer::adjust` passes `adjustmentScale` (the canvas's zoom, Swift's, 1 elsewhere) times the device's pixels a region unit, and `apply` counts the noise origin in the image's pixels, so a HiDPI canvas blurs at its own density and the noise stays one field across dirty rects. `AdjustmentSurface::draw` takes Swift's `padding` in the painter's units: the halo is allocated past the shown area, not cut to the device (the canvas's outer surface is dirty-sized), and drawn back under the painter's clip. The canvas pads by the widest visible `samplingMargin` times the zoom, and its crisp path (from 200%) now draws the layers through a padded surface as well, as Swift's `drawLayers` wraps its own there. Known difference, Swift's too: on the smooth path the canvas's noise follows the view, as Grain's does; on the crisp path the surface's region starts at the repaint region, so each partial repaint there draws a noise field of its own. The crisp path wraps its surface even without an adjustment, where Swift's `drawLayers` wraps only for one: pixel-identical (source-over onto a cleared raster), one allocation a repaint region, as the smooth path already does. The HiDPI product is pinned by its two factors (the zoom on the canvas, the device ratio on a scaled composite), not at a device ratio of two itself. Image Trim: `Document/ImageTrim` twins Swift's file: `TrimBasedOn` by raw value, `TrimOptions` (a tolerance per premultiplied channel, alpha included, which the sheet leaves at 0), `calculateTrimRect` (the alpha bounds, or each row scanned in from both ends against the corner's pixel) and `trim` (the export's render, then `CanvasResizer` by the content offset, or the snapshot itself when nothing is cut). `UI/TrimSheet` is Swift's sheet: 320 wide, the title, Based On as radio buttons, Trim Away's four checkboxes, OK resting while none is checked, Return and Escape through `NativeShortcut`. `ProjectController::trim` runs as Canvas Size does, through `resizeProject`, whose resize may now return nothing: Swift's trim with nothing left returns with no alert and no step. The Image menu's Trim… follows Image Size…, with no shortcut. Left out as dead: `TrimError` (never thrown), `trimImage` and the session's `trim` (called by Swift's tests alone; the twin tests the rect and the controller's two steps), the kept rect's `maxX > minX` guard (the early returns leave content, so left < right and top < bottom) and the colour scan's `last > first` (the row's first unmatched pixel stops it). A fix beyond Swift: a raster that cannot be converted is `ExportError(render)`, explained, where Swift's `try?` reports nothing left; that failure path is untested, since only a failed allocation reaches it. Inner Glow joins as Outer Glow did (12.5b): `InnerGlowEffect` (10 pixels, white, 75%; 0–500, 0–1), `LayerEffectKind::innerGlow` last; `LayerEffectsKernel` blurs the shape at sigma size ÷ 2 and keeps shape × (1 − blur), composed over the colour overlay and under the inner shadow, Metal's order; no margin (Swift's `innerGlowRendersInsideSourceWithoutBoundsExpansion`), the stroke surface's reach three sizes and two. Swift's CPU `innerGlowCoverage` is dead here, as Outer Glow's is, so its parity test has no twin. A fix beyond Swift: a reduced preview scales the glow's size. The sheet shows its swatch, Size (0–100, 500 typed, px) and Opacity. Equivalent mutants: the canvas's margin counting hidden layers (it only widens the halo), the glow's `opacity > 0` (a clear glow composes nothing; the term spares the blur) and Trim's return Line truncated
|
||||
- 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 the mean of its edge pixels, as Swift's `edgeTone`. `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.
|
||||
|
||||
@@ -69,6 +69,7 @@ add_library(compositor_core STATIC
|
||||
src/Document/LevelsAutomatic.cpp
|
||||
src/Document/Curves.cpp
|
||||
src/Document/ImageAdjustments.cpp
|
||||
src/Document/ImageTrim.cpp
|
||||
src/Document/HueSaturation.cpp
|
||||
src/Document/HueSaturation+Filter.cpp
|
||||
src/Document/HueSaturation+Session.cpp
|
||||
@@ -93,6 +94,7 @@ add_library(compositor_core STATIC
|
||||
src/UI/CanvasRulers.cpp
|
||||
src/UI/CanvasSizeSheet.cpp
|
||||
src/UI/ImageSizeSheet.cpp
|
||||
src/UI/TrimSheet.cpp
|
||||
src/UI/JPEGExportSheet.cpp
|
||||
src/UI/ByteCounts.cpp
|
||||
src/UI/SliderSnap.cpp
|
||||
|
||||
@@ -359,12 +359,14 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
|
||||
- 12.8b2 round 1: FAIL: two classes named `CurveGraph` shared a vtable (renamed); a real double click re-added the point; the wheel's double click reset outside the circle; 79 of 81 mutants lived. All fixed, with row tables and drawing tests.
|
||||
- 12.8b2 round 2: FAIL, residue: words, captions, buddies, picker states, page visibility, the wheel's lower half and rim, the curve's line, repaints, the selected point. All pinned.
|
||||
- 12.8b2 round 3: FAIL: the AGENTS.md wheel sentence was wrong; the widgets' following of the model and two guards were unpinned. Fixed after the round and closed, as the rule allows; the last mutants caught.
|
||||
- [ ] 12.8c Version 1.2.3, part four: Inner Glow, Image Trim, blur and noise adjustment layers, the layer context menu without 1.2.5's dropped Layer Effects entry (`5d7adba`).
|
||||
- [x] 12.8c Version 1.2.3, part four: Inner Glow, Image Trim, blur and noise adjustment layers (`98ad817`, `24052b5`, `feb39f3`, `cd0ca3c`). The layer context menu is 1.2.5's (`func menu(for:)` first appears there), so it moved to 12.10.
|
||||
- 12.8c round 1: FAIL, two unpinned behaviours and two wordings: the Trim sheet was missing from `everySheetBindsSwiftsKeys`, the canvas's widest margin had no test with two blurs, a test comment contradicted the resizer's own shortcut, and AGENTS.md lacked the crisp path's noise field, its unconditional surface and the untested conversion failure. All fixed, each mutant caught.
|
||||
- 12.8c round 2: PASS. Its notes taken: the halo test's zooms named, `versionupdate.md` left to 12.8r.
|
||||
- [ ] 12.8r Release OmaPhoto 1.2.3 once 12.8b2 and 12.8c are in (the user's decision, 2026-09-27): the GitHub release, and `versionupdate.md` with what changed since 1.0.1.
|
||||
- [x] 12.8d A `std::bad_alloc` aborted `ProjectExportFailureTests` under a parallel run (seen in 12.7b and 12.8a; alone it passed ten of ten): the panel tests held a memory limit while dialogs painted, and the panel limits sat at the edge of what the panel and its snapshot need.
|
||||
- 12.8d round 1: PASS. Its notes taken: the snapshot's measured peak named in AGENTS.md, `malloc_trim` in `limited` named equivalent.
|
||||
- [ ] 12.9 Version 1.2.4: the finishing filters, stepped keyboard zoom, a steady tool rail and tab strip, the project format's version in one place.
|
||||
- [ ] 12.10 Versions 1.2.5–1.2.7: copying and pasting whole layers within and between projects, duplicating every selected layer, crop ratios 3:4 and 9:16 and a crop that starts at the selection, Vignette's defaults, text colour previews.
|
||||
- [ ] 12.10 Versions 1.2.5–1.2.7: the layer context menu without its dropped Layer Effects entry (`5d7adba`), copying and pasting whole layers within and between projects, duplicating every selected layer, crop ratios 3:4 and 9:16 and a crop that starts at the selection, Vignette's defaults, text colour previews.
|
||||
- [ ] 12.11 Versions 1.2.8–1.2.10: the document's own pixel budget, PSB, simple Photoshop text as editable text, background-only PSDs, oversized Photoshop layers cropped, clicked text starting on its first baseline, live text opened by a double click with the Move tool (`a5721b2`), marching ants at screen resolution, SVG import (Qt SVG).
|
||||
- [ ] 12.12 Version 1.2.11: masks painted anywhere on the canvas, moved unlinked masks under effects, effects kept on screen while text is edited, masks painted and steps undone (`b683acb`), mask thumbnails' ground, text shown as its committed pixels, numeric label scrubbing.
|
||||
- [ ] 12.13 Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), editing while a project saves, images decoded from memory, the guide for AI agents writing projects.
|
||||
|
||||
@@ -14,6 +14,12 @@ static inline float noise_unit(uint32_t key) { return (float)(noise_hash(key) >>
|
||||
|
||||
void noise_add(uint8_t *rgba, size_t width, size_t height, size_t stride,
|
||||
float amount, int gaussian, int monochromatic, uint32_t seed) {
|
||||
noise_add_at(rgba, width, height, stride, amount, gaussian, monochromatic, seed, 0, 0);
|
||||
}
|
||||
|
||||
void noise_add_at(uint8_t *rgba, size_t width, size_t height, size_t stride,
|
||||
float amount, int gaussian, int monochromatic, uint32_t seed,
|
||||
int64_t origin_x, int64_t origin_y) {
|
||||
float spread = amount / 100.0f * 127.5f;
|
||||
for (size_t y = 0; y < height; ++y) {
|
||||
uint8_t *row = rgba + y * stride;
|
||||
@@ -21,7 +27,9 @@ void noise_add(uint8_t *rgba, size_t width, size_t height, size_t stride,
|
||||
uint8_t *p = row + x * 4;
|
||||
unsigned alpha = p[3];
|
||||
if (!alpha) continue;
|
||||
uint32_t base = noise_hash(seed ^ noise_hash((uint32_t)(y * width + x)));
|
||||
uint32_t px = (uint32_t)(origin_x + (int64_t)x);
|
||||
uint32_t py = (uint32_t)(origin_y + (int64_t)y);
|
||||
uint32_t base = noise_hash(seed ^ noise_hash(px * 0x9e3779b9U ^ noise_hash(py * 0x85ebca6bU)));
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
uint32_t key = monochromatic ? base : base + (uint32_t)c * 0x9e3779b9U;
|
||||
float n;
|
||||
|
||||
@@ -9,4 +9,7 @@
|
||||
// Each pixel's noise depends only on its position and `seed`, so the same seed gives the same grain.
|
||||
void noise_add(uint8_t *rgba, size_t width, size_t height, size_t stride,
|
||||
float amount, int gaussian, int monochromatic, uint32_t seed);
|
||||
void noise_add_at(uint8_t *rgba, size_t width, size_t height, size_t stride,
|
||||
float amount, int gaussian, int monochromatic, uint32_t seed,
|
||||
int64_t origin_x, int64_t origin_y);
|
||||
#endif
|
||||
@@ -81,6 +81,9 @@ void EditorSession::openAdjustmentEditor(QUuid id, const LayerAdjustment &origin
|
||||
case AdjustmentKind::exposure:
|
||||
case AdjustmentKind::gradientMap:
|
||||
case AdjustmentKind::grain:
|
||||
case AdjustmentKind::addNoise:
|
||||
case AdjustmentKind::gaussianBlur:
|
||||
case AdjustmentKind::motionBlur:
|
||||
case AdjustmentKind::blackWhite:
|
||||
case AdjustmentKind::colorBalance: {
|
||||
FilterSettings settings;
|
||||
@@ -90,6 +93,12 @@ void EditorSession::openAdjustmentEditor(QUuid id, const LayerAdjustment &origin
|
||||
settings.grain = original.grain();
|
||||
settings.blackWhite = original.blackWhite();
|
||||
settings.colorBalance = original.colorBalance();
|
||||
settings.radius = original.gaussianRadius();
|
||||
settings.angle = original.resolvedMotionAngle();
|
||||
settings.distance = original.resolvedMotionDistance();
|
||||
settings.amount = original.resolvedNoiseAmount();
|
||||
settings.gaussian = original.resolvedNoiseGaussian();
|
||||
settings.monochromatic = original.resolvedNoiseMonochromatic();
|
||||
m_filterEdit.emplace(filterKind(original.kind).value(), layer, std::nullopt, settings, std::nullopt);
|
||||
break;
|
||||
}
|
||||
@@ -121,6 +130,16 @@ LayerAdjustment EditorSession::editedAdjustment() const
|
||||
case AdjustmentKind::grain: value.setGrain(m_filterEdit.value().settings.grain); break;
|
||||
case AdjustmentKind::blackWhite: value.setBlackWhite(m_filterEdit.value().settings.blackWhite); break;
|
||||
case AdjustmentKind::colorBalance: value.setColorBalance(m_filterEdit.value().settings.colorBalance); break;
|
||||
case AdjustmentKind::gaussianBlur: value.setGaussianRadius(m_filterEdit.value().settings.radius); break;
|
||||
case AdjustmentKind::motionBlur:
|
||||
value.setResolvedMotionAngle(m_filterEdit.value().settings.angle);
|
||||
value.setResolvedMotionDistance(m_filterEdit.value().settings.distance);
|
||||
break;
|
||||
case AdjustmentKind::addNoise:
|
||||
value.setResolvedNoiseAmount(m_filterEdit.value().settings.amount);
|
||||
value.setResolvedNoiseGaussian(m_filterEdit.value().settings.gaussian);
|
||||
value.setResolvedNoiseMonochromatic(m_filterEdit.value().settings.monochromatic);
|
||||
break;
|
||||
case AdjustmentKind::invert: throw std::logic_error("Invert has no editor to read");
|
||||
}
|
||||
return value;
|
||||
|
||||
@@ -134,8 +134,8 @@ QImage PixelFilter::run(const FilterJob &job)
|
||||
break;
|
||||
case FilterKind::addNoise:
|
||||
image = drawn(job.image);
|
||||
noise_add(image.bits(), size_t(width), size_t(height), size_t(image.bytesPerLine()), float(settings.amount), settings.gaussian ? 1 : 0,
|
||||
settings.monochromatic ? 1 : 0, job.seed);
|
||||
noise_add_at(image.bits(), size_t(width), size_t(height), size_t(image.bytesPerLine()), float(settings.amount), settings.gaussian ? 1 : 0,
|
||||
settings.monochromatic ? 1 : 0, job.seed, int64_t(std::floor(job.noiseOrigin.x())), int64_t(std::floor(job.noiseOrigin.y())));
|
||||
break;
|
||||
case FilterKind::lensCorrection: {
|
||||
// Relative to the image's size, so previews bend alike.
|
||||
|
||||
@@ -87,6 +87,8 @@ struct FilterJob {
|
||||
// The point colour shown alone, or −1 for the grade.
|
||||
int visualizesPointColor = -1;
|
||||
bool showsSharpenMask = false;
|
||||
// A live adjustment's noise field, fixed in its region's pixels.
|
||||
QPointF noiseOrigin{};
|
||||
};
|
||||
|
||||
// Swift's PixelFilter.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#include "Document/ImageTrim.h"
|
||||
#include "Document/CanvasSize.h"
|
||||
#include "IO/CanvasResizer.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include <cstdlib>
|
||||
|
||||
extern "C" {
|
||||
#include "BrushPixels.h"
|
||||
}
|
||||
|
||||
QString rawValue(TrimBasedOn basedOn)
|
||||
{
|
||||
switch (basedOn) {
|
||||
case TrimBasedOn::transparentPixels: return QStringLiteral("Transparent Pixels");
|
||||
case TrimBasedOn::topLeftPixelColor: return QStringLiteral("Top Left Pixel Color");
|
||||
case TrimBasedOn::bottomRightPixelColor: return QStringLiteral("Bottom Right Pixel Color");
|
||||
}
|
||||
throw std::logic_error("unknown trim basis");
|
||||
}
|
||||
|
||||
bool TrimOptions::trimsAny() const
|
||||
{
|
||||
return top || bottom || left || right;
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Unchecked edges stay at the image's; content is never empty.
|
||||
QRect kept(int left, int top, int right, int bottom, int width, int height, const TrimOptions &options)
|
||||
{
|
||||
const int minX = options.left ? left : 0, minY = options.top ? top : 0;
|
||||
const int maxX = options.right ? right : width, maxY = options.bottom ? bottom : height;
|
||||
return QRect(minX, minY, maxX - minX, maxY - minY);
|
||||
}
|
||||
|
||||
// Swift's calculateColorTrimRect: rows scanned in from both ends.
|
||||
std::optional<QRect> colorTrimRect(const QImage &pixels, QPoint sample, const TrimOptions &options)
|
||||
{
|
||||
const int width = pixels.width(), height = pixels.height(), tolerance = options.tolerance;
|
||||
const uchar *target = pixels.constScanLine(sample.y()) + sample.x() * 4;
|
||||
const auto matches = [&](const uchar *row, int x) {
|
||||
const uchar *pixel = row + x * 4;
|
||||
for (int channel = 0; channel < 4; ++channel) {
|
||||
if (std::abs(pixel[channel] - target[channel]) > tolerance)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
int left = width, right = 0, top = height, bottom = 0;
|
||||
for (int y = 0; y < height; ++y) {
|
||||
const uchar *row = pixels.constScanLine(y);
|
||||
int first = 0;
|
||||
while (first < width && matches(row, first))
|
||||
++first;
|
||||
if (first == width)
|
||||
continue;
|
||||
// The unmatched pixel at `first` stops this scan.
|
||||
int last = width;
|
||||
while (matches(row, last - 1))
|
||||
--last;
|
||||
left = std::min(left, first);
|
||||
right = std::max(right, last);
|
||||
top = std::min(top, y);
|
||||
bottom = y + 1;
|
||||
}
|
||||
// Every pixel matched the sample.
|
||||
if (right == 0)
|
||||
return std::nullopt;
|
||||
return kept(left, top, right, bottom, width, height, options);
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<QRect> ImageTrim::calculateTrimRect(const QImage &image, const TrimOptions &options)
|
||||
{
|
||||
if (!options.trimsAny() || image.isNull())
|
||||
return std::nullopt;
|
||||
// Swift's try? returns nil; running out here is explained instead.
|
||||
const QImage pixels = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
|
||||
if (pixels.isNull())
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
const int width = pixels.width(), height = pixels.height();
|
||||
switch (options.basedOn) {
|
||||
case TrimBasedOn::transparentPixels: {
|
||||
size_t edges[4] = {0, 0, 0, 0};
|
||||
brush_alpha_bounds(pixels.constBits(), size_t(width), size_t(height), size_t(pixels.bytesPerLine()), edges);
|
||||
// A right edge of zero: every pixel is clear.
|
||||
if (edges[2] == 0)
|
||||
return std::nullopt;
|
||||
return kept(int(edges[0]), int(edges[1]), int(edges[2]), int(edges[3]), width, height, options);
|
||||
}
|
||||
case TrimBasedOn::topLeftPixelColor: return colorTrimRect(pixels, QPoint(0, 0), options);
|
||||
case TrimBasedOn::bottomRightPixelColor: return colorTrimRect(pixels, QPoint(width - 1, height - 1), options);
|
||||
}
|
||||
throw std::logic_error("unknown trim basis");
|
||||
}
|
||||
|
||||
std::optional<ProjectSnapshot> ImageTrim::trim(const ProjectSnapshot &snapshot, const TrimOptions &options)
|
||||
{
|
||||
const std::optional<QRect> rect = calculateTrimRect(ImageExporter::render(snapshot).image, options);
|
||||
if (!rect)
|
||||
return std::nullopt;
|
||||
if (*rect == QRect(0, 0, int(snapshot.manifest.width), int(snapshot.manifest.height)))
|
||||
return snapshot;
|
||||
return CanvasResizer::resize(snapshot, CanvasSizeOptions{.width = rect->width(), .height = rect->height(),
|
||||
.contentOffset = QPointF(-rect->left(), -rect->top())});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
#include "IO/ProjectStore.h"
|
||||
#include <QImage>
|
||||
#include <QRect>
|
||||
#include <array>
|
||||
#include <optional>
|
||||
|
||||
// What Trim compares each edge against.
|
||||
enum class TrimBasedOn { transparentPixels, topLeftPixelColor, bottomRightPixelColor };
|
||||
inline constexpr std::array allTrimBasedOn{TrimBasedOn::transparentPixels, TrimBasedOn::topLeftPixelColor, TrimBasedOn::bottomRightPixelColor};
|
||||
// Swift's rawValue: the sheet's words.
|
||||
QString rawValue(TrimBasedOn basedOn);
|
||||
|
||||
struct TrimOptions {
|
||||
TrimBasedOn basedOn = TrimBasedOn::transparentPixels;
|
||||
bool top = true;
|
||||
bool bottom = true;
|
||||
bool left = true;
|
||||
bool right = true;
|
||||
// Per channel, premultiplied: how far a pixel may stray.
|
||||
quint8 tolerance = 0;
|
||||
bool trimsAny() const;
|
||||
friend bool operator==(const TrimOptions &, const TrimOptions &) = default;
|
||||
};
|
||||
|
||||
namespace ImageTrim {
|
||||
// The rect to keep, or none when nothing would remain.
|
||||
std::optional<QRect> calculateTrimRect(const QImage &image, const TrimOptions &options);
|
||||
// The rendered canvas trimmed by the canvas resizer, or none.
|
||||
std::optional<ProjectSnapshot> trim(const ProjectSnapshot &snapshot, const TrimOptions &options);
|
||||
}
|
||||
@@ -13,6 +13,9 @@ QString rawValue(AdjustmentKind kind)
|
||||
case AdjustmentKind::exposure: return QStringLiteral("Exposure");
|
||||
case AdjustmentKind::gradientMap: return QStringLiteral("Gradient Map");
|
||||
case AdjustmentKind::grain: return QStringLiteral("Grain");
|
||||
case AdjustmentKind::addNoise: return QStringLiteral("Add Noise");
|
||||
case AdjustmentKind::gaussianBlur: return QStringLiteral("Gaussian Blur");
|
||||
case AdjustmentKind::motionBlur: return QStringLiteral("Motion Blur");
|
||||
case AdjustmentKind::invert: return QStringLiteral("Invert");
|
||||
case AdjustmentKind::blackWhite: return QStringLiteral("Black & White");
|
||||
case AdjustmentKind::colorBalance: return QStringLiteral("Color Balance");
|
||||
@@ -36,6 +39,9 @@ std::optional<FilterKind> filterKind(AdjustmentKind kind)
|
||||
case AdjustmentKind::exposure: return FilterKind::exposure;
|
||||
case AdjustmentKind::gradientMap: return FilterKind::gradientMap;
|
||||
case AdjustmentKind::grain: return FilterKind::grain;
|
||||
case AdjustmentKind::addNoise: return FilterKind::addNoise;
|
||||
case AdjustmentKind::gaussianBlur: return FilterKind::gaussianBlur;
|
||||
case AdjustmentKind::motionBlur: return FilterKind::motionBlur;
|
||||
case AdjustmentKind::blackWhite: return FilterKind::blackWhite;
|
||||
case AdjustmentKind::colorBalance: return FilterKind::colorBalance;
|
||||
// Panels of their own; Invert has nothing to set.
|
||||
@@ -106,6 +112,85 @@ void LayerAdjustment::setColorBalance(const ColorBalanceSettings &value)
|
||||
colorBalanceSettings = value;
|
||||
}
|
||||
|
||||
double LayerAdjustment::gaussianRadius() const
|
||||
{
|
||||
return blurRadius.value_or(10);
|
||||
}
|
||||
|
||||
void LayerAdjustment::setGaussianRadius(double value)
|
||||
{
|
||||
blurRadius = value;
|
||||
}
|
||||
|
||||
double LayerAdjustment::resolvedMotionAngle() const
|
||||
{
|
||||
return motionAngle.value_or(0);
|
||||
}
|
||||
|
||||
void LayerAdjustment::setResolvedMotionAngle(double value)
|
||||
{
|
||||
motionAngle = value;
|
||||
}
|
||||
|
||||
double LayerAdjustment::resolvedMotionDistance() const
|
||||
{
|
||||
return motionDistance.value_or(10);
|
||||
}
|
||||
|
||||
void LayerAdjustment::setResolvedMotionDistance(double value)
|
||||
{
|
||||
motionDistance = value;
|
||||
}
|
||||
|
||||
double LayerAdjustment::resolvedNoiseAmount() const
|
||||
{
|
||||
return noiseAmount.value_or(10);
|
||||
}
|
||||
|
||||
void LayerAdjustment::setResolvedNoiseAmount(double value)
|
||||
{
|
||||
noiseAmount = value;
|
||||
}
|
||||
|
||||
bool LayerAdjustment::resolvedNoiseGaussian() const
|
||||
{
|
||||
return noiseGaussian.value_or(false);
|
||||
}
|
||||
|
||||
void LayerAdjustment::setResolvedNoiseGaussian(bool value)
|
||||
{
|
||||
noiseGaussian = value;
|
||||
}
|
||||
|
||||
bool LayerAdjustment::resolvedNoiseMonochromatic() const
|
||||
{
|
||||
return noiseMonochromatic.value_or(false);
|
||||
}
|
||||
|
||||
void LayerAdjustment::setResolvedNoiseMonochromatic(bool value)
|
||||
{
|
||||
noiseMonochromatic = value;
|
||||
}
|
||||
|
||||
quint32 LayerAdjustment::resolvedNoiseSeed() const
|
||||
{
|
||||
return noiseSeed.value_or(0);
|
||||
}
|
||||
|
||||
void LayerAdjustment::setResolvedNoiseSeed(quint32 value)
|
||||
{
|
||||
noiseSeed = value;
|
||||
}
|
||||
|
||||
double LayerAdjustment::samplingMargin() const
|
||||
{
|
||||
switch (kind) {
|
||||
case AdjustmentKind::gaussianBlur: return gaussianRadius() * 3 + 2;
|
||||
case AdjustmentKind::motionBlur: return resolvedMotionDistance() / 2 + 2;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Bounds refuse what is no number: no isFinite terms.
|
||||
bool LayerAdjustment::isValid() const
|
||||
{
|
||||
@@ -130,10 +215,11 @@ bool LayerAdjustment::isValid() const
|
||||
return false;
|
||||
}
|
||||
return curves.isValid() && exposure().isValid() && gradientMap().isValid() && grain().isValid() && blackWhite().isValid()
|
||||
&& colorBalance().isValid();
|
||||
&& colorBalance().isValid() && gaussianRadius() >= 0.1 && gaussianRadius() <= 250 && std::abs(resolvedMotionAngle()) <= 90
|
||||
&& resolvedMotionDistance() >= 1 && resolvedMotionDistance() <= 2000 && resolvedNoiseAmount() >= 0.1 && resolvedNoiseAmount() <= 400;
|
||||
}
|
||||
|
||||
QImage LayerAdjustment::apply(const QImage &image, std::optional<QRectF> region) const
|
||||
QImage LayerAdjustment::apply(const QImage &image, std::optional<QRectF> region, double scale) const
|
||||
{
|
||||
switch (kind) {
|
||||
case AdjustmentKind::hsv:
|
||||
@@ -144,6 +230,21 @@ QImage LayerAdjustment::apply(const QImage &image, std::optional<QRectF> region)
|
||||
case AdjustmentKind::gradientMap: return gradientMap().apply(image);
|
||||
case AdjustmentKind::blackWhite: return blackWhite().apply(image);
|
||||
case AdjustmentKind::colorBalance: return colorBalance().apply(image);
|
||||
case AdjustmentKind::addNoise:
|
||||
case AdjustmentKind::gaussianBlur:
|
||||
case AdjustmentKind::motionBlur: {
|
||||
const FilterSettings settings{.radius = gaussianRadius(),
|
||||
.angle = resolvedMotionAngle(),
|
||||
.distance = resolvedMotionDistance(),
|
||||
.amount = resolvedNoiseAmount(),
|
||||
.gaussian = resolvedNoiseGaussian(),
|
||||
.monochromatic = resolvedNoiseMonochromatic()};
|
||||
FilterJob job{filterKind(kind).value(), image, settings, scale, std::nullopt, QTransform(), resolvedNoiseSeed()};
|
||||
// Swift's region origin, counted in the image's own pixels.
|
||||
if (region)
|
||||
job.noiseOrigin = region->topLeft() * (image.width() / region->width());
|
||||
return PixelFilter::run(job);
|
||||
}
|
||||
case AdjustmentKind::invert: return PixelInvert::run(PixelInvert::Job{image, false, QTransform(), std::nullopt});
|
||||
case AdjustmentKind::grain: {
|
||||
// Grain sits in the document, however the image is cut.
|
||||
@@ -169,6 +270,8 @@ void EditorSession::addAdjustment(AdjustmentKind kind)
|
||||
grain.seed = QRandomGenerator::global()->generate();
|
||||
adjustment.setGrain(grain);
|
||||
}
|
||||
if (kind == AdjustmentKind::addNoise)
|
||||
adjustment.setResolvedNoiseSeed(QRandomGenerator::global()->generate());
|
||||
layer.adjustment = adjustment;
|
||||
const std::optional<ImageLayer> active = activeLayer();
|
||||
layer.parentID = active && active->isGroup ? m_activeLayerID : active ? active->parentID : std::nullopt;
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
#include <optional>
|
||||
|
||||
// An adjustment layer's kind, in the Layer menu's order.
|
||||
enum class AdjustmentKind { hsv, levels, curves, exposure, gradientMap, grain, invert, blackWhite, colorBalance };
|
||||
inline constexpr std::array allAdjustmentKinds{AdjustmentKind::hsv, AdjustmentKind::levels, AdjustmentKind::curves,
|
||||
AdjustmentKind::exposure, AdjustmentKind::gradientMap, AdjustmentKind::grain,
|
||||
AdjustmentKind::invert, AdjustmentKind::blackWhite, AdjustmentKind::colorBalance};
|
||||
enum class AdjustmentKind { hsv, levels, curves, exposure, gradientMap, grain, addNoise, gaussianBlur, motionBlur, invert, blackWhite, colorBalance };
|
||||
inline constexpr std::array allAdjustmentKinds{AdjustmentKind::hsv, AdjustmentKind::levels, AdjustmentKind::curves,
|
||||
AdjustmentKind::exposure, AdjustmentKind::gradientMap, AdjustmentKind::grain,
|
||||
AdjustmentKind::addNoise, AdjustmentKind::gaussianBlur, AdjustmentKind::motionBlur,
|
||||
AdjustmentKind::invert, AdjustmentKind::blackWhite, AdjustmentKind::colorBalance};
|
||||
// Swift's rawValue: the new layer's name and the manifest's word.
|
||||
QString rawValue(AdjustmentKind kind);
|
||||
// The kind a manifest names, or none.
|
||||
@@ -40,6 +41,14 @@ struct LayerAdjustment {
|
||||
std::optional<GrainSettings> grainSettings = std::nullopt;
|
||||
std::optional<BlackWhiteSettings> blackWhiteSettings = std::nullopt;
|
||||
std::optional<ColorBalanceSettings> colorBalanceSettings = std::nullopt;
|
||||
// Absent in projects from before blur and noise layers.
|
||||
std::optional<double> blurRadius = std::nullopt;
|
||||
std::optional<double> motionAngle = std::nullopt;
|
||||
std::optional<double> motionDistance = std::nullopt;
|
||||
std::optional<double> noiseAmount = std::nullopt;
|
||||
std::optional<bool> noiseGaussian = std::nullopt;
|
||||
std::optional<bool> noiseMonochromatic = std::nullopt;
|
||||
std::optional<quint32> noiseSeed = std::nullopt;
|
||||
HueSaturationSettings resolvedHSV() const;
|
||||
// Swift's computed properties over the optional settings.
|
||||
ExposureSettings exposure() const;
|
||||
@@ -52,8 +61,24 @@ struct LayerAdjustment {
|
||||
void setBlackWhite(const BlackWhiteSettings &value);
|
||||
ColorBalanceSettings colorBalance() const;
|
||||
void setColorBalance(const ColorBalanceSettings &value);
|
||||
double gaussianRadius() const;
|
||||
void setGaussianRadius(double value);
|
||||
double resolvedMotionAngle() const;
|
||||
void setResolvedMotionAngle(double value);
|
||||
double resolvedMotionDistance() const;
|
||||
void setResolvedMotionDistance(double value);
|
||||
double resolvedNoiseAmount() const;
|
||||
void setResolvedNoiseAmount(double value);
|
||||
bool resolvedNoiseGaussian() const;
|
||||
void setResolvedNoiseGaussian(bool value);
|
||||
bool resolvedNoiseMonochromatic() const;
|
||||
void setResolvedNoiseMonochromatic(bool value);
|
||||
quint32 resolvedNoiseSeed() const;
|
||||
void setResolvedNoiseSeed(quint32 value);
|
||||
// Document pixels a partial redraw reads past its rect.
|
||||
double samplingMargin() const;
|
||||
bool isValid() const;
|
||||
// `region`: the document `image` covers, whole pixels by default.
|
||||
QImage apply(const QImage &image, std::optional<QRectF> region = std::nullopt) const;
|
||||
// Region: the document shown; scale: image pixels a document pixel.
|
||||
QImage apply(const QImage &image, std::optional<QRectF> region = std::nullopt, double scale = 1) const;
|
||||
friend bool operator==(const LayerAdjustment &, const LayerAdjustment &) = default;
|
||||
};
|
||||
@@ -11,6 +11,7 @@ void take(LayerEffects &into, const LayerEffects &from, LayerEffectKind kind)
|
||||
case LayerEffectKind::colorOverlay: into.colorOverlay = from.colorOverlay; return;
|
||||
case LayerEffectKind::innerShadow: into.innerShadow = from.innerShadow; return;
|
||||
case LayerEffectKind::outerGlow: into.outerGlow = from.outerGlow; return;
|
||||
case LayerEffectKind::innerGlow: into.innerGlow = from.innerGlow; return;
|
||||
}
|
||||
throw std::logic_error("unknown effect kind");
|
||||
}
|
||||
@@ -75,6 +76,10 @@ void EditorSession::addEffect(LayerEffectKind kind)
|
||||
if (!effects.outerGlow)
|
||||
effects.outerGlow = OuterGlowEffect();
|
||||
break;
|
||||
case LayerEffectKind::innerGlow:
|
||||
if (!effects.innerGlow)
|
||||
effects.innerGlow = InnerGlowEffect();
|
||||
break;
|
||||
}
|
||||
setEffects(effects, id, QStringLiteral("Add ") + rawValue(kind));
|
||||
selectEffect(kind, id, true);
|
||||
|
||||
@@ -61,6 +61,11 @@ bool OuterGlowEffect::isValid() const
|
||||
return within(size, 0, 500) && within(opacity, 0, 1) && colourIsValid(red, green, blue);
|
||||
}
|
||||
|
||||
bool InnerGlowEffect::isValid() const
|
||||
{
|
||||
return within(size, 0, 500) && within(opacity, 0, 1) && colourIsValid(red, green, blue);
|
||||
}
|
||||
|
||||
QString rawValue(LayerEffectKind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
@@ -69,6 +74,7 @@ QString rawValue(LayerEffectKind kind)
|
||||
case LayerEffectKind::colorOverlay: return QStringLiteral("Color Overlay");
|
||||
case LayerEffectKind::innerShadow: return QStringLiteral("Inner Shadow");
|
||||
case LayerEffectKind::outerGlow: return QStringLiteral("Outer Glow");
|
||||
case LayerEffectKind::innerGlow: return QStringLiteral("Inner Glow");
|
||||
}
|
||||
throw std::logic_error("unknown effect kind");
|
||||
}
|
||||
@@ -76,7 +82,7 @@ QString rawValue(LayerEffectKind kind)
|
||||
bool LayerEffects::isValid() const
|
||||
{
|
||||
return (!stroke || stroke->isValid()) && (!shadow || shadow->isValid()) && (!colorOverlay || colorOverlay->isValid())
|
||||
&& (!innerShadow || innerShadow->isValid()) && (!outerGlow || outerGlow->isValid());
|
||||
&& (!innerShadow || innerShadow->isValid()) && (!outerGlow || outerGlow->isValid()) && (!innerGlow || innerGlow->isValid());
|
||||
}
|
||||
|
||||
std::vector<LayerEffectKind> LayerEffects::kinds() const
|
||||
@@ -97,6 +103,7 @@ bool LayerEffects::contains(LayerEffectKind kind) const
|
||||
case LayerEffectKind::colorOverlay: return colorOverlay.has_value();
|
||||
case LayerEffectKind::innerShadow: return innerShadow.has_value();
|
||||
case LayerEffectKind::outerGlow: return outerGlow.has_value();
|
||||
case LayerEffectKind::innerGlow: return innerGlow.has_value();
|
||||
}
|
||||
throw std::logic_error("unknown effect kind");
|
||||
}
|
||||
@@ -109,6 +116,7 @@ bool LayerEffects::isEnabled(LayerEffectKind kind) const
|
||||
case LayerEffectKind::colorOverlay: return colorOverlay && colorOverlay->isEnabled();
|
||||
case LayerEffectKind::innerShadow: return innerShadow && innerShadow->isEnabled();
|
||||
case LayerEffectKind::outerGlow: return outerGlow && outerGlow->isEnabled();
|
||||
case LayerEffectKind::innerGlow: return innerGlow && innerGlow->isEnabled();
|
||||
}
|
||||
throw std::logic_error("unknown effect kind");
|
||||
}
|
||||
@@ -121,6 +129,7 @@ std::optional<PaletteColor> LayerEffects::color(LayerEffectKind kind) const
|
||||
case LayerEffectKind::colorOverlay: return colorOverlay ? std::optional(colorOverlay->color()) : std::nullopt;
|
||||
case LayerEffectKind::innerShadow: return innerShadow ? std::optional(innerShadow->color()) : std::nullopt;
|
||||
case LayerEffectKind::outerGlow: return outerGlow ? std::optional(outerGlow->color()) : std::nullopt;
|
||||
case LayerEffectKind::innerGlow: return innerGlow ? std::optional(innerGlow->color()) : std::nullopt;
|
||||
}
|
||||
throw std::logic_error("unknown effect kind");
|
||||
}
|
||||
@@ -156,6 +165,7 @@ void LayerEffects::setColor(const PaletteColor &color, LayerEffectKind kind)
|
||||
case LayerEffectKind::colorOverlay: paint(colorOverlay, color); return;
|
||||
case LayerEffectKind::innerShadow: paint(innerShadow, color); return;
|
||||
case LayerEffectKind::outerGlow: paint(outerGlow, color); return;
|
||||
case LayerEffectKind::innerGlow: paint(innerGlow, color); return;
|
||||
}
|
||||
throw std::logic_error("unknown effect kind");
|
||||
}
|
||||
@@ -168,6 +178,7 @@ void LayerEffects::remove(LayerEffectKind kind)
|
||||
case LayerEffectKind::colorOverlay: colorOverlay.reset(); return;
|
||||
case LayerEffectKind::innerShadow: innerShadow.reset(); return;
|
||||
case LayerEffectKind::outerGlow: outerGlow.reset(); return;
|
||||
case LayerEffectKind::innerGlow: innerGlow.reset(); return;
|
||||
}
|
||||
throw std::logic_error("unknown effect kind");
|
||||
}
|
||||
@@ -180,11 +191,12 @@ void LayerEffects::setEnabled(bool enabled, LayerEffectKind kind)
|
||||
case LayerEffectKind::colorOverlay: enable(colorOverlay, enabled); return;
|
||||
case LayerEffectKind::innerShadow: enable(innerShadow, enabled); return;
|
||||
case LayerEffectKind::outerGlow: enable(outerGlow, enabled); return;
|
||||
case LayerEffectKind::innerGlow: enable(innerGlow, enabled); return;
|
||||
}
|
||||
throw std::logic_error("unknown effect kind");
|
||||
}
|
||||
|
||||
LayerEffects LayerEffects::visible() const
|
||||
{
|
||||
return LayerEffects{shown(stroke), shown(shadow), shown(colorOverlay), shown(innerShadow), shown(outerGlow)};
|
||||
return LayerEffects{shown(stroke), shown(shadow), shown(colorOverlay), shown(innerShadow), shown(outerGlow), shown(innerGlow)};
|
||||
}
|
||||
@@ -88,10 +88,24 @@ struct OuterGlowEffect {
|
||||
friend bool operator==(const OuterGlowEffect &, const OuterGlowEffect &) = default;
|
||||
};
|
||||
|
||||
// A glow inside the layer's edges, fading inward.
|
||||
struct InnerGlowEffect {
|
||||
std::optional<bool> enabled = std::nullopt;
|
||||
double size = 10;
|
||||
double red = 1;
|
||||
double green = 1;
|
||||
double blue = 1;
|
||||
double opacity = 0.75;
|
||||
bool isEnabled() const { return enabled.value_or(true); }
|
||||
PaletteColor color() const { return {red, green, blue}; }
|
||||
bool isValid() const;
|
||||
friend bool operator==(const InnerGlowEffect &, const InnerGlowEffect &) = default;
|
||||
};
|
||||
|
||||
// Swift's raw values are the panel's and the menu's words.
|
||||
enum class LayerEffectKind { stroke, shadow, colorOverlay, innerShadow, outerGlow };
|
||||
inline constexpr std::array allLayerEffectKinds{LayerEffectKind::stroke, LayerEffectKind::shadow, LayerEffectKind::colorOverlay,
|
||||
LayerEffectKind::innerShadow, LayerEffectKind::outerGlow};
|
||||
enum class LayerEffectKind { stroke, shadow, colorOverlay, innerShadow, outerGlow, innerGlow };
|
||||
inline constexpr std::array allLayerEffectKinds{LayerEffectKind::stroke, LayerEffectKind::shadow, LayerEffectKind::colorOverlay,
|
||||
LayerEffectKind::innerShadow, LayerEffectKind::outerGlow, LayerEffectKind::innerGlow};
|
||||
QString rawValue(LayerEffectKind kind);
|
||||
|
||||
// What a layer draws round itself; its pixels stay untouched.
|
||||
@@ -101,7 +115,8 @@ struct LayerEffects {
|
||||
std::optional<ColorOverlayEffect> colorOverlay = std::nullopt;
|
||||
std::optional<InnerShadowEffect> innerShadow = std::nullopt;
|
||||
std::optional<OuterGlowEffect> outerGlow = std::nullopt;
|
||||
bool isEmpty() const { return !stroke && !shadow && !colorOverlay && !innerShadow && !outerGlow; }
|
||||
std::optional<InnerGlowEffect> innerGlow = std::nullopt;
|
||||
bool isEmpty() const { return !stroke && !shadow && !colorOverlay && !innerShadow && !outerGlow && !innerGlow; }
|
||||
bool isValid() const;
|
||||
std::vector<LayerEffectKind> kinds() const;
|
||||
bool contains(LayerEffectKind kind) const;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#include "Document/ImageTrim.h"
|
||||
#include "IO/CanvasResizer.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "IO/ImageResizer.h"
|
||||
#include "IO/ProjectController.h"
|
||||
#include "UI/CanvasSizeSheet.h"
|
||||
#include "UI/ImageSizeSheet.h"
|
||||
#include "UI/TrimSheet.h"
|
||||
#include <QDialog>
|
||||
#include <QFutureWatcher>
|
||||
#include <QVBoxLayout>
|
||||
@@ -37,7 +39,7 @@ void ProjectController::canvasSize(std::function<void()> done)
|
||||
connect(dialog, &QDialog::finished, this, [this, chosen, done] {
|
||||
const std::optional<CanvasSizeOptions> options = *chosen;
|
||||
resizeProject(options ? [options = *options](const ProjectSnapshot &snapshot) { return CanvasResizer::resize(snapshot, options); }
|
||||
: std::function<ProjectSnapshot(const ProjectSnapshot &)>(),
|
||||
: std::function<std::optional<ProjectSnapshot>(const ProjectSnapshot &)>(),
|
||||
[this](const ProjectSnapshot &resized) { session.applyDocumentSize(resized, QStringLiteral("Canvas Size")); },
|
||||
QStringLiteral("Couldn’t change canvas size"), done);
|
||||
});
|
||||
@@ -60,14 +62,37 @@ void ProjectController::imageSize(std::function<void()> done)
|
||||
connect(dialog, &QDialog::finished, this, [this, chosen, done] {
|
||||
const std::optional<ImageSizeOptions> options = *chosen;
|
||||
resizeProject(options ? [options = *options](const ProjectSnapshot &snapshot) { return ImageResizer::resize(snapshot, options); }
|
||||
: std::function<ProjectSnapshot(const ProjectSnapshot &)>(),
|
||||
: std::function<std::optional<ProjectSnapshot>(const ProjectSnapshot &)>(),
|
||||
[this](const ProjectSnapshot &resized) { session.applyImageSize(resized); }, QStringLiteral("Couldn’t resize the image"), done);
|
||||
});
|
||||
dialog->open();
|
||||
}
|
||||
|
||||
void ProjectController::trim(std::function<void()> done)
|
||||
{
|
||||
if (!window || !session.document() || !begin()) {
|
||||
if (done)
|
||||
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
|
||||
return;
|
||||
}
|
||||
QDialog *dialog = sheet(QStringLiteral("Trim"));
|
||||
auto chosen = std::make_shared<std::optional<TrimOptions>>();
|
||||
dialog->layout()->addWidget(new TrimSheet([dialog, chosen](std::optional<TrimOptions> options) {
|
||||
*chosen = options;
|
||||
dialog->done(options ? QDialog::Accepted : QDialog::Rejected);
|
||||
}, dialog));
|
||||
connect(dialog, &QDialog::finished, this, [this, chosen, done] {
|
||||
const std::optional<TrimOptions> options = *chosen;
|
||||
resizeProject(options ? [options = *options](const ProjectSnapshot &snapshot) { return ImageTrim::trim(snapshot, options); }
|
||||
: std::function<std::optional<ProjectSnapshot>(const ProjectSnapshot &)>(),
|
||||
[this](const ProjectSnapshot &trimmed) { session.applyDocumentSize(trimmed, QStringLiteral("Trim")); },
|
||||
QStringLiteral("Couldn’t trim image"), done);
|
||||
});
|
||||
dialog->open();
|
||||
}
|
||||
|
||||
// Cancelled, nothing runs; running out of memory fails a render.
|
||||
void ProjectController::resizeProject(std::function<ProjectSnapshot(const ProjectSnapshot &)> resize, std::function<void(const ProjectSnapshot &)> land,
|
||||
void ProjectController::resizeProject(std::function<std::optional<ProjectSnapshot>(const ProjectSnapshot &)> resize, std::function<void(const ProjectSnapshot &)> land,
|
||||
const QString &failure, std::function<void()> done)
|
||||
{
|
||||
const auto end = [this, done] {
|
||||
@@ -89,11 +114,13 @@ void ProjectController::resizeProject(std::function<ProjectSnapshot(const Projec
|
||||
watcher->deleteLater();
|
||||
try {
|
||||
const Resized result = watcher->future().takeResult();
|
||||
if (!result.snapshot) {
|
||||
if (!result.failure.isEmpty()) {
|
||||
explain(result.failure);
|
||||
return;
|
||||
}
|
||||
land(*result.snapshot);
|
||||
// Swift's trim with nothing left changes nothing.
|
||||
if (result.snapshot)
|
||||
land(*result.snapshot);
|
||||
} catch (const std::bad_alloc &) {
|
||||
explain(render);
|
||||
return;
|
||||
|
||||
@@ -40,6 +40,7 @@ public:
|
||||
// Swift's sheets over the window; resizes off the UI thread.
|
||||
void canvasSize(std::function<void()> done = {});
|
||||
void imageSize(std::function<void()> done = {});
|
||||
void trim(std::function<void()> done = {});
|
||||
void exportPNG(std::function<void()> done = {});
|
||||
void exportJPEG(std::function<void()> done = {});
|
||||
|
||||
@@ -68,8 +69,8 @@ private:
|
||||
void showError(const QString &title, const QString &message, std::function<void()> then);
|
||||
// Swift's beginSheet: window-modal over the window; Escape cancels.
|
||||
QDialog *sheet(const QString &title);
|
||||
// Off the UI thread, then landed or explained; freed after.
|
||||
void resizeProject(std::function<ProjectSnapshot(const ProjectSnapshot &)> resize, std::function<void(const ProjectSnapshot &)> land,
|
||||
// Off the UI thread, then landed, skipped or explained.
|
||||
void resizeProject(std::function<std::optional<ProjectSnapshot>(const ProjectSnapshot &)> resize, std::function<void(const ProjectSnapshot &)> land,
|
||||
const QString &failure, std::function<void()> done);
|
||||
void exportPanel(const QString &title, const QString &filter, const QStringList &suffixes, const QString &name, const QString &failure,
|
||||
std::function<void(std::optional<QString>)> then);
|
||||
|
||||
@@ -118,15 +118,20 @@ GradientMapSettings gradientMap(const QJsonValue &value)
|
||||
return {colour(object.value("shadows")), colour(object.value("highlights")), boolean(object.value("reversed"))};
|
||||
}
|
||||
|
||||
// Swift's UInt32 refuses what it cannot hold.
|
||||
quint32 unsigned32(const QJsonValue &value)
|
||||
{
|
||||
const qint64 seed = integer(value);
|
||||
if (seed < 0 || seed > std::numeric_limits<quint32>::max())
|
||||
refuse();
|
||||
return quint32(seed);
|
||||
}
|
||||
|
||||
GrainSettings grain(const QJsonValue &value)
|
||||
{
|
||||
const QJsonObject object = value.toObject();
|
||||
// Swift's UInt32 refuses what it cannot hold.
|
||||
const qint64 seed = integer(object.value("seed"));
|
||||
if (seed < 0 || seed > std::numeric_limits<quint32>::max())
|
||||
refuse();
|
||||
return {.amount = number(object.value("amount")), .size = number(object.value("size")), .roughness = number(object.value("roughness")),
|
||||
.seed = quint32(seed)};
|
||||
.seed = unsigned32(object.value("seed"))};
|
||||
}
|
||||
|
||||
BlackWhiteSettings blackWhite(const QJsonValue &value)
|
||||
@@ -205,7 +210,14 @@ LayerAdjustment ManifestJson::adjustment(const QJsonValue &value)
|
||||
.gradientMapSettings = optional(object, "gradientMapSettings", gradientMap),
|
||||
.grainSettings = optional(object, "grainSettings", grain),
|
||||
.blackWhiteSettings = optional(object, "blackWhiteSettings", blackWhite),
|
||||
.colorBalanceSettings = optional(object, "colorBalanceSettings", colorBalance)};
|
||||
.colorBalanceSettings = optional(object, "colorBalanceSettings", colorBalance),
|
||||
.blurRadius = optional(object, "blurRadius", number),
|
||||
.motionAngle = optional(object, "motionAngle", number),
|
||||
.motionDistance = optional(object, "motionDistance", number),
|
||||
.noiseAmount = optional(object, "noiseAmount", number),
|
||||
.noiseGaussian = optional(object, "noiseGaussian", boolean),
|
||||
.noiseMonochromatic = optional(object, "noiseMonochromatic", boolean),
|
||||
.noiseSeed = optional(object, "noiseSeed", unsigned32)};
|
||||
}
|
||||
|
||||
QJsonObject ManifestJson::encoded(const LayerAdjustment &adjustment)
|
||||
@@ -248,5 +260,16 @@ QJsonObject ManifestJson::encoded(const LayerAdjustment &adjustment)
|
||||
{"highlightCyanRed", balance.highlightCyanRed}, {"highlightMagentaGreen", balance.highlightMagentaGreen},
|
||||
{"highlightYellowBlue", balance.highlightYellowBlue}, {"preserveLuminosity", balance.preserveLuminosity}});
|
||||
}
|
||||
const auto put = [&object](const char *key, const auto &value) {
|
||||
if (value)
|
||||
object.insert(QLatin1String(key), *value);
|
||||
};
|
||||
put("blurRadius", adjustment.blurRadius);
|
||||
put("motionAngle", adjustment.motionAngle);
|
||||
put("motionDistance", adjustment.motionDistance);
|
||||
put("noiseAmount", adjustment.noiseAmount);
|
||||
put("noiseGaussian", adjustment.noiseGaussian);
|
||||
put("noiseMonochromatic", adjustment.noiseMonochromatic);
|
||||
put("noiseSeed", adjustment.noiseSeed ? std::optional(qint64(*adjustment.noiseSeed)) : std::nullopt);
|
||||
return object;
|
||||
}
|
||||
@@ -46,6 +46,13 @@ OuterGlowEffect outerGlow(const QJsonValue &value)
|
||||
number(keys.value("blue")), number(keys.value("opacity"))};
|
||||
}
|
||||
|
||||
InnerGlowEffect innerGlow(const QJsonValue &value)
|
||||
{
|
||||
const QJsonObject keys = object(value);
|
||||
return {optional(keys, "enabled", boolean), number(keys.value("size")), number(keys.value("red")), number(keys.value("green")),
|
||||
number(keys.value("blue")), number(keys.value("opacity"))};
|
||||
}
|
||||
|
||||
void colour(QJsonObject &keys, double red, double green, double blue)
|
||||
{
|
||||
keys.insert("red", red);
|
||||
@@ -58,7 +65,7 @@ LayerEffects ManifestJson::effects(const QJsonValue &value)
|
||||
{
|
||||
const QJsonObject keys = object(value);
|
||||
return {optional(keys, "stroke", stroke), optional(keys, "shadow", shadow), optional(keys, "colorOverlay", colorOverlay),
|
||||
optional(keys, "innerShadow", innerShadow), optional(keys, "outerGlow", outerGlow)};
|
||||
optional(keys, "innerShadow", innerShadow), optional(keys, "outerGlow", outerGlow), optional(keys, "innerGlow", innerGlow)};
|
||||
}
|
||||
|
||||
QJsonObject ManifestJson::encoded(const LayerEffects &effects)
|
||||
@@ -104,5 +111,13 @@ QJsonObject ManifestJson::encoded(const LayerEffects &effects)
|
||||
object.insert("enabled", *glow.enabled);
|
||||
keys.insert("outerGlow", object);
|
||||
}
|
||||
if (effects.innerGlow) {
|
||||
const InnerGlowEffect &glow = *effects.innerGlow;
|
||||
QJsonObject object{{"size", glow.size}, {"opacity", glow.opacity}};
|
||||
colour(object, glow.red, glow.green, glow.blue);
|
||||
if (glow.enabled)
|
||||
object.insert("enabled", *glow.enabled);
|
||||
keys.insert("innerGlow", object);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -62,7 +62,7 @@ void validate(const ProjectManifest &manifest)
|
||||
{
|
||||
if (manifest.format != QLatin1String("com.compositor.project"))
|
||||
throw ProjectError(ProjectError::Kind::invalid);
|
||||
if (manifest.version < 1 || manifest.version > 8)
|
||||
if (manifest.version < 1 || manifest.version > 9)
|
||||
throw ProjectError::unsupportedVersion(manifest.version);
|
||||
if (manifest.colorSpace != QLatin1String("sRGB"))
|
||||
throw ProjectError(ProjectError::Kind::invalid);
|
||||
@@ -78,6 +78,11 @@ void validate(const ProjectManifest &manifest)
|
||||
// Version 7's adjustments hold settings, never pixels or children.
|
||||
if (layer.adjustment && (manifest.version < 7 || layer.isGroup == true || layer.imageFile || !layer.adjustment->isValid()))
|
||||
throw ProjectError(ProjectError::Kind::invalid);
|
||||
// Blur and noise layers came with version 9.
|
||||
if (layer.adjustment && manifest.version < 9
|
||||
&& (layer.adjustment->kind == AdjustmentKind::gaussianBlur || layer.adjustment->kind == AdjustmentKind::motionBlur
|
||||
|| layer.adjustment->kind == AdjustmentKind::addNoise))
|
||||
throw ProjectError(ProjectError::Kind::invalid);
|
||||
const bool group = layer.isGroup == true;
|
||||
// Layer masks came with version 4, folder masks with 6.
|
||||
const bool maskAllowed = !layer.maskFile
|
||||
@@ -251,7 +256,7 @@ ProjectError::ProjectError(Kind kind) : ProjectError(kind, std::nullopt, descrip
|
||||
ProjectError ProjectError::unsupportedVersion(qint64 version)
|
||||
{
|
||||
return ProjectError(Kind::version, version,
|
||||
QStringLiteral("This project uses format version %1. This app supports versions 1–8.").arg(version));
|
||||
QStringLiteral("This project uses format version %1. This app supports versions 1–9.").arg(version));
|
||||
}
|
||||
|
||||
ProjectError::ProjectError(Kind kind, std::optional<qint64> version, const QString &description)
|
||||
@@ -327,7 +332,7 @@ ProjectSnapshot ProjectStore::load(const QString &path)
|
||||
const auto [format, version] = ProjectManifest::header(metadata);
|
||||
if (format != QLatin1String("com.compositor.project"))
|
||||
throw ProjectError(ProjectError::Kind::invalid);
|
||||
if (version < 1 || version > 8)
|
||||
if (version < 1 || version > 9)
|
||||
throw ProjectError::unsupportedVersion(version);
|
||||
const ProjectManifest manifest = ProjectManifest::decoded(metadata);
|
||||
validate(manifest);
|
||||
|
||||
@@ -52,7 +52,7 @@ QString uuidString(const QUuid &id);
|
||||
struct ProjectManifest {
|
||||
QString format = QStringLiteral("com.compositor.project");
|
||||
// Swift's Int: a wild number must reach validation whole.
|
||||
qint64 version = 8;
|
||||
qint64 version = 9;
|
||||
QString colorSpace = QStringLiteral("sRGB");
|
||||
// Version-1 projects carry none: 72 pixels per inch.
|
||||
std::optional<double> resolution = std::nullopt;
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
#include "Logging.h"
|
||||
#include <QPaintEngine>
|
||||
|
||||
void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainter &)> &body, qint64 pixelBudget)
|
||||
void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainter &)> &body, double padding, qint64 pixelBudget)
|
||||
{
|
||||
// The engine's device holds the pixels: a widget's window store.
|
||||
const QPaintDevice &target = *context.paintEngine()->paintDevice();
|
||||
const QRect device(0, 0, target.width(), target.height());
|
||||
const QRect area = context.deviceTransform().mapRect(BrushRaster::visibleRect(context)).toAlignedRect().intersected(device);
|
||||
if (area.isEmpty())
|
||||
const QTransform toDevice = context.deviceTransform();
|
||||
const QRect output = toDevice.mapRect(BrushRaster::visibleRect(context)).toAlignedRect().intersected(device);
|
||||
if (output.isEmpty())
|
||||
return;
|
||||
// Spatial adjustments read a halo; the clip keeps what shows.
|
||||
const QRectF shown = toDevice.inverted().mapRect(QRectF(output));
|
||||
const QRect area = padding > 0 ? toDevice.mapRect(shown.adjusted(-padding, -padding, padding, padding)).toAlignedRect() : output;
|
||||
if (qint64(area.width()) * area.height() > pixelBudget) {
|
||||
qCWarning(lcRendering) << "an adjustment surface passes its pixel budget:" << area.size();
|
||||
return;
|
||||
|
||||
@@ -5,5 +5,7 @@
|
||||
// Draws through an image surface over what the painter shows.
|
||||
namespace AdjustmentSurface {
|
||||
inline constexpr qint64 pixelBudget = 100'000'000;
|
||||
void draw(QPainter &context, const std::function<void(QPainter &)> &body, qint64 pixelBudget = AdjustmentSurface::pixelBudget);
|
||||
// Padding, in painter units, is drawn past what shows.
|
||||
void draw(QPainter &context, const std::function<void(QPainter &)> &body, double padding = 0,
|
||||
qint64 pixelBudget = AdjustmentSurface::pixelBudget);
|
||||
}
|
||||
@@ -34,6 +34,18 @@ void drawShadow(const QRectF &rect, QPainter &context)
|
||||
context.restore();
|
||||
}
|
||||
|
||||
// Swift's halo: the widest reach of a visible adjustment.
|
||||
double samplingMargin(const CanvasDocument &document)
|
||||
{
|
||||
const QSet<QUuid> visible = document.effectiveVisibleIDs();
|
||||
double margin = 0;
|
||||
for (const ImageLayer &layer : document.layers) {
|
||||
if (layer.adjustment && visible.contains(layer.id))
|
||||
margin = std::max(margin, layer.adjustment->samplingMargin());
|
||||
}
|
||||
return margin;
|
||||
}
|
||||
|
||||
std::optional<ImageIdentity> enabledMaskID(const std::optional<LayerMask> &mask)
|
||||
{
|
||||
return mask && mask->isEnabled ? std::optional(mask->asset.identity()) : std::nullopt;
|
||||
@@ -246,7 +258,7 @@ void CanvasView::draw(QPainter &context, const QRectF &dirty)
|
||||
// Its own group: blend modes never meet the checkerboard.
|
||||
AdjustmentSurface::draw(context, [&](QPainter &group) {
|
||||
drawLayers(*document, points, [&](QPointF point) { return viewport.viewPoint(point, document->size()); }, group);
|
||||
});
|
||||
}, samplingMargin(*document) * points);
|
||||
}
|
||||
if (m_session.showsPixelGrid() && viewport.zoom() >= pixelGridZoom && !visible.isEmpty())
|
||||
drawPixelGrid(visible, *document, context);
|
||||
@@ -419,6 +431,7 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
|
||||
LiveMaskRenderer live([&](QUuid id) { return byID.contains(id) ? byID.at(id).maskSourceID : std::nullopt; }, drawOwnWithDraft);
|
||||
live.adjustment = [&](QUuid id) { return byID.contains(id) ? byID.at(id).adjustment : std::nullopt; };
|
||||
live.adjustmentOpacity = [&](QUuid id) { return byID.at(id).effectiveOpacity(byID); };
|
||||
live.adjustmentScale = scale;
|
||||
live.adjustmentClip = [&](QUuid id, const QPainter &painter, QImage &coverage) {
|
||||
const ImageLayer &layer = byID.at(id);
|
||||
if (!layer.mask || !layer.mask->isEnabled)
|
||||
@@ -468,7 +481,9 @@ void CanvasView::drawDocumentPixels(const QRectF &view, const QRectF &pixels, co
|
||||
}
|
||||
{
|
||||
QPainter painter(&raster);
|
||||
drawLayers(document, 1, [&](QPointF point) { return QPointF(point.x() - region.left(), point.y() - region.top()); }, painter);
|
||||
AdjustmentSurface::draw(painter, [&](QPainter &group) {
|
||||
drawLayers(document, 1, [&](QPointF point) { return QPointF(point.x() - region.left(), point.y() - region.top()); }, group);
|
||||
}, samplingMargin(document));
|
||||
}
|
||||
const QPointF origin = viewport.viewPoint(region.topLeft(), document.size());
|
||||
const QRectF target(origin, QSizeF(region.width() * viewport.pointsPerPixel(), region.height() * viewport.pointsPerPixel()));
|
||||
|
||||
@@ -150,13 +150,15 @@ std::optional<EffectsPreviewCache::Result> EffectsPreviewCache::render(const Req
|
||||
effects.shadow->distance *= factor;
|
||||
effects.shadow->blur *= factor;
|
||||
}
|
||||
// Swift forgets the inner shadow and glow: too wide.
|
||||
// Swift forgets the inner shadow and glows: too wide.
|
||||
if (effects.innerShadow) {
|
||||
effects.innerShadow->distance *= factor;
|
||||
effects.innerShadow->blur *= factor;
|
||||
}
|
||||
if (effects.outerGlow)
|
||||
effects.outerGlow->size *= factor;
|
||||
if (effects.innerGlow)
|
||||
effects.innerGlow->size *= factor;
|
||||
const LayerEffectsRenderer::Rendered rendered = LayerEffectsRenderer::render(pixels, mask, effects);
|
||||
return Result{rendered.image, rendered.inset, std::nullopt};
|
||||
} catch (const ExportError &error) {
|
||||
|
||||
@@ -206,13 +206,24 @@ QImage LayerEffectsKernel::render(const QImage &pixels, const LayerEffects &effe
|
||||
std::copy(shape.get(), shape.get() + count, glowing.get());
|
||||
blurred(glowing.get(), buffer(count).get(), width, height, float(glow->size / 2));
|
||||
}
|
||||
const std::optional<InnerGlowEffect> innerGlow =
|
||||
effects.innerGlow && effects.innerGlow->isEnabled() && effects.innerGlow->size > 0 && effects.innerGlow->opacity > 0 ? effects.innerGlow : std::nullopt;
|
||||
Buffer glowingInside;
|
||||
if (innerGlow) {
|
||||
// The shape softened; what it loses stays inside.
|
||||
glowingInside = buffer(count);
|
||||
std::copy(shape.get(), shape.get() + count, glowingInside.get());
|
||||
blurred(glowingInside.get(), buffer(count).get(), width, height, float(innerGlow->size / 2));
|
||||
for (qint64 index = 0; index < count; ++index)
|
||||
glowingInside[index] = std::clamp(shape[index] * (1.f - glowingInside[index]), 0.f, 1.f);
|
||||
}
|
||||
const std::optional<ColorOverlayEffect> overlay = effects.colorOverlay && effects.colorOverlay->isEnabled() && effects.colorOverlay->opacity > 0
|
||||
? effects.colorOverlay : std::nullopt;
|
||||
QImage result = BrushRaster::context(width, height, false);
|
||||
// Taken once: scanLine() detaches, which workers must not race.
|
||||
uchar *const bits = result.bits();
|
||||
const qsizetype stride = result.bytesPerLine();
|
||||
// Shadow, glow, outside stroke, pixels, overlay, inner shadow, inside stroke.
|
||||
// Shadow, glow, outside stroke, pixels, overlay, inner glow, shadow, stroke.
|
||||
bands(height, [&](int, int y) {
|
||||
const uchar *in = source.constScanLine(y);
|
||||
uchar *out = bits + y * stride;
|
||||
@@ -232,6 +243,8 @@ QImage LayerEffectsKernel::render(const QImage &pixels, const LayerEffects &effe
|
||||
alpha = sourceAlpha + alpha * (1.f - sourceAlpha);
|
||||
if (overlay)
|
||||
over(colour, alpha, overlay->color(), std::clamp(shape[index] * float(overlay->opacity), 0.f, 1.f));
|
||||
if (innerGlow)
|
||||
over(colour, alpha, innerGlow->color(), std::clamp(glowingInside[index] * float(innerGlow->opacity), 0.f, 1.f));
|
||||
if (inner)
|
||||
over(colour, alpha, inner->color(), std::clamp(inside[index] * float(inner->opacity), 0.f, 1.f));
|
||||
if (stroke && stroke->inside)
|
||||
|
||||
@@ -54,6 +54,8 @@ double LayerEffectsSurface::reach() const
|
||||
reach = std::max(reach, m_effects.innerShadow->distance + m_effects.innerShadow->blur * 3 + 2);
|
||||
if (m_effects.outerGlow)
|
||||
reach = std::max(reach, m_effects.outerGlow->size * 3 + 2);
|
||||
if (m_effects.innerGlow)
|
||||
reach = std::max(reach, m_effects.innerGlow->size * 3 + 2);
|
||||
return reach;
|
||||
}
|
||||
|
||||
|
||||
@@ -216,7 +216,8 @@ void LiveMaskRenderer::adjust(QUuid id, QPainter &context, const QImage &clip)
|
||||
if (original.isNull())
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
original.setDevicePixelRatio(1);
|
||||
QImage adjusted = adjustment(id).value().apply(original, region);
|
||||
// Device pixels a document pixel, where Swift's surface counts points.
|
||||
QImage adjusted = adjustment(id).value().apply(original, region, adjustmentScale * device->width() / region.width());
|
||||
const auto found = m_modes.find(id);
|
||||
const LayerBlendMode mode = found != m_modes.end() ? found->second : LayerBlendMode::normal;
|
||||
if (mode != LayerBlendMode::normal)
|
||||
|
||||
@@ -28,6 +28,8 @@ public:
|
||||
std::function<std::optional<LayerAdjustment>(QUuid)> adjustment = [](QUuid) { return std::optional<LayerAdjustment>(); };
|
||||
std::function<double(QUuid)> adjustmentOpacity = [](QUuid) { return 1.0; };
|
||||
std::function<void(QUuid, const QPainter &, QImage &coverage)> adjustmentClip = [](QUuid, const QPainter &, QImage &) {};
|
||||
// Painter units a document pixel: the canvas's zoom.
|
||||
double adjustmentScale = 1;
|
||||
|
||||
private:
|
||||
// The pixels beneath, adjusted and mixed back through coverage.
|
||||
|
||||
@@ -209,6 +209,7 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
|
||||
image->addSeparator();
|
||||
add(image, QStringLiteral("canvasSize"), QStringLiteral("Canvas Size…"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_C), [this] { projects().canvasSize(); });
|
||||
add(image, QStringLiteral("imageSize"), QStringLiteral("Image Size…"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_I), [this] { projects().imageSize(); });
|
||||
add(image, QStringLiteral("trim"), QStringLiteral("Trim…"), QKeySequence(), [this] { projects().trim(); });
|
||||
image->addSeparator();
|
||||
add(image, QStringLiteral("flipCanvasHorizontal"), QStringLiteral("Flip Canvas Horizontal"), QKeySequence(), [this] { session().flipCanvas(true); });
|
||||
add(image, QStringLiteral("flipCanvasVertical"), QStringLiteral("Flip Canvas Vertical"), QKeySequence(), [this] { session().flipCanvas(false); });
|
||||
@@ -365,6 +366,7 @@ void CompositorMenus::synchronize()
|
||||
action(QStringLiteral("invert"))->setEnabled(s.canInvert());
|
||||
action(QStringLiteral("canvasSize"))->setEnabled(drawn && p.canStart());
|
||||
action(QStringLiteral("imageSize"))->setEnabled(drawn && p.canStart());
|
||||
action(QStringLiteral("trim"))->setEnabled(drawn && p.canStart());
|
||||
action(QStringLiteral("flipCanvasHorizontal"))->setEnabled(s.canEditLayers());
|
||||
action(QStringLiteral("flipCanvasVertical"))->setEnabled(s.canEditLayers());
|
||||
action(QStringLiteral("newAdjustmentLayer"))->setEnabled(s.canEditLayers());
|
||||
|
||||
@@ -92,6 +92,13 @@ EffectsSheet::EffectsSheet(EditorSession &session, LayerEffectKind kind, QWidget
|
||||
slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.outerGlow); },
|
||||
[](LayerEffects &e, double value) { e.outerGlow->opacity = value / 100; }, 0, 100, QStringLiteral("%"));
|
||||
break;
|
||||
case LayerEffectKind::innerGlow:
|
||||
header(QStringLiteral("Inner Glow"), false);
|
||||
slider(QStringLiteral("Size"), [](const LayerEffects &e) { return e.innerGlow ? std::optional(e.innerGlow->size) : std::nullopt; },
|
||||
[](LayerEffects &e, double value) { e.innerGlow->size = value; }, 0, 100, QStringLiteral("px"), 500);
|
||||
slider(QStringLiteral("Opacity"), [](const LayerEffects &e) { return percent(e.innerGlow); },
|
||||
[](LayerEffects &e, double value) { e.innerGlow->opacity = value / 100; }, 0, 100, QStringLiteral("%"));
|
||||
break;
|
||||
}
|
||||
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
|
||||
cancel->setObjectName(QStringLiteral("effectsCancel"));
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#include "UI/LayerIcons.h"
|
||||
#include "UI/ToolIcons.h"
|
||||
#include <QPainterPath>
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
|
||||
namespace {
|
||||
void eye(QPainter &painter, bool slashed)
|
||||
@@ -209,6 +211,47 @@ void sparkles(QPainter &painter)
|
||||
star(QPointF(14.5, 14), 2);
|
||||
}
|
||||
|
||||
// Swift's drop.fill: a filled teardrop, point up.
|
||||
void drop(QPainter &painter)
|
||||
{
|
||||
QPainterPath path;
|
||||
path.moveTo(9, 1.5);
|
||||
path.cubicTo(11, 5, 14.5, 8.5, 14.5, 11.3);
|
||||
path.cubicTo(14.5, 14.4, 12, 16.5, 9, 16.5);
|
||||
path.cubicTo(6, 16.5, 3.5, 14.4, 3.5, 11.3);
|
||||
path.cubicTo(3.5, 8.5, 7, 5, 9, 1.5);
|
||||
painter.fillPath(path, painter.pen().color());
|
||||
}
|
||||
|
||||
// Swift's wind: three strokes, the outer two curling back.
|
||||
void wind(QPainter &painter)
|
||||
{
|
||||
QPainterPath top;
|
||||
top.moveTo(1.5, 6);
|
||||
top.lineTo(12, 6);
|
||||
top.cubicTo(14.5, 6, 15, 2.5, 12.5, 2.5);
|
||||
QPainterPath middle;
|
||||
middle.moveTo(1.5, 9.5);
|
||||
middle.lineTo(15, 9.5);
|
||||
QPainterPath bottom;
|
||||
bottom.moveTo(1.5, 13);
|
||||
bottom.lineTo(10, 13);
|
||||
bottom.cubicTo(12.5, 13, 13, 16.5, 10.5, 16.5);
|
||||
for (const QPainterPath &path : {top, middle, bottom})
|
||||
painter.drawPath(path);
|
||||
}
|
||||
|
||||
// Swift's circle.dotted: twelve dots round a ring.
|
||||
void dottedCircle(QPainter &painter)
|
||||
{
|
||||
painter.setBrush(painter.pen().color());
|
||||
painter.setPen(Qt::NoPen);
|
||||
for (int index = 0; index < 12; ++index) {
|
||||
const double angle = index * std::numbers::pi / 6;
|
||||
painter.drawEllipse(QPointF(9 + 6.8 * std::cos(angle), 9 + 6.8 * std::sin(angle)), 1.1, 1.1);
|
||||
}
|
||||
}
|
||||
|
||||
// Swift's scope: a ring with four ticks toward its middle.
|
||||
void scope(QPainter &painter)
|
||||
{
|
||||
@@ -255,6 +298,9 @@ void LayerIcons::paint(QPainter &painter, LayerIcon icon, QPointF origin, double
|
||||
case LayerIcon::axes: axes(painter); break;
|
||||
case LayerIcon::scope: scope(painter); break;
|
||||
case LayerIcon::lineDiagonal: painter.drawLine(QPointF(3, 15), QPointF(15, 3)); break;
|
||||
case LayerIcon::drop: drop(painter); break;
|
||||
case LayerIcon::wind: wind(painter); break;
|
||||
case LayerIcon::dottedCircle: dottedCircle(painter); break;
|
||||
}
|
||||
painter.restore();
|
||||
}
|
||||
@@ -278,6 +324,9 @@ LayerIcon LayerIcons::symbol(AdjustmentKind kind)
|
||||
case AdjustmentKind::exposure: return LayerIcon::plusMinusCircle;
|
||||
case AdjustmentKind::gradientMap: return LayerIcon::paintPalette;
|
||||
case AdjustmentKind::grain: return LayerIcon::circleGrid;
|
||||
case AdjustmentKind::addNoise: return LayerIcon::dottedCircle;
|
||||
case AdjustmentKind::gaussianBlur: return LayerIcon::drop;
|
||||
case AdjustmentKind::motionBlur: return LayerIcon::wind;
|
||||
case AdjustmentKind::invert: return LayerIcon::rightHalfCircle;
|
||||
case AdjustmentKind::blackWhite: return LayerIcon::hatchedCircle;
|
||||
case AdjustmentKind::colorBalance: return LayerIcon::axes;
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
enum class LayerIcon {
|
||||
eye, eyeSlash, chevronRight, chevronDown, folder, link, newLayer, newFolder, addMask, trash, layers, text,
|
||||
halfFilledCircle, sliders, curvePath, plusMinusCircle, paintPalette, circleGrid, sparkles, rightHalfCircle, hatchedCircle, axes,
|
||||
scope, lineDiagonal
|
||||
scope, lineDiagonal, drop, wind, dottedCircle
|
||||
};
|
||||
|
||||
namespace LayerIcons {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#include "UI/TrimSheet.h"
|
||||
#include "UI/KeyboardShortcuts.h"
|
||||
#include <QButtonGroup>
|
||||
#include <QCheckBox>
|
||||
#include <QFrame>
|
||||
#include <QGridLayout>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QRadioButton>
|
||||
#include <QVBoxLayout>
|
||||
#include <algorithm>
|
||||
|
||||
namespace {
|
||||
QLabel *text(const QString &words, int pixels, QFont::Weight weight, QWidget *parent)
|
||||
{
|
||||
auto *label = new QLabel(words, parent);
|
||||
label->setTextFormat(Qt::PlainText);
|
||||
QFont font = label->font();
|
||||
font.setPixelSize(pixels);
|
||||
font.setWeight(weight);
|
||||
label->setFont(font);
|
||||
return label;
|
||||
}
|
||||
|
||||
QFrame *divider(QWidget *parent)
|
||||
{
|
||||
auto *line = new QFrame(parent);
|
||||
line->setFrameShape(QFrame::HLine);
|
||||
line->setForegroundRole(QPalette::Mid);
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
TrimSheet::TrimSheet(std::function<void(std::optional<TrimOptions>)> finish, QWidget *parent)
|
||||
: QWidget(parent), m_finish(std::move(finish)), m_basedOn(new QButtonGroup(this)), m_ok(new QPushButton(QStringLiteral("OK"), this))
|
||||
{
|
||||
setFixedWidth(320);
|
||||
auto *column = new QVBoxLayout(this);
|
||||
column->setContentsMargins(24, 24, 24, 24);
|
||||
column->setSpacing(18);
|
||||
column->addWidget(text(QStringLiteral("Trim"), 17, QFont::Bold, this));
|
||||
|
||||
// Swift's radio group, its label hidden behind the headline.
|
||||
auto *basis = new QVBoxLayout;
|
||||
basis->setSpacing(8);
|
||||
basis->addWidget(text(QStringLiteral("Based On"), 13, QFont::DemiBold, this));
|
||||
for (const TrimBasedOn option : allTrimBasedOn) {
|
||||
auto *radio = new QRadioButton(rawValue(option), this);
|
||||
radio->setObjectName(QStringLiteral("trimBasedOn%1").arg(int(option)));
|
||||
radio->setChecked(option == TrimBasedOn::transparentPixels);
|
||||
m_basedOn->addButton(radio, int(option));
|
||||
basis->addWidget(radio);
|
||||
}
|
||||
column->addLayout(basis);
|
||||
column->addWidget(divider(this));
|
||||
|
||||
auto *away = new QVBoxLayout;
|
||||
away->setSpacing(8);
|
||||
away->addWidget(text(QStringLiteral("Trim Away"), 13, QFont::DemiBold, this));
|
||||
auto *grid = new QGridLayout;
|
||||
grid->setHorizontalSpacing(24);
|
||||
grid->setVerticalSpacing(8);
|
||||
const std::array<QString, 4> names = {QStringLiteral("Top"), QStringLiteral("Bottom"), QStringLiteral("Left"), QStringLiteral("Right")};
|
||||
for (size_t index = 0; index < names.size(); ++index) {
|
||||
m_edges[index] = new QCheckBox(names[index], this);
|
||||
m_edges[index]->setObjectName(QStringLiteral("trim") + names[index]);
|
||||
m_edges[index]->setChecked(true);
|
||||
grid->addWidget(m_edges[index], int(index / 2), int(index % 2));
|
||||
// OK rests while no edge is checked.
|
||||
connect(m_edges[index], &QCheckBox::toggled, this, [this] {
|
||||
m_ok->setEnabled(std::any_of(m_edges.begin(), m_edges.end(), [](const QCheckBox *edge) { return edge->isChecked(); }));
|
||||
});
|
||||
}
|
||||
grid->setColumnStretch(2, 1);
|
||||
away->addLayout(grid);
|
||||
column->addLayout(away);
|
||||
column->addWidget(divider(this));
|
||||
|
||||
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
|
||||
cancel->setObjectName(QStringLiteral("trimCancel"));
|
||||
cancel->setAutoDefault(false);
|
||||
connect(cancel, &QPushButton::clicked, this, [this] { m_finish(std::nullopt); });
|
||||
m_ok->setObjectName(QStringLiteral("trimOK"));
|
||||
m_ok->setDefault(true);
|
||||
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
|
||||
NativeShortcut::bind(*this, m_ok, cancel);
|
||||
connect(m_ok, &QPushButton::clicked, this, [this] {
|
||||
m_finish(TrimOptions{.basedOn = TrimBasedOn(m_basedOn->checkedId()), .top = m_edges[0]->isChecked(), .bottom = m_edges[1]->isChecked(),
|
||||
.left = m_edges[2]->isChecked(), .right = m_edges[3]->isChecked()});
|
||||
});
|
||||
auto *buttons = new QHBoxLayout;
|
||||
buttons->addWidget(cancel);
|
||||
buttons->addStretch(1);
|
||||
buttons->addWidget(m_ok);
|
||||
column->addLayout(buttons);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
#include "Document/ImageTrim.h"
|
||||
#include <QWidget>
|
||||
#include <array>
|
||||
#include <functional>
|
||||
|
||||
class QButtonGroup;
|
||||
class QCheckBox;
|
||||
class QPushButton;
|
||||
|
||||
// Swift's TrimSheet: the basis and the edges trimmed away.
|
||||
class TrimSheet : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit TrimSheet(std::function<void(std::optional<TrimOptions>)> finish, QWidget *parent = nullptr);
|
||||
|
||||
private:
|
||||
const std::function<void(std::optional<TrimOptions>)> m_finish;
|
||||
QButtonGroup *const m_basedOn;
|
||||
// Top, bottom, left, right.
|
||||
std::array<QCheckBox *, 4> m_edges{};
|
||||
QPushButton *const m_ok;
|
||||
};
|
||||
@@ -27,16 +27,6 @@ bool opened(EditorSession &session, QUuid id)
|
||||
{
|
||||
return awaited([&](std::function<void()> done) { session.beginAdjustmentEditing(id, std::move(done)); });
|
||||
}
|
||||
|
||||
// The pixels the open editor reads, premultiplied RGBA.
|
||||
std::vector<int> input(const EditorSession &session)
|
||||
{
|
||||
const QImage image = session.levels().value().original.image().convertToFormat(QImage::Format_RGBA8888_Premultiplied);
|
||||
std::vector<int> result;
|
||||
for (int y = 0; y < image.height(); ++y)
|
||||
result.insert(result.end(), image.constScanLine(y), image.constScanLine(y) + image.width() * 4);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class AdjustmentEditorTests : public QObject {
|
||||
@@ -104,6 +94,9 @@ void AdjustmentEditorTests::sharedEditorsKeepPixelsDynamicAndSupportCancel()
|
||||
case AdjustmentKind::exposure:
|
||||
case AdjustmentKind::gradientMap:
|
||||
case AdjustmentKind::grain:
|
||||
case AdjustmentKind::addNoise:
|
||||
case AdjustmentKind::gaussianBlur:
|
||||
case AdjustmentKind::motionBlur:
|
||||
case AdjustmentKind::blackWhite:
|
||||
case AdjustmentKind::colorBalance: {
|
||||
QVERIFY(session.filterEdit().value().kind == filterKind(kind));
|
||||
@@ -116,12 +109,15 @@ void AdjustmentEditorTests::sharedEditorsKeepPixelsDynamicAndSupportCancel()
|
||||
settings.blackWhite.reds = 100;
|
||||
else if (kind == AdjustmentKind::colorBalance)
|
||||
settings.colorBalance.midCyanRed = 50;
|
||||
else if (kind != AdjustmentKind::grain)
|
||||
(kind == AdjustmentKind::gaussianBlur ? settings.radius : kind == AdjustmentKind::motionBlur ? settings.distance : settings.amount) = 40;
|
||||
else
|
||||
settings.grain.amount = 70;
|
||||
session.updateFilter(settings, true);
|
||||
const LayerAdjustment live = session.activeLayer().value().adjustment.value();
|
||||
QVERIFY(live.exposure() == settings.exposure && live.gradientMap() == settings.gradientMap && live.grain() == settings.grain
|
||||
&& live.blackWhite() == settings.blackWhite && live.colorBalance() == settings.colorBalance);
|
||||
&& live.blackWhite() == settings.blackWhite && live.colorBalance() == settings.colorBalance && live.gaussianRadius() == settings.radius
|
||||
&& live.resolvedMotionDistance() == settings.distance && live.resolvedNoiseAmount() == settings.amount);
|
||||
QVERIFY(awaited([&](std::function<void()> done) { session.commitFilter(std::move(done)); }));
|
||||
break;
|
||||
}
|
||||
@@ -156,11 +152,15 @@ void AdjustmentEditorTests::sharedEditorsKeepPixelsDynamicAndSupportCancel()
|
||||
case AdjustmentKind::exposure:
|
||||
case AdjustmentKind::gradientMap:
|
||||
case AdjustmentKind::grain:
|
||||
case AdjustmentKind::addNoise:
|
||||
case AdjustmentKind::gaussianBlur:
|
||||
case AdjustmentKind::motionBlur:
|
||||
case AdjustmentKind::blackWhite:
|
||||
case AdjustmentKind::colorBalance: {
|
||||
const FilterSettings reopened = session.filterEdit().value().settings;
|
||||
QVERIFY(reopened.curves == saved.curves && reopened.exposure == saved.exposure() && reopened.gradientMap == saved.gradientMap()
|
||||
&& reopened.grain == saved.grain() && reopened.blackWhite == saved.blackWhite() && reopened.colorBalance == saved.colorBalance());
|
||||
&& reopened.grain == saved.grain() && reopened.blackWhite == saved.blackWhite() && reopened.colorBalance == saved.colorBalance()
|
||||
&& reopened.radius == saved.gaussianRadius() && reopened.distance == saved.resolvedMotionDistance() && reopened.amount == saved.resolvedNoiseAmount());
|
||||
session.updateFilter(FilterSettings(), true);
|
||||
session.cancelFilter();
|
||||
break;
|
||||
@@ -202,7 +202,8 @@ void AdjustmentEditorTests::theEditorReadsOnlyWhatLiesBeneath()
|
||||
QCOMPARE(layerWith(session, id).parentID, layerWith(session, green).parentID);
|
||||
session.setAdjustmentEditingID(id);
|
||||
QVERIFY(opened(session, id));
|
||||
QVERIFY((input(session) == std::vector<int>{0, 255, 0, 255, 255, 0, 0, 255}));
|
||||
const QImage read = session.levels().value().original.image();
|
||||
QVERIFY(read.size() == QSize(2, 1) && read.pixel(0, 0) == qRgb(0, 255, 0) && read.pixel(1, 0) == qRgb(255, 0, 0));
|
||||
// The editors preview nothing: the layer's settings are the preview.
|
||||
LevelsSettings settings;
|
||||
settings.ranges[0].outputWhite = 100;
|
||||
|
||||
@@ -311,8 +311,8 @@ void AdjustmentLayerTests::everyKindAdjustsThroughItsSettings()
|
||||
kinds[3].setExposure(ExposureSettings{.exposure = 1});
|
||||
kinds[4].setGradientMap(GradientMapSettings{{1, 0, 0}, {0, 0, 1}, false});
|
||||
kinds[5].setGrain(GrainSettings{.amount = 80, .seed = 4});
|
||||
kinds[7].setBlackWhite(BlackWhiteSettings{.reds = 100});
|
||||
kinds[8].setColorBalance(ColorBalanceSettings{.midCyanRed = 60});
|
||||
kinds[10].setBlackWhite(BlackWhiteSettings{.reds = 100});
|
||||
kinds[11].setColorBalance(ColorBalanceSettings{.midCyanRed = 60});
|
||||
for (const LayerAdjustment &adjustment : kinds) {
|
||||
EditorSession s;
|
||||
s.createDocument(2, 2);
|
||||
|
||||
@@ -70,7 +70,7 @@ void AdjustmentPanelTests::theFooterAddsEachKindAndItsEditorOpens()
|
||||
QCOMPARE(entries.size(), qsizetype(allAdjustmentKinds.size()));
|
||||
for (size_t index = 0; index < allAdjustmentKinds.size(); ++index)
|
||||
QCOMPARE(entries[qsizetype(index)]->text(), rawValue(allAdjustmentKinds[index]).replace("&", "&&"));
|
||||
QCOMPARE(entries[7]->text(), QString("Black && White"));
|
||||
QCOMPARE(entries[10]->text(), QString("Black && White"));
|
||||
session.createDocument(4, 4);
|
||||
session.insert(whiteImage(4, 4));
|
||||
QVERIFY(button.isEnabled());
|
||||
@@ -103,11 +103,11 @@ void AdjustmentPanelTests::rowsShowTheirGlyphAndOpenOnADoubleClick()
|
||||
}
|
||||
session.addMask();
|
||||
NativeLayerList list(session);
|
||||
list.resize(252, 500);
|
||||
list.resize(252, 720);
|
||||
list.show();
|
||||
QVERIFY(QTest::qWaitForWindowActive(&list));
|
||||
// Top first: the last kind added heads the list.
|
||||
QCOMPARE(list.cells().size(), size_t(10));
|
||||
QCOMPARE(list.cells().size(), size_t(13));
|
||||
for (size_t index = 0; index < allAdjustmentKinds.size(); ++index) {
|
||||
LayerCell &row = *list.cells().at(allAdjustmentKinds.size() - 1 - index);
|
||||
const QColor ink = row.palette().color(QPalette::WindowText);
|
||||
@@ -120,6 +120,8 @@ void AdjustmentPanelTests::rowsShowTheirGlyphAndOpenOnADoubleClick()
|
||||
const std::pair<AdjustmentKind, LayerIcon> symbols[] = {{AdjustmentKind::hsv, LayerIcon::halfFilledCircle}, {AdjustmentKind::levels, LayerIcon::sliders},
|
||||
{AdjustmentKind::curves, LayerIcon::curvePath}, {AdjustmentKind::exposure, LayerIcon::plusMinusCircle},
|
||||
{AdjustmentKind::gradientMap, LayerIcon::paintPalette}, {AdjustmentKind::grain, LayerIcon::circleGrid},
|
||||
{AdjustmentKind::addNoise, LayerIcon::dottedCircle}, {AdjustmentKind::gaussianBlur, LayerIcon::drop},
|
||||
{AdjustmentKind::motionBlur, LayerIcon::wind},
|
||||
{AdjustmentKind::invert, LayerIcon::rightHalfCircle}, {AdjustmentKind::blackWhite, LayerIcon::hatchedCircle},
|
||||
{AdjustmentKind::colorBalance, LayerIcon::axes}};
|
||||
QList<QImage> glyphs;
|
||||
@@ -139,6 +141,12 @@ void AdjustmentPanelTests::rowsShowTheirGlyphAndOpenOnADoubleClick()
|
||||
const QImage upright = LayerIcons::pixmap(LayerIcon::curvePath, 36, Qt::black, 1).toImage();
|
||||
QVERIFY(inked(curve, 24, 11) && !inked(curve, 11, 11));
|
||||
QVERIFY(inked(upright, 7, 7) && !inked(upright, 29, 7));
|
||||
// The drop fills; the wind strokes across; dots ring emptiness.
|
||||
const auto glyph = [](AdjustmentKind kind) { return LayerIcons::adjustmentThumbnail(kind, Qt::black, 1).toImage(); };
|
||||
QVERIFY(inked(glyph(AdjustmentKind::gaussianBlur), 18, 20) && !inked(glyph(AdjustmentKind::gaussianBlur), 11, 11));
|
||||
QVERIFY(inked(glyph(AdjustmentKind::motionBlur), 11, 18) && inked(glyph(AdjustmentKind::motionBlur), 24, 18));
|
||||
QVERIFY(!inked(glyph(AdjustmentKind::motionBlur), 16, 16));
|
||||
QVERIFY(inked(glyph(AdjustmentKind::addNoise), 26, 18) && !inked(glyph(AdjustmentKind::addNoise), 18, 18));
|
||||
// The masks: an adjustment's shows no link, the pixels' does.
|
||||
QVERIFY(list.cells().front()->findChild<QToolButton *>("maskLink")->isHidden());
|
||||
QVERIFY(!list.cells().back()->findChild<QToolButton *>("maskLink")->isHidden());
|
||||
@@ -160,11 +168,11 @@ void AdjustmentPanelTests::rowsShowTheirGlyphAndOpenOnADoubleClick()
|
||||
QTest::keyClick(levels.findChild<QLineEdit *>("layerNameEditor"), Qt::Key_Escape);
|
||||
QVERIFY(!session.renamingLayerID());
|
||||
// Invert has no editor: its glyph renames, as Swift's row.
|
||||
LayerCell &invert = *list.cells().at(allAdjustmentKinds.size() - 7);
|
||||
LayerCell &invert = *list.cells().at(allAdjustmentKinds.size() - 10);
|
||||
QTest::mouseDClick(&invert.thumbnail(), Qt::LeftButton);
|
||||
QCOMPARE(session.activeLayerID(), std::optional(ids[6]));
|
||||
QCOMPARE(session.activeLayerID(), std::optional(ids[9]));
|
||||
QVERIFY(!session.adjustmentEditingID());
|
||||
QCOMPARE(session.renamingLayerID(), std::optional(ids[6]));
|
||||
QCOMPARE(session.renamingLayerID(), std::optional(ids[9]));
|
||||
QCOMPARE(invert.findChild<QLabel *>("layerDimensions")->text(), QString("Adjustment · Double-click to edit"));
|
||||
}
|
||||
|
||||
@@ -178,6 +186,8 @@ void AdjustmentPanelTests::theLayerMenuAddsAndEditsAdjustments()
|
||||
const std::pair<const char *, const char *> entries[] = {{"newHueSaturationAdjustment", "Hue/Saturation…"}, {"newLevelsAdjustment", "Levels…"},
|
||||
{"newCurvesAdjustment", "Curves…"}, {"newExposureAdjustment", "Exposure…"},
|
||||
{"newGradientMapAdjustment", "Gradient Map…"}, {"newGrainAdjustment", "Grain…"},
|
||||
{"newAddNoiseAdjustment", "Add Noise…"}, {"newGaussianBlurAdjustment", "Gaussian Blur…"},
|
||||
{"newMotionBlurAdjustment", "Motion Blur…"},
|
||||
{"newInvertAdjustment", "Invert"}, {"newBlackWhiteAdjustment", "Black && White…"},
|
||||
{"newColorBalanceAdjustment", "Color Balance…"}};
|
||||
const QList<QAction *> listed = adjustments.menu()->actions();
|
||||
|
||||
@@ -111,7 +111,7 @@ void AdjustmentSurfaceTests::pastItsPixelBudgetNothingIsDrawn()
|
||||
painter.scale(2, 2);
|
||||
bool called = false;
|
||||
QTest::ignoreMessage(QtWarningMsg, "an adjustment surface passes its pixel budget: QSize(8, 8)");
|
||||
AdjustmentSurface::draw(painter, [&](QPainter &) { called = true; }, 63);
|
||||
AdjustmentSurface::draw(painter, [&](QPainter &) { called = true; }, 0, 63);
|
||||
QVERIFY(!called);
|
||||
AdjustmentSurface::draw(painter, [&](QPainter &target) {
|
||||
called = true;
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
#include "Rendering/EditorCanvas.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "IO/ProjectStore+Json.h"
|
||||
#include "ProjectFixtures.h"
|
||||
#include "RenderFixtures.h"
|
||||
#include <QJsonArray>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
#include <limits>
|
||||
extern "C" {
|
||||
#include "NoisePixels.h"
|
||||
}
|
||||
|
||||
// Blur and noise adjustment layers: 1.2.3's three kinds.
|
||||
namespace {
|
||||
constexpr double notANumber = std::numeric_limits<double>::quiet_NaN();
|
||||
|
||||
ImportedImage filled(int width, int height, QRgb premultiplied, const QString &name)
|
||||
{
|
||||
const QImage image = solid(width, height, premultiplied);
|
||||
return ImportedImage(image, image, name);
|
||||
}
|
||||
|
||||
bool refused(const QJsonObject &object)
|
||||
{
|
||||
try {
|
||||
ManifestJson::adjustment(object);
|
||||
} catch (const ProjectError &) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Saving and loading judge alike.
|
||||
std::optional<ProjectError::Kind> stored(const ProjectSnapshot &snapshot)
|
||||
{
|
||||
QTemporaryDir root;
|
||||
const std::optional<ProjectError::Kind> saving = projectError([&] { ProjectStore::save(snapshot, root.filePath("Saved.comp")); });
|
||||
ProjectStore::save(twoLayers(), root.filePath("Read.comp"));
|
||||
for (const auto &[id, pixels] : snapshot.images) {
|
||||
if (!pixels.image().save(root.filePath("Read.comp/images/") + uuidString(id) + ".png"))
|
||||
throw std::runtime_error("cannot write a fixture image");
|
||||
}
|
||||
overwrite(root.filePath("Read.comp/manifest.json"), snapshot.manifest.encoded());
|
||||
if (saving != projectError([&] { ProjectStore::load(root.filePath("Read.comp")); }))
|
||||
throw std::runtime_error("saving and loading judged the project apart");
|
||||
return saving;
|
||||
}
|
||||
|
||||
// White, black over the left half's top square, the adjustment.
|
||||
QUuid blurred(EditorSession &session, const LayerAdjustment &adjustment)
|
||||
{
|
||||
const QSizeF size = session.document().value().size();
|
||||
const int side = int(size.width()) / 2;
|
||||
session.insert(filled(int(size.width()), int(size.height()), qRgba(255, 255, 255, 255), "White"), QPointF(size.width() / 2, size.height() / 2));
|
||||
session.insert(filled(side, side, qRgba(0, 0, 0, 255), "Black"), QPointF(side / 2.0, side / 2.0));
|
||||
session.addAdjustment(adjustment.kind);
|
||||
session.setAdjustmentEditingID(std::nullopt);
|
||||
const QUuid id = session.activeLayerID().value();
|
||||
session.updateAdjustment(id, adjustment);
|
||||
return id;
|
||||
}
|
||||
|
||||
LayerAdjustment gaussian(double radius)
|
||||
{
|
||||
LayerAdjustment adjustment{AdjustmentKind::gaussianBlur};
|
||||
adjustment.setGaussianRadius(radius);
|
||||
return adjustment;
|
||||
}
|
||||
|
||||
// A canvas this size at a zoom, no overlay.
|
||||
void frame(EditorSession &session, CanvasView &canvas, QSize view, double zoom)
|
||||
{
|
||||
session.setShowsTransformControls(false);
|
||||
canvas.resize(view);
|
||||
session.viewport.resize(QSizeF(view), 1, session.document().value().size());
|
||||
session.zoom(zoom);
|
||||
}
|
||||
|
||||
int level(const QImage &image, int x, int y)
|
||||
{
|
||||
return qGray(image.pixel(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
class BlurNoiseAdjustmentTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void settingsFallBackToSwiftsDefaultsAndBound();
|
||||
void theHaloCoversEachKindsReach();
|
||||
void theManifestWritesTheKeysOnlyWhenSet();
|
||||
void theStoreTakesTheKindsFromVersion9();
|
||||
void eachNoiseLayerRollsItsOwnSeed();
|
||||
void eachEditorWritesItsOwnSettingsAlone();
|
||||
void theKindsRunTheirFiltersAtTheScale();
|
||||
void noiseStaysOneFieldAcrossRegions();
|
||||
void aScaledTargetBlursInItsOwnPixels();
|
||||
void theCanvasBlursAtItsZoom();
|
||||
void aPartialRepaintReadsTheHalo();
|
||||
};
|
||||
|
||||
void BlurNoiseAdjustmentTests::settingsFallBackToSwiftsDefaultsAndBound()
|
||||
{
|
||||
LayerAdjustment adjustment{AdjustmentKind::levels};
|
||||
QVERIFY(!adjustment.blurRadius && !adjustment.motionAngle && !adjustment.motionDistance && !adjustment.noiseAmount);
|
||||
QVERIFY(!adjustment.noiseGaussian && !adjustment.noiseMonochromatic && !adjustment.noiseSeed);
|
||||
QVERIFY(adjustment.gaussianRadius() == 10 && adjustment.resolvedMotionAngle() == 0 && adjustment.resolvedMotionDistance() == 10);
|
||||
QVERIFY(adjustment.resolvedNoiseAmount() == 10 && !adjustment.resolvedNoiseGaussian() && !adjustment.resolvedNoiseMonochromatic());
|
||||
QCOMPARE(adjustment.resolvedNoiseSeed(), 0u);
|
||||
// Each setter fills its own optional.
|
||||
adjustment.setGaussianRadius(3);
|
||||
adjustment.setResolvedMotionAngle(-20);
|
||||
adjustment.setResolvedMotionDistance(40);
|
||||
adjustment.setResolvedNoiseAmount(70);
|
||||
adjustment.setResolvedNoiseGaussian(true);
|
||||
adjustment.setResolvedNoiseMonochromatic(true);
|
||||
adjustment.setResolvedNoiseSeed(12);
|
||||
QVERIFY(adjustment.blurRadius == 3.0 && adjustment.motionAngle == -20.0 && adjustment.motionDistance == 40.0 && adjustment.noiseAmount == 70.0);
|
||||
QVERIFY(adjustment.noiseGaussian == true && adjustment.noiseMonochromatic == true && adjustment.noiseSeed == 12u);
|
||||
QVERIFY(adjustment.isValid());
|
||||
// Swift's ranges bind every kind, NaN included.
|
||||
const std::pair<void (LayerAdjustment::*)(double), std::vector<std::pair<double, bool>>> bounds[] = {
|
||||
{&LayerAdjustment::setGaussianRadius, {{0.1, true}, {250, true}, {0.09, false}, {250.5, false}, {notANumber, false}}},
|
||||
{&LayerAdjustment::setResolvedMotionAngle, {{-90, true}, {90, true}, {-90.5, false}, {90.5, false}, {notANumber, false}}},
|
||||
{&LayerAdjustment::setResolvedMotionDistance, {{1, true}, {2000, true}, {0.5, false}, {2000.5, false}, {notANumber, false}}},
|
||||
{&LayerAdjustment::setResolvedNoiseAmount, {{0.1, true}, {400, true}, {0.05, false}, {400.5, false}, {notANumber, false}}}};
|
||||
for (const auto &[set, values] : bounds) {
|
||||
for (const auto &[value, valid] : values) {
|
||||
LayerAdjustment bounded{AdjustmentKind::hsv};
|
||||
(bounded.*set)(value);
|
||||
QVERIFY2(bounded.isValid() == valid, qPrintable(QString::number(value)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void BlurNoiseAdjustmentTests::theHaloCoversEachKindsReach()
|
||||
{
|
||||
// Swift's samplingMargin: three sigmas plus two, half streak plus two.
|
||||
QCOMPARE(gaussian(4).samplingMargin(), 14.0);
|
||||
LayerAdjustment motion{AdjustmentKind::motionBlur};
|
||||
motion.setResolvedMotionDistance(20);
|
||||
QCOMPARE(motion.samplingMargin(), 12.0);
|
||||
for (const AdjustmentKind kind : allAdjustmentKinds) {
|
||||
if (kind != AdjustmentKind::gaussianBlur && kind != AdjustmentKind::motionBlur)
|
||||
QCOMPARE(LayerAdjustment{kind}.samplingMargin(), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
void BlurNoiseAdjustmentTests::theManifestWritesTheKeysOnlyWhenSet()
|
||||
{
|
||||
QCOMPARE(ManifestJson::encoded(LayerAdjustment{AdjustmentKind::addNoise}).keys(),
|
||||
(QStringList{"colorize", "curves", "hue", "kind", "levels", "lightness", "saturation"}));
|
||||
LayerAdjustment set{AdjustmentKind::motionBlur};
|
||||
set.setGaussianRadius(2.5);
|
||||
set.setResolvedMotionAngle(-45);
|
||||
set.setResolvedMotionDistance(30);
|
||||
set.setResolvedNoiseAmount(25);
|
||||
set.setResolvedNoiseGaussian(true);
|
||||
set.setResolvedNoiseMonochromatic(false);
|
||||
set.setResolvedNoiseSeed(4'294'967'295u);
|
||||
const QJsonObject object = ManifestJson::encoded(set);
|
||||
QCOMPARE(object.value("kind").toString(), QString("Motion Blur"));
|
||||
QVERIFY(object.value("blurRadius") == 2.5 && object.value("motionAngle") == -45 && object.value("motionDistance") == 30);
|
||||
QVERIFY(object.value("noiseAmount") == 25 && object.value("noiseGaussian") == true && object.value("noiseMonochromatic") == false);
|
||||
QCOMPARE(object.value("noiseSeed").toInteger(), qint64(4'294'967'295));
|
||||
QVERIFY(ManifestJson::adjustment(object) == set);
|
||||
// Absent or null is nil; wrong types and seeds refuse.
|
||||
for (const char *key : {"blurRadius", "motionAngle", "motionDistance", "noiseAmount", "noiseGaussian", "noiseMonochromatic", "noiseSeed"}) {
|
||||
QJsonObject changed = object;
|
||||
changed.insert(QLatin1String(key), QJsonValue::Null);
|
||||
QVERIFY2(!refused(changed) && ManifestJson::adjustment(changed) != set, key);
|
||||
changed.remove(QLatin1String(key));
|
||||
QVERIFY2(!refused(changed), key);
|
||||
changed.insert(QLatin1String(key), QJsonArray());
|
||||
QVERIFY2(refused(changed), key);
|
||||
}
|
||||
for (const QJsonValue &seed : {QJsonValue(-1), QJsonValue(qint64(4'294'967'296)), QJsonValue(1.5)}) {
|
||||
QJsonObject changed = object;
|
||||
changed.insert("noiseSeed", seed);
|
||||
QVERIFY(refused(changed));
|
||||
}
|
||||
}
|
||||
|
||||
void BlurNoiseAdjustmentTests::theStoreTakesTheKindsFromVersion9()
|
||||
{
|
||||
for (const AdjustmentKind kind : {AdjustmentKind::gaussianBlur, AdjustmentKind::motionBlur, AdjustmentKind::addNoise}) {
|
||||
ProjectSnapshot snapshot = twoLayers();
|
||||
snapshot.manifest.layers[1].adjustment = LayerAdjustment{kind};
|
||||
QCOMPARE(stored(snapshot), std::nullopt);
|
||||
snapshot.manifest.version = 8;
|
||||
QCOMPARE(stored(snapshot), std::optional(ProjectError::Kind::invalid));
|
||||
// Invalid settings refuse it at any version.
|
||||
snapshot.manifest.version = 9;
|
||||
snapshot.manifest.layers[1].adjustment->setGaussianRadius(300);
|
||||
QCOMPARE(stored(snapshot), std::optional(ProjectError::Kind::invalid));
|
||||
}
|
||||
// The rule reads the kind: older kinds may carry keys.
|
||||
ProjectSnapshot older = twoLayers();
|
||||
older.manifest.version = 7;
|
||||
older.manifest.layers[1].adjustment = LayerAdjustment{AdjustmentKind::levels};
|
||||
older.manifest.layers[1].adjustment->setGaussianRadius(5);
|
||||
QCOMPARE(stored(older), std::nullopt);
|
||||
}
|
||||
|
||||
void BlurNoiseAdjustmentTests::eachNoiseLayerRollsItsOwnSeed()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(4, 4);
|
||||
session.addAdjustment(AdjustmentKind::addNoise);
|
||||
const LayerAdjustment first = session.activeLayer().value().adjustment.value();
|
||||
session.setAdjustmentEditingID(std::nullopt);
|
||||
session.addAdjustment(AdjustmentKind::addNoise);
|
||||
const LayerAdjustment second = session.activeLayer().value().adjustment.value();
|
||||
QVERIFY(first.noiseSeed && second.noiseSeed && *first.noiseSeed != *second.noiseSeed);
|
||||
// Only noise rolls one; the rest keep their defaults.
|
||||
session.setAdjustmentEditingID(std::nullopt);
|
||||
session.addAdjustment(AdjustmentKind::gaussianBlur);
|
||||
QVERIFY(session.activeLayer().value().adjustment.value() == LayerAdjustment{AdjustmentKind::gaussianBlur});
|
||||
}
|
||||
|
||||
void BlurNoiseAdjustmentTests::eachEditorWritesItsOwnSettingsAlone()
|
||||
{
|
||||
for (const AdjustmentKind kind : {AdjustmentKind::gaussianBlur, AdjustmentKind::motionBlur, AdjustmentKind::addNoise}) {
|
||||
EditorSession session;
|
||||
session.createDocument(4, 4);
|
||||
session.insert(filled(4, 4, qRgba(255, 255, 255, 255), "White"));
|
||||
session.addAdjustment(kind);
|
||||
const QUuid id = session.activeLayerID().value();
|
||||
// Every field set: the panel reads them all.
|
||||
LayerAdjustment preset = session.activeLayer().value().adjustment.value();
|
||||
preset.setGaussianRadius(20);
|
||||
preset.setResolvedMotionAngle(45);
|
||||
preset.setResolvedMotionDistance(30);
|
||||
preset.setResolvedNoiseAmount(50);
|
||||
preset.setResolvedNoiseGaussian(true);
|
||||
preset.setResolvedNoiseMonochromatic(true);
|
||||
session.updateAdjustment(id, preset);
|
||||
bool open = false;
|
||||
session.beginAdjustmentEditing(id, [&open] { open = true; });
|
||||
QTRY_VERIFY(open);
|
||||
const FilterEdit &edit = session.filterEdit().value();
|
||||
QVERIFY(edit.kind == filterKind(kind));
|
||||
QVERIFY(edit.settings.radius == 20 && edit.settings.angle == 45 && edit.settings.distance == 30 && edit.settings.amount == 50);
|
||||
QVERIFY(edit.settings.gaussian && edit.settings.monochromatic);
|
||||
session.updateFilter(FilterSettings{.radius = 5, .angle = -30, .distance = 12, .amount = 70}, true);
|
||||
// The kind writes its own; the others keep the preset.
|
||||
const LayerAdjustment live = session.activeLayer().value().adjustment.value();
|
||||
const bool blur = kind == AdjustmentKind::gaussianBlur, motion = kind == AdjustmentKind::motionBlur, noise = !blur && !motion;
|
||||
QCOMPARE(live.blurRadius, std::optional(blur ? 5.0 : 20.0));
|
||||
QCOMPARE(live.motionAngle, std::optional(motion ? -30.0 : 45.0));
|
||||
QCOMPARE(live.motionDistance, std::optional(motion ? 12.0 : 30.0));
|
||||
QCOMPARE(live.noiseAmount, std::optional(noise ? 70.0 : 50.0));
|
||||
QCOMPARE(live.noiseGaussian, std::optional(!noise));
|
||||
QCOMPARE(live.noiseMonochromatic, std::optional(!noise));
|
||||
// The seed stays the layer's own.
|
||||
QCOMPARE(live.noiseSeed.has_value(), noise);
|
||||
session.cancelFilter();
|
||||
}
|
||||
}
|
||||
|
||||
void BlurNoiseAdjustmentTests::theKindsRunTheirFiltersAtTheScale()
|
||||
{
|
||||
QImage image = BrushRaster::context(12, 12, false);
|
||||
image.fill(0);
|
||||
QPainter(&image).fillRect(QRect(3, 3, 6, 6), QColor(200, 100, 50));
|
||||
const auto filtered = [&image](FilterKind kind, const FilterSettings &settings, quint32 seed = 0) {
|
||||
return PixelFilter::run(FilterJob{kind, image, settings, 1, std::nullopt, QTransform(), seed});
|
||||
};
|
||||
// At scale two a blur reads twice the radius.
|
||||
QCOMPARE(gaussian(1.5).apply(image, std::nullopt, 2), filtered(FilterKind::gaussianBlur, FilterSettings{.radius = 3}));
|
||||
LayerAdjustment motion{AdjustmentKind::motionBlur};
|
||||
motion.setResolvedMotionAngle(30);
|
||||
motion.setResolvedMotionDistance(4);
|
||||
QCOMPARE(motion.apply(image, std::nullopt, 2), filtered(FilterKind::motionBlur, FilterSettings{.angle = 30, .distance = 8}));
|
||||
LayerAdjustment noise{AdjustmentKind::addNoise};
|
||||
noise.setResolvedNoiseAmount(60);
|
||||
noise.setResolvedNoiseGaussian(true);
|
||||
noise.setResolvedNoiseMonochromatic(true);
|
||||
noise.setResolvedNoiseSeed(77);
|
||||
QCOMPARE(noise.apply(image), filtered(FilterKind::addNoise, FilterSettings{.amount = 60, .gaussian = true, .monochromatic = true}, 77));
|
||||
QVERIFY(noise.apply(image) != image);
|
||||
}
|
||||
|
||||
void BlurNoiseAdjustmentTests::noiseStaysOneFieldAcrossRegions()
|
||||
{
|
||||
QImage gray = BrushRaster::context(8, 8, false);
|
||||
gray.fill(QColor(128, 128, 128));
|
||||
LayerAdjustment noise{AdjustmentKind::addNoise};
|
||||
noise.setResolvedNoiseAmount(50);
|
||||
noise.setResolvedNoiseSeed(3);
|
||||
// A region's pixels are the whole field's there.
|
||||
const QImage whole = noise.apply(gray, QRectF(10, 20, 8, 8));
|
||||
QCOMPARE(noise.apply(gray.copy(2, 3, 4, 4), QRectF(12, 23, 4, 4)), whole.copy(2, 3, 4, 4));
|
||||
// The kernel's origin, in the image's own pixels.
|
||||
QImage expected = gray.copy();
|
||||
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 20, 40);
|
||||
QCOMPARE(noise.apply(gray, QRectF(10, 20, 4, 4)), expected);
|
||||
// A negative origin floors, as Swift rounds down.
|
||||
expected = gray.copy();
|
||||
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, -11, -21);
|
||||
QCOMPARE(noise.apply(gray, QRectF(-10.5, -20.5, 8, 8)), expected);
|
||||
// Without a region, the origin is zero.
|
||||
expected = gray.copy();
|
||||
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 0, 0);
|
||||
QCOMPARE(noise.apply(gray), expected);
|
||||
}
|
||||
|
||||
void BlurNoiseAdjustmentTests::aScaledTargetBlursInItsOwnPixels()
|
||||
{
|
||||
// Drawn twice as large, the blur doubles in pixels.
|
||||
EditorSession session;
|
||||
session.createDocument(16, 16);
|
||||
blurred(session, gaussian(1.5));
|
||||
QImage twice = BrushRaster::context(32, 32, false);
|
||||
twice.fill(0);
|
||||
{
|
||||
QPainter painter(&twice);
|
||||
painter.scale(2, 2);
|
||||
session.drawLiveComposite(session.document().value(), painter);
|
||||
}
|
||||
session.toggleLayerVisibility(session.activeLayerID().value());
|
||||
QImage plain = BrushRaster::context(32, 32, false);
|
||||
plain.fill(0);
|
||||
{
|
||||
QPainter painter(&plain);
|
||||
painter.scale(2, 2);
|
||||
session.drawLiveComposite(session.document().value(), painter);
|
||||
}
|
||||
QCOMPARE(twice, gaussian(1.5).apply(plain, QRectF(0, 0, 16, 16), 2));
|
||||
}
|
||||
|
||||
void BlurNoiseAdjustmentTests::theCanvasBlursAtItsZoom()
|
||||
{
|
||||
// At half size radius 8 spreads 4 view points.
|
||||
EditorSession session;
|
||||
session.createDocument(200, 100);
|
||||
CanvasView canvas(session);
|
||||
blurred(session, gaussian(8));
|
||||
frame(session, canvas, QSize(200, 100), 0.5);
|
||||
QCOMPARE(session.viewport.viewPoint(QPointF(100, 50), QSizeF(200, 100)), QPointF(100, 50));
|
||||
const QImage shot = canvas.grab().toImage();
|
||||
// 0.875 sigma out 19% of the black spreads; 1.875, 3%.
|
||||
const int near = level(shot, 103, 50), far = level(shot, 107, 50);
|
||||
QVERIFY2(near > 195 && near < 220, qPrintable(QString::number(near)));
|
||||
QVERIFY2(far > 240, qPrintable(QString::number(far)));
|
||||
}
|
||||
|
||||
void BlurNoiseAdjustmentTests::aPartialRepaintReadsTheHalo()
|
||||
{
|
||||
// A dirty rect by the edge sees pixels past it.
|
||||
for (const double zoom : {1.0, 1.5, 4.0}) {
|
||||
EditorSession session;
|
||||
session.createDocument(60, 60);
|
||||
CanvasView canvas(session);
|
||||
blurred(session, gaussian(3));
|
||||
// A hair-thin blur above: the widest margin must win.
|
||||
session.addAdjustment(AdjustmentKind::gaussianBlur);
|
||||
session.setAdjustmentEditingID(std::nullopt);
|
||||
session.updateAdjustment(session.activeLayerID().value(), gaussian(0.2));
|
||||
frame(session, canvas, QSize(240, 240), zoom);
|
||||
const QImage full = canvas.grab().toImage();
|
||||
const QPointF edge = session.viewport.viewPoint(QPointF(30, 15), QSizeF(60, 60));
|
||||
const QRect dirty(int(edge.x()) - 3, int(edge.y()) - 3, 6, 6);
|
||||
QImage part(canvas.size(), QImage::Format_ARGB32_Premultiplied);
|
||||
part.fill(0);
|
||||
canvas.render(&part, dirty.topLeft(), QRegion(dirty));
|
||||
for (int y = dirty.top(); y <= dirty.bottom(); ++y) {
|
||||
for (int x = dirty.left(); x <= dirty.right(); ++x)
|
||||
QVERIFY2(part.pixel(x, y) == full.pixel(x, y), qPrintable(QStringLiteral("%1 at %2, %3").arg(zoom).arg(x).arg(y)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QTEST_MAIN(BlurNoiseAdjustmentTests)
|
||||
#include "BlurNoiseAdjustmentTests.moc"
|
||||
@@ -36,11 +36,11 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
|
||||
{"mergeLayers", "Ctrl+E"}, {"flipHorizontal", ""}, {"flipVertical", ""}, {"deleteLayer", ""},
|
||||
{"selectAll", "Ctrl+A"}, {"deselect", "Ctrl+D"}, {"inverse", "Ctrl+Shift+I"}, {"layerPixels", ""}, {"subject", "Ctrl+Alt+A"}, {"maskBlackAreas", ""},
|
||||
{"expandSelection", ""}, {"contractSelection", ""}, {"featherSelection", ""}, {"curves", "Ctrl+M"}, {"levels", "Ctrl+L"}, {"hueSaturation", "Ctrl+U"}, {"blackWhite", ""}, {"colorBalance", ""}, {"exposure", ""}, {"gradientMap", ""}, {"grain", ""}, {"invert", "Ctrl+I"},
|
||||
{"canvasSize", "Ctrl+Alt+C"}, {"imageSize", "Ctrl+Alt+I"}, {"flipCanvasHorizontal", ""}, {"flipCanvasVertical", ""},
|
||||
{"canvasSize", "Ctrl+Alt+C"}, {"imageSize", "Ctrl+Alt+I"}, {"trim", ""}, {"flipCanvasHorizontal", ""}, {"flipCanvasVertical", ""},
|
||||
{"gaussianBlur", ""}, {"motionBlur", ""}, {"addNoise", ""}, {"lensCorrection", ""}, {"removeBackground", ""}, {"newAdjustmentLayer", ""}, {"editAdjustment", ""},
|
||||
{"newHueSaturationAdjustment", ""}, {"newLevelsAdjustment", ""}, {"newCurvesAdjustment", ""}, {"newExposureAdjustment", ""},
|
||||
{"newGradientMapAdjustment", ""}, {"newGrainAdjustment", ""}, {"newInvertAdjustment", ""}, {"newBlackWhiteAdjustment", ""},
|
||||
{"newColorBalanceAdjustment", ""},
|
||||
{"newColorBalanceAdjustment", ""}, {"newAddNoiseAdjustment", ""}, {"newGaussianBlurAdjustment", ""}, {"newMotionBlurAdjustment", ""},
|
||||
};
|
||||
for (const auto &[name, key] : keys)
|
||||
QCOMPARE(bar.action(name).shortcut().toString(), QString::fromLatin1(key));
|
||||
@@ -54,7 +54,11 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
|
||||
QCOMPARE(menus[1]->actions().size(), 13);
|
||||
QCOMPARE(menus[2]->actions().size(), 16);
|
||||
QCOMPARE(menus[3]->actions().size(), 10);
|
||||
QCOMPARE(menus[4]->actions().size(), 15);
|
||||
QCOMPARE(menus[4]->actions().size(), 16);
|
||||
// Swift's Trim… follows Image Size….
|
||||
const QList<QAction *> image = menus[4]->actions();
|
||||
QCOMPARE(image.indexOf(&bar.action("trim")), image.indexOf(&bar.action("imageSize")) + 1);
|
||||
QCOMPARE(bar.action("trim").text(), QString("Trim…"));
|
||||
// Swift's Image menu: Black & White and Color Balance first.
|
||||
QStringList adjustments;
|
||||
for (int index = 3; index < 8; ++index)
|
||||
@@ -433,7 +437,7 @@ void CompositorMenusTests::imageEntriesSizeAndFlipTheCanvas()
|
||||
{
|
||||
Bar bar;
|
||||
bar.window.show();
|
||||
const char *entries[] = {"canvasSize", "imageSize", "flipCanvasHorizontal", "flipCanvasVertical"};
|
||||
const char *entries[] = {"canvasSize", "imageSize", "trim", "flipCanvasHorizontal", "flipCanvasVertical"};
|
||||
for (const char *name : entries)
|
||||
QVERIFY2(!bar.action(name).isEnabled(), name);
|
||||
bar.session().createDocument(100, 50);
|
||||
@@ -446,7 +450,7 @@ void CompositorMenusTests::imageEntriesSizeAndFlipTheCanvas()
|
||||
QVERIFY2(!bar.action(name).isEnabled(), name);
|
||||
bar.session().setIsProjectBusy(false);
|
||||
bar.session().selectTool(NavigationTool::crop);
|
||||
QVERIFY(bar.action("canvasSize").isEnabled() && !bar.action("flipCanvasHorizontal").isEnabled());
|
||||
QVERIFY(bar.action("canvasSize").isEnabled() && bar.action("trim").isEnabled() && !bar.action("flipCanvasHorizontal").isEnabled());
|
||||
bar.session().selectTool(NavigationTool::move);
|
||||
bar.action("flipCanvasHorizontal").trigger();
|
||||
QCOMPARE(bar.session().activeLayer().value().origin(), QPointF(79, 10));
|
||||
@@ -454,7 +458,7 @@ void CompositorMenusTests::imageEntriesSizeAndFlipTheCanvas()
|
||||
bar.action("flipCanvasVertical").trigger();
|
||||
QCOMPARE(bar.session().activeLayer().value().origin(), QPointF(79, 39));
|
||||
// The size entries open their sheets over the window.
|
||||
for (const auto &[name, title] : {std::pair("canvasSize", "Canvas Size"), std::pair("imageSize", "Image Size")}) {
|
||||
for (const auto &[name, title] : {std::pair("canvasSize", "Canvas Size"), std::pair("imageSize", "Image Size"), std::pair("trim", "Trim")}) {
|
||||
bar.action(name).trigger();
|
||||
auto *sheet = bar.window.findChild<QDialog *>();
|
||||
QVERIFY(sheet && sheet->isVisible());
|
||||
|
||||
@@ -119,7 +119,7 @@ void EffectsPanelTests::theFooterAddsEachEffectAndOpensItsPanel()
|
||||
QStringList names;
|
||||
for (const QAction *entry : button.menu()->actions())
|
||||
names << entry->text();
|
||||
QCOMPARE(names, QStringList({"Stroke…", "Drop Shadow…", "Color Overlay…", "Inner Shadow…", "Outer Glow…"}));
|
||||
QCOMPARE(names, QStringList({"Stroke…", "Drop Shadow…", "Color Overlay…", "Inner Shadow…", "Outer Glow…", "Inner Glow…"}));
|
||||
QVERIFY(button.isEnabled());
|
||||
button.menu()->actions()[0]->trigger();
|
||||
QVERIFY((editor.session.effectsEditing() == LayerEffectSelection{editor.layer, LayerEffectKind::stroke}));
|
||||
@@ -292,6 +292,7 @@ void EffectsPanelTests::eachKindHasSwiftsControls_data()
|
||||
QTest::newRow("inner shadow") << LayerEffectKind::innerShadow << QString("Inner Shadow")
|
||||
<< QStringList{"Opacity:0:100:100", "Angle:-180:180:180", "Distance:0:5000:50", "Blur:0:500:100"};
|
||||
QTest::newRow("outer glow") << LayerEffectKind::outerGlow << QString("Outer Glow") << QStringList{"Size:0:500:100", "Opacity:0:100:100"};
|
||||
QTest::newRow("inner glow") << LayerEffectKind::innerGlow << QString("Inner Glow") << QStringList{"Size:0:500:100", "Opacity:0:100:100"};
|
||||
}
|
||||
|
||||
void EffectsPanelTests::eachKindHasSwiftsControls()
|
||||
|
||||
@@ -320,6 +320,7 @@ void EffectsPreviewCacheTests::aReducedPreviewScalesEveryLength()
|
||||
effects.colorOverlay = ColorOverlayEffect{.red = 0, .green = 1, .blue = 0, .opacity = 0.5};
|
||||
effects.innerShadow = InnerShadowEffect{.angle = 120, .distance = 30, .blur = 12, .red = 0, .green = 0, .blue = 0, .opacity = 0.7};
|
||||
effects.outerGlow = OuterGlowEffect{.size = 5, .red = 0, .green = 0, .blue = 1, .opacity = 0.6};
|
||||
effects.innerGlow = InnerGlowEffect{.size = 40, .red = 1, .green = 0, .blue = 1, .opacity = 0.8};
|
||||
const QImage image = noise(6000, 100, 7);
|
||||
const ImageLayer layer = layerOf(image, effects);
|
||||
const Landings landings;
|
||||
@@ -335,6 +336,7 @@ void EffectsPreviewCacheTests::aReducedPreviewScalesEveryLength()
|
||||
effects.innerShadow->distance *= factor;
|
||||
effects.innerShadow->blur *= factor;
|
||||
effects.outerGlow->size *= factor;
|
||||
effects.innerGlow->size *= factor;
|
||||
QVERIFY(result.image == LayerEffectsRenderer::render(reduced, std::nullopt, effects).image);
|
||||
}
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ void FolderOpacityTests::aDimmedFolderSavesAndReopens()
|
||||
const QString path = root.filePath("Dimmed.comp");
|
||||
ProjectStore::save(session.projectSnapshot().value(), path);
|
||||
const ProjectSnapshot loaded = ProjectStore::load(path);
|
||||
QCOMPARE(loaded.manifest.version, qint64(8));
|
||||
QCOMPARE(loaded.manifest.version, qint64(9));
|
||||
for (const ProjectLayerRecord &record : loaded.manifest.layers) {
|
||||
if (record.isGroup == true)
|
||||
QCOMPARE(record.opacity, std::optional(0.5));
|
||||
|
||||
@@ -217,7 +217,7 @@ void GroupSessionTests::groupsRoundTripAndSurviveImageAndCanvasResize()
|
||||
session.addBlankLayer();
|
||||
const QUuid child = session.activeLayerID().value();
|
||||
const ProjectSnapshot snapshot = session.projectSnapshot().value();
|
||||
QCOMPARE(snapshot.manifest.version, qint64(8));
|
||||
QCOMPARE(snapshot.manifest.version, qint64(9));
|
||||
const auto record = [](const ProjectSnapshot &from, QUuid id) {
|
||||
for (const ProjectLayerRecord &layer : from.manifest.layers) {
|
||||
if (layer.id == id)
|
||||
|
||||
@@ -258,10 +258,10 @@ void GroupTests::projectErrorsCarryTheirMessages()
|
||||
QCOMPARE(invalid.version, std::nullopt);
|
||||
QCOMPARE(QString(invalid.what()), QString("This is not a valid OmaPhoto project, or its metadata is damaged."));
|
||||
|
||||
const ProjectError version = ProjectError::unsupportedVersion(9);
|
||||
const ProjectError version = ProjectError::unsupportedVersion(10);
|
||||
QCOMPARE(version.kind, ProjectError::Kind::version);
|
||||
QCOMPARE(version.version, std::optional<int>(9));
|
||||
QCOMPARE(QString(version.what()), QString("This project uses format version 9. This app supports versions 1–8."));
|
||||
QCOMPARE(version.version, std::optional<int>(10));
|
||||
QCOMPARE(QString(version.what()), QString("This project uses format version 10. This app supports versions 1–9."));
|
||||
QVERIFY_THROWS_EXCEPTION(std::logic_error, ProjectError(ProjectError::Kind::version));
|
||||
|
||||
const ProjectError missingImage(ProjectError::Kind::missingImage);
|
||||
|
||||
@@ -139,7 +139,7 @@ void GuideTests::projectRoundTripAndLegacyRejection()
|
||||
session.addGuide(first);
|
||||
session.addGuide(second);
|
||||
ProjectSnapshot snapshot = session.projectSnapshot().value();
|
||||
QCOMPARE(snapshot.manifest.version, qint64(8));
|
||||
QCOMPARE(snapshot.manifest.version, qint64(9));
|
||||
QCOMPARE(snapshot.manifest.guides.value(), (std::vector<CanvasGuide>{first, second}));
|
||||
// Swift's keys: an array of id, axis and position.
|
||||
const QByteArray json = snapshot.manifest.encoded();
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
#include "DialogDesk.h"
|
||||
#include "Document/ImageTrim.h"
|
||||
#include "IO/ProjectController.h"
|
||||
#include "UI/TrimSheet.h"
|
||||
#include <QButtonGroup>
|
||||
#include <QCheckBox>
|
||||
#include <QDialog>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QRadioButton>
|
||||
#include <QtTest>
|
||||
|
||||
// Swift's ImageTrimTests, then the sheet, the controller and the menu.
|
||||
namespace {
|
||||
struct Colour {
|
||||
int r, g, b, a;
|
||||
};
|
||||
|
||||
// Premultiplied RGBA, painted pixel by pixel.
|
||||
QImage painted(int width, int height, const std::function<Colour(int, int)> &painter)
|
||||
{
|
||||
QImage image(width, height, QImage::Format_RGBA8888_Premultiplied);
|
||||
for (int y = 0; y < height; ++y) {
|
||||
uchar *row = image.scanLine(y);
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const Colour colour = painter(x, y);
|
||||
row[x * 4] = uchar(colour.r), row[x * 4 + 1] = uchar(colour.g), row[x * 4 + 2] = uchar(colour.b), row[x * 4 + 3] = uchar(colour.a);
|
||||
}
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
TrimOptions edges(TrimBasedOn basedOn, bool top, bool bottom, bool left, bool right)
|
||||
{
|
||||
return TrimOptions{.basedOn = basedOn, .top = top, .bottom = bottom, .left = left, .right = right};
|
||||
}
|
||||
|
||||
struct Desk {
|
||||
EditorSession session;
|
||||
ProjectController controller{session};
|
||||
QWidget window;
|
||||
bool done = false;
|
||||
Desk()
|
||||
{
|
||||
session.createDocument(100, 100);
|
||||
session.insert(ImportedImage(painted(40, 40, [](int, int) { return Colour{200, 50, 50, 255}; }), QImage(), "Square"), QPointF(40, 50));
|
||||
window.resize(600, 400);
|
||||
window.show();
|
||||
controller.window = &window;
|
||||
}
|
||||
QDialog &dialog()
|
||||
{
|
||||
auto *shown = window.findChild<QDialog *>();
|
||||
if (!shown)
|
||||
throw std::runtime_error("no sheet shown");
|
||||
return *shown;
|
||||
}
|
||||
std::function<void()> finished()
|
||||
{
|
||||
done = false;
|
||||
return [this] { done = true; };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class ImageTrimTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void calculateTrimRectTransparentPixels();
|
||||
void calculateTrimRectTopLeftPixelColor();
|
||||
void calculateTrimRectBottomRightPixelColor();
|
||||
void uniformImageReturnsNil();
|
||||
void trimImageDirectly();
|
||||
void sessionTrimIntegrationAndUndo();
|
||||
void toleranceAndAlphaDecideAMatch();
|
||||
void theSheetSaysSwiftsWordsAndReturnsItsChoice();
|
||||
void theControllerTrimsThroughTheSheetAsOneStep();
|
||||
void nothingToTrimOrACancelChangesNothing();
|
||||
void aTrimThatFailsIsExplained();
|
||||
};
|
||||
|
||||
void ImageTrimTests::calculateTrimRectTransparentPixels()
|
||||
{
|
||||
// Content x 2..<17, y 4..<15, clear around it.
|
||||
const QImage image = painted(20, 20, [](int x, int y) {
|
||||
return x >= 2 && x < 17 && y >= 4 && y < 15 ? Colour{255, 0, 0, 255} : Colour{0, 0, 0, 0};
|
||||
});
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, true, true, true, true)), std::optional(QRect(2, 4, 15, 11)));
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, true, true, false, false)), std::optional(QRect(0, 4, 20, 11)));
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, false, false, true, true)), std::optional(QRect(2, 0, 15, 20)));
|
||||
// One edge alone, each.
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, false, true, false, false)), std::optional(QRect(0, 0, 20, 15)));
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, false, false, false, true)), std::optional(QRect(0, 0, 17, 20)));
|
||||
// No edge chosen trims nothing.
|
||||
QVERIFY(!edges(TrimBasedOn::transparentPixels, false, false, false, false).trimsAny());
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::transparentPixels, false, false, false, false)), std::nullopt);
|
||||
}
|
||||
|
||||
void ImageTrimTests::calculateTrimRectTopLeftPixelColor()
|
||||
{
|
||||
// A blue border of three around yellow.
|
||||
const QImage image = painted(16, 16, [](int x, int y) {
|
||||
return x >= 3 && x < 13 && y >= 3 && y < 13 ? Colour{255, 255, 0, 255} : Colour{0, 0, 255, 255};
|
||||
});
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::topLeftPixelColor, true, true, true, true)), std::optional(QRect(3, 3, 10, 10)));
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::topLeftPixelColor, true, false, false, false)), std::optional(QRect(0, 3, 16, 13)));
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::topLeftPixelColor, false, false, true, false)), std::optional(QRect(3, 0, 13, 16)));
|
||||
// A staircase: the widest reach comes from middle rows.
|
||||
const QImage stairs = painted(8, 3, [](int x, int y) {
|
||||
const int from = std::array{1, 5, 3}[size_t(y)];
|
||||
return x >= from && x < from + 2 ? Colour{255, 255, 255, 255} : Colour{0, 0, 0, 255};
|
||||
});
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(stairs, edges(TrimBasedOn::topLeftPixelColor, true, true, true, true)), std::optional(QRect(1, 0, 6, 3)));
|
||||
}
|
||||
|
||||
void ImageTrimTests::calculateTrimRectBottomRightPixelColor()
|
||||
{
|
||||
// Magenta along the bottom and right four, cyan elsewhere.
|
||||
const QImage image = painted(16, 16, [](int x, int y) { return x >= 12 || y >= 12 ? Colour{255, 0, 255, 255} : Colour{0, 255, 255, 255}; });
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(image, edges(TrimBasedOn::bottomRightPixelColor, true, true, true, true)), std::optional(QRect(0, 0, 12, 12)));
|
||||
// Matches count from a row's ends alone.
|
||||
const QImage holed = painted(8, 3, [](int x, int y) { return y == 1 && (x == 1 || x == 6) ? Colour{9, 9, 9, 255} : Colour{0, 0, 0, 255}; });
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(holed, edges(TrimBasedOn::bottomRightPixelColor, true, true, true, true)), std::optional(QRect(1, 1, 6, 1)));
|
||||
}
|
||||
|
||||
void ImageTrimTests::uniformImageReturnsNil()
|
||||
{
|
||||
const QImage solid = painted(8, 8, [](int, int) { return Colour{100, 150, 200, 255}; });
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(solid, edges(TrimBasedOn::topLeftPixelColor, true, true, true, true)), std::nullopt);
|
||||
const QImage clear = painted(8, 8, [](int, int) { return Colour{0, 0, 0, 0}; });
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(clear, TrimOptions()), std::nullopt);
|
||||
}
|
||||
|
||||
// Swift's trimImage, uncalled in the app: the rect it crops.
|
||||
void ImageTrimTests::trimImageDirectly()
|
||||
{
|
||||
const QImage image = painted(10, 10, [](int x, int y) { return x >= 2 && x < 8 && y >= 2 && y < 8 ? Colour{255, 128, 0, 255} : Colour{0, 0, 0, 0}; });
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(image, TrimOptions()), std::optional(QRect(2, 2, 6, 6)));
|
||||
}
|
||||
|
||||
// Swift's session trim, uncalled: the controller's two steps.
|
||||
void ImageTrimTests::sessionTrimIntegrationAndUndo()
|
||||
{
|
||||
Desk desk;
|
||||
EditorSession &session = desk.session;
|
||||
QCOMPARE(session.activeLayer().value().origin(), QPointF(20, 30));
|
||||
const std::optional<ProjectSnapshot> trimmed = ImageTrim::trim(session.projectSnapshot().value(), TrimOptions());
|
||||
session.applyDocumentSize(trimmed.value(), QStringLiteral("Trim"));
|
||||
QVERIFY(session.document().value().width == 40 && session.document().value().height == 40);
|
||||
QCOMPARE(session.activeLayer().value().origin(), QPointF(0, 0));
|
||||
QCOMPARE(session.history.undoName(), QString("Trim"));
|
||||
session.undo();
|
||||
QVERIFY(session.document().value().width == 100 && session.document().value().height == 100);
|
||||
QCOMPARE(session.activeLayer().value().origin(), QPointF(20, 30));
|
||||
// A canvas with nothing to cut comes back whole.
|
||||
session.createDocument(4, 4);
|
||||
session.insert(ImportedImage(painted(4, 4, [](int, int) { return Colour{1, 2, 3, 255}; }), QImage(), "Full"));
|
||||
// The snapshot comes back whole, effects and all.
|
||||
session.setEffects(LayerEffects{.stroke = StrokeEffect()});
|
||||
const ProjectSnapshot full = session.projectSnapshot().value();
|
||||
QVERIFY(full.manifest.layers[0].effects);
|
||||
QVERIFY(ImageTrim::trim(full, TrimOptions()).value().manifest.encoded() == full.manifest.encoded());
|
||||
// A clear canvas has nothing left.
|
||||
session.createDocument(4, 4);
|
||||
QCOMPARE(ImageTrim::trim(session.projectSnapshot().value(), TrimOptions()).has_value(), false);
|
||||
}
|
||||
|
||||
void ImageTrimTests::toleranceAndAlphaDecideAMatch()
|
||||
{
|
||||
// A frame just off the corner, then a half-clear one.
|
||||
const QImage image = painted(6, 6, [](int x, int y) {
|
||||
if (x == 0 && y == 0)
|
||||
return Colour{10, 10, 10, 255};
|
||||
if (x == 0 || y == 0)
|
||||
return Colour{12, 9, 10, 255};
|
||||
if (x == 1 || y == 1)
|
||||
return Colour{10, 10, 10, 128};
|
||||
return Colour{200, 0, 0, 255};
|
||||
});
|
||||
TrimOptions options = edges(TrimBasedOn::topLeftPixelColor, true, false, true, false);
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(image, options), std::optional(QRect(0, 0, 6, 6)));
|
||||
options.tolerance = 2;
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(image, options), std::optional(QRect(1, 1, 5, 5)));
|
||||
// Alpha is a channel like the others.
|
||||
options.tolerance = 127;
|
||||
QCOMPARE(ImageTrim::calculateTrimRect(image, options), std::optional(QRect(2, 2, 4, 4)));
|
||||
QCOMPARE(rawValue(TrimBasedOn::transparentPixels), QString("Transparent Pixels"));
|
||||
QCOMPARE(rawValue(TrimBasedOn::topLeftPixelColor), QString("Top Left Pixel Color"));
|
||||
QCOMPARE(rawValue(TrimBasedOn::bottomRightPixelColor), QString("Bottom Right Pixel Color"));
|
||||
QVERIFY(TrimOptions() == edges(TrimBasedOn::transparentPixels, true, true, true, true) && TrimOptions().tolerance == 0);
|
||||
}
|
||||
|
||||
void ImageTrimTests::theSheetSaysSwiftsWordsAndReturnsItsChoice()
|
||||
{
|
||||
std::optional<std::optional<TrimOptions>> answer;
|
||||
TrimSheet sheet([&answer](std::optional<TrimOptions> options) { answer = options; });
|
||||
sheet.show();
|
||||
QCOMPARE(sheet.width(), 320);
|
||||
QStringList words;
|
||||
for (const QLabel *label : sheet.findChildren<QLabel *>())
|
||||
words << label->text();
|
||||
QCOMPARE(words, (QStringList{"Trim", "Based On", "Trim Away"}));
|
||||
QCOMPARE(sheet.findChildren<QLabel *>().at(0)->font().pixelSize(), 17);
|
||||
QVERIFY(sheet.findChildren<QLabel *>().at(0)->font().bold() && sheet.findChildren<QLabel *>().at(1)->font().weight() == QFont::DemiBold);
|
||||
// Swift's defaults: transparent pixels, every edge.
|
||||
for (const TrimBasedOn option : allTrimBasedOn) {
|
||||
auto *radio = sheet.findChild<QRadioButton *>(QStringLiteral("trimBasedOn%1").arg(int(option)));
|
||||
QVERIFY(radio && radio->text() == rawValue(option) && radio->isChecked() == (option == TrimBasedOn::transparentPixels));
|
||||
}
|
||||
auto &ok = *sheet.findChild<QPushButton *>("trimOK");
|
||||
QVERIFY(ok.isDefault() && ok.isEnabled() && !sheet.findChild<QPushButton *>("trimCancel")->autoDefault());
|
||||
for (const char *edge : {"trimTop", "trimBottom", "trimLeft", "trimRight"})
|
||||
QVERIFY(sheet.findChild<QCheckBox *>(edge)->isChecked());
|
||||
// OK rests only while every edge is off.
|
||||
for (const char *edge : {"trimTop", "trimBottom", "trimLeft"})
|
||||
sheet.findChild<QCheckBox *>(edge)->click();
|
||||
QVERIFY(ok.isEnabled());
|
||||
sheet.findChild<QCheckBox *>("trimRight")->click();
|
||||
QVERIFY(!ok.isEnabled());
|
||||
sheet.findChild<QCheckBox *>("trimBottom")->click();
|
||||
sheet.findChild<QRadioButton *>("trimBasedOn2")->click();
|
||||
ok.click();
|
||||
QVERIFY(answer.value() == edges(TrimBasedOn::bottomRightPixelColor, false, true, false, false));
|
||||
sheet.findChild<QPushButton *>("trimCancel")->click();
|
||||
QVERIFY(answer && !answer->has_value());
|
||||
}
|
||||
|
||||
void ImageTrimTests::theControllerTrimsThroughTheSheetAsOneStep()
|
||||
{
|
||||
Desk desk;
|
||||
const int steps = desk.session.history.undoCount();
|
||||
desk.controller.trim(desk.finished());
|
||||
QVERIFY(desk.session.isProjectBusy());
|
||||
QCOMPARE(desk.dialog().windowTitle(), QString("Trim"));
|
||||
QVERIFY(desk.dialog().isVisible() && desk.dialog().windowModality() == Qt::WindowModal);
|
||||
QVERIFY(desk.dialog().findChild<TrimSheet *>());
|
||||
// Inside the dialog Cancel still defaults to nothing.
|
||||
QVERIFY(!desk.dialog().findChild<QPushButton *>("trimCancel")->autoDefault());
|
||||
// Return is OK: the canvas shrinks to the square.
|
||||
QTest::keyClick(&desk.dialog(), Qt::Key_Return);
|
||||
QTRY_VERIFY(desk.done);
|
||||
QVERIFY(desk.session.document().value().width == 40 && desk.session.document().value().height == 40);
|
||||
QCOMPARE(desk.session.history.undoCount(), steps + 1);
|
||||
QCOMPARE(desk.session.history.undoName(), QString("Trim"));
|
||||
QVERIFY(!desk.session.isProjectBusy());
|
||||
QTRY_VERIFY(!desk.window.findChild<QDialog *>());
|
||||
}
|
||||
|
||||
void ImageTrimTests::nothingToTrimOrACancelChangesNothing()
|
||||
{
|
||||
Desk desk;
|
||||
const CanvasDocument before = desk.session.document().value();
|
||||
const int steps = desk.session.history.undoCount();
|
||||
desk.controller.trim(desk.finished());
|
||||
QTest::keyClick(&desk.dialog(), Qt::Key_Escape);
|
||||
QTRY_VERIFY(desk.done);
|
||||
QVERIFY(desk.session.document().value() == before && desk.session.history.undoCount() == steps && !desk.session.isProjectBusy());
|
||||
// Everything matches the corner: no alert, no step.
|
||||
DialogDesk alerts;
|
||||
desk.session.createDocument(8, 8);
|
||||
desk.session.insert(ImportedImage(painted(8, 8, [](int, int) { return Colour{5, 5, 5, 255}; }), QImage(), "Flat"));
|
||||
const CanvasDocument flat = desk.session.document().value();
|
||||
const int flatSteps = desk.session.history.undoCount();
|
||||
desk.controller.trim(desk.finished());
|
||||
desk.dialog().findChild<QRadioButton *>("trimBasedOn1")->click();
|
||||
desk.dialog().findChild<QPushButton *>("trimOK")->click();
|
||||
QTRY_VERIFY(desk.done);
|
||||
QVERIFY(desk.session.document().value() == flat && desk.session.history.undoCount() == flatSteps);
|
||||
QVERIFY(alerts.seen.isEmpty() && !desk.session.isProjectBusy());
|
||||
// Busy, or without a window, no sheet opens.
|
||||
desk.session.setIsProjectBusy(true);
|
||||
desk.controller.trim(desk.finished());
|
||||
QTRY_VERIFY(desk.done);
|
||||
QVERIFY(!desk.window.findChild<QDialog *>());
|
||||
desk.session.setIsProjectBusy(false);
|
||||
desk.controller.window = nullptr;
|
||||
desk.controller.trim(desk.finished());
|
||||
QTRY_VERIFY(desk.done);
|
||||
QVERIFY(!desk.session.isProjectBusy());
|
||||
}
|
||||
|
||||
void ImageTrimTests::aTrimThatFailsIsExplained()
|
||||
{
|
||||
Desk desk;
|
||||
// Past the render's hundred megapixels, the trim cannot look.
|
||||
desk.session.createDocument(20'000, 10'000);
|
||||
const CanvasDocument before = desk.session.document().value();
|
||||
DialogDesk alerts;
|
||||
alerts.replies = {"OK"};
|
||||
alerts.note = [&] { return QStringLiteral("busy %1").arg(desk.session.isProjectBusy()); };
|
||||
desk.controller.trim(desk.finished());
|
||||
desk.dialog().findChild<QPushButton *>("trimOK")->click();
|
||||
QTRY_VERIFY(desk.done);
|
||||
QCOMPARE(alerts.seen, (QStringList{QStringLiteral("alert|2|Couldn’t trim image|%1|OK|busy 1")
|
||||
.arg(QString::fromUtf8(ExportError(ExportError::Kind::tooLarge).what()))}));
|
||||
QVERIFY(desk.session.document().value() == before && !desk.session.isProjectBusy());
|
||||
}
|
||||
|
||||
QTEST_MAIN(ImageTrimTests)
|
||||
#include "ImageTrimTests.moc"
|
||||
@@ -0,0 +1,217 @@
|
||||
#include "Document/EditorSession.h"
|
||||
#include "Document/LayerEffects+Renderer.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "IO/ProjectStore+Json.h"
|
||||
#include <QPainter>
|
||||
#include <QtTest>
|
||||
|
||||
// Swift's InnerGlowTests: the glow's model, drawing and export.
|
||||
namespace {
|
||||
// Swift's solidSquare: opaque, one colour.
|
||||
QImage square(int size, const QColor &colour)
|
||||
{
|
||||
QImage image(size, size, QImage::Format_RGBA8888_Premultiplied);
|
||||
image.fill(colour);
|
||||
return image;
|
||||
}
|
||||
|
||||
QColor at(const LayerEffectsRenderer::Rendered &rendered, int x, int y)
|
||||
{
|
||||
const int inset = int(rendered.inset);
|
||||
return rendered.image.pixelColor(inset + x, inset + y);
|
||||
}
|
||||
|
||||
LayerEffects glowing(double size, double red, double green, double blue, double opacity)
|
||||
{
|
||||
LayerEffects effects;
|
||||
effects.innerGlow = InnerGlowEffect{.enabled = true, .size = size, .red = red, .green = green, .blue = blue, .opacity = opacity};
|
||||
return effects;
|
||||
}
|
||||
}
|
||||
|
||||
class InnerGlowTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void innerGlowDefaultsAndValidation();
|
||||
void innerGlowCodableRoundTrip();
|
||||
void innerGlowBackwardCompatibility();
|
||||
void layerEffectsIntegration();
|
||||
void innerGlowRendersInsideSourceWithoutBoundsExpansion();
|
||||
void innerGlowRendersAroundTextGlyphs();
|
||||
void itSitsOverTheOverlayAndUnderTheInnerShadow();
|
||||
void innerGlowPreservedInExport();
|
||||
void theSessionAddsCopiesAndCancelsIt();
|
||||
};
|
||||
|
||||
void InnerGlowTests::innerGlowDefaultsAndValidation()
|
||||
{
|
||||
const InnerGlowEffect glow;
|
||||
QVERIFY(glow.isEnabled() && glow.isValid());
|
||||
QCOMPARE(glow.size, 10.0);
|
||||
QCOMPARE(glow.opacity, 0.75);
|
||||
QVERIFY(glow.color() == (PaletteColor{1, 1, 1}));
|
||||
for (const auto &broken : std::vector<std::function<void(InnerGlowEffect &)>>{
|
||||
[](InnerGlowEffect &e) { e.size = -1; }, [](InnerGlowEffect &e) { e.size = 500.5; }, [](InnerGlowEffect &e) { e.size = qQNaN(); },
|
||||
[](InnerGlowEffect &e) { e.opacity = 1.5; }, [](InnerGlowEffect &e) { e.opacity = -0.1; }, [](InnerGlowEffect &e) { e.red = 2; },
|
||||
[](InnerGlowEffect &e) { e.green = -0.1; }, [](InnerGlowEffect &e) { e.blue = qInf(); }}) {
|
||||
InnerGlowEffect changed = glow;
|
||||
broken(changed);
|
||||
QVERIFY(!changed.isValid());
|
||||
LayerEffects effects;
|
||||
effects.innerGlow = changed;
|
||||
QVERIFY(!effects.isValid());
|
||||
}
|
||||
InnerGlowEffect edge = glow;
|
||||
edge.size = 500;
|
||||
edge.opacity = 1;
|
||||
QVERIFY(edge.isValid());
|
||||
}
|
||||
|
||||
void InnerGlowTests::innerGlowCodableRoundTrip()
|
||||
{
|
||||
const LayerEffects original = glowing(25, 1, 0.5, 0.2, 0.85);
|
||||
const QJsonObject encoded = ManifestJson::encoded(original);
|
||||
const QJsonObject glow = encoded.value("innerGlow").toObject();
|
||||
QCOMPARE(glow.keys(), (QStringList{"blue", "enabled", "green", "opacity", "red", "size"}));
|
||||
QVERIFY(glow.value("size") == 25 && glow.value("opacity") == 0.85 && glow.value("green") == 0.5);
|
||||
QCOMPARE(ManifestJson::effects(encoded), original);
|
||||
// Absent `enabled` stays absent, read as shown.
|
||||
LayerEffects shown = original;
|
||||
shown.innerGlow->enabled = std::nullopt;
|
||||
QVERIFY(!ManifestJson::encoded(shown).value("innerGlow").toObject().contains("enabled"));
|
||||
QCOMPARE(ManifestJson::effects(ManifestJson::encoded(shown)), shown);
|
||||
// A key of the wrong type, or missing, refuses it.
|
||||
for (const char *key : {"size", "red", "green", "blue", "opacity"}) {
|
||||
QJsonObject wrong = encoded, bent = glow;
|
||||
bent.insert(QLatin1String(key), "big");
|
||||
wrong.insert("innerGlow", bent);
|
||||
QVERIFY_THROWS_EXCEPTION(ProjectError, ManifestJson::effects(wrong));
|
||||
bent.remove(QLatin1String(key));
|
||||
wrong.insert("innerGlow", bent);
|
||||
QVERIFY_THROWS_EXCEPTION(ProjectError, ManifestJson::effects(wrong));
|
||||
}
|
||||
}
|
||||
|
||||
void InnerGlowTests::innerGlowBackwardCompatibility()
|
||||
{
|
||||
// Effects written before the glow read without it.
|
||||
const QJsonObject older{{"shadow", QJsonObject{{"angle", 90}, {"distance", 10}, {"blur", 15}, {"red", 0}, {"green", 0}, {"blue", 0}, {"opacity", 0.5}}}};
|
||||
const LayerEffects decoded = ManifestJson::effects(older);
|
||||
QVERIFY(decoded.shadow && !decoded.innerGlow && decoded.isValid());
|
||||
}
|
||||
|
||||
void InnerGlowTests::layerEffectsIntegration()
|
||||
{
|
||||
LayerEffects effects;
|
||||
QVERIFY(effects.isEmpty() && !effects.contains(LayerEffectKind::innerGlow));
|
||||
effects.innerGlow = InnerGlowEffect{.size = 15};
|
||||
QVERIFY(!effects.isEmpty() && effects.contains(LayerEffectKind::innerGlow) && effects.isEnabled(LayerEffectKind::innerGlow));
|
||||
QVERIFY((effects.kinds() == std::vector{LayerEffectKind::innerGlow}));
|
||||
effects.setEnabled(false, LayerEffectKind::innerGlow);
|
||||
QVERIFY(!effects.isEnabled(LayerEffectKind::innerGlow) && !effects.visible().innerGlow);
|
||||
effects.setColor(PaletteColor{1, 0.8, 0}, LayerEffectKind::innerGlow);
|
||||
QVERIFY(effects.color(LayerEffectKind::innerGlow) == (PaletteColor{1, 0.8, 0}));
|
||||
effects.remove(LayerEffectKind::innerGlow);
|
||||
QVERIFY(!effects.contains(LayerEffectKind::innerGlow) && effects.isEmpty());
|
||||
}
|
||||
|
||||
void InnerGlowTests::innerGlowRendersInsideSourceWithoutBoundsExpansion()
|
||||
{
|
||||
const LayerEffects effects = glowing(12, 1, 1, 0, 1);
|
||||
// The margin stays the baseline two: nothing spills out.
|
||||
QCOMPARE(LayerEffectsRenderer::margin(effects), 2.0);
|
||||
const LayerEffectsRenderer::Rendered rendered = LayerEffectsRenderer::render(square(40, Qt::black), std::nullopt, effects);
|
||||
QCOMPARE(rendered.image.pixelColor(0, 0).alpha(), 0);
|
||||
QCOMPARE(at(rendered, -1, 20).alpha(), 0);
|
||||
// Yellow near the edge, black in the deep centre.
|
||||
const QColor edge = at(rendered, 2, 20), centre = at(rendered, 20, 20);
|
||||
QVERIFY(edge.alphaF() > 0.9 && edge.redF() > 0.3 && edge.greenF() > 0.3 && edge.blueF() < 0.05);
|
||||
QVERIFY(centre.redF() < 0.2 && centre.greenF() < 0.2);
|
||||
// It fades inward: the edge brightest.
|
||||
QVERIFY(at(rendered, 0, 20).redF() > at(rendered, 4, 20).redF() && at(rendered, 4, 20).redF() > at(rendered, 10, 20).redF());
|
||||
}
|
||||
|
||||
void InnerGlowTests::innerGlowRendersAroundTextGlyphs()
|
||||
{
|
||||
// Swift's black O: a ring on clear.
|
||||
QImage glyph(72, 72, QImage::Format_RGBA8888_Premultiplied);
|
||||
glyph.fill(Qt::transparent);
|
||||
QPainter painter(&glyph);
|
||||
painter.setRenderHint(QPainter::Antialiasing);
|
||||
painter.setPen(QPen(Qt::black, 12));
|
||||
painter.drawEllipse(QRectF(12, 12, 48, 48));
|
||||
painter.end();
|
||||
const LayerEffectsRenderer::Rendered rendered = LayerEffectsRenderer::render(glyph, std::nullopt, glowing(8, 1, 0, 0, 0.9));
|
||||
QVERIFY(rendered.image.width() >= glyph.width() && rendered.image.height() >= glyph.height());
|
||||
QCOMPARE(rendered.image.pixelColor(0, 0).alpha(), 0);
|
||||
// Swift's search: some pixel glows red; the hole is clear.
|
||||
bool found = false;
|
||||
for (int y = 0; y < glyph.height() && !found; ++y) {
|
||||
for (int x = 0; x < glyph.width() && !found; ++x)
|
||||
found = at(rendered, x, y).alphaF() > 0.5 && at(rendered, x, y).redF() > 0.3;
|
||||
}
|
||||
QVERIFY(found);
|
||||
QCOMPARE(at(rendered, 36, 36).alpha(), 0);
|
||||
}
|
||||
|
||||
void InnerGlowTests::itSitsOverTheOverlayAndUnderTheInnerShadow()
|
||||
{
|
||||
// Metal's order: overlay, inner glow, inner shadow.
|
||||
LayerEffects effects = glowing(12, 0, 1, 0, 1);
|
||||
effects.colorOverlay = ColorOverlayEffect{.red = 0, .green = 0, .blue = 1, .opacity = 1};
|
||||
const LayerEffectsRenderer::Rendered covered = LayerEffectsRenderer::render(square(40, Qt::black), std::nullopt, effects);
|
||||
QVERIFY(at(covered, 0, 20).greenF() > 0.4 && at(covered, 20, 20).blueF() > 0.9);
|
||||
effects.innerShadow = InnerShadowEffect{.angle = 0, .distance = 0, .blur = 0.01, .red = 1, .green = 0, .blue = 0, .opacity = 1};
|
||||
const LayerEffectsRenderer::Rendered shadowed = LayerEffectsRenderer::render(square(40, Qt::black), std::nullopt, effects);
|
||||
QCOMPARE(at(shadowed, 0, 20), at(covered, 0, 20));
|
||||
// A sideways shadow covers the glow on one edge.
|
||||
effects.innerShadow->distance = 4;
|
||||
const int x = effects.innerShadow->offset().width() > 0 ? 0 : 39;
|
||||
const QColor edge = at(LayerEffectsRenderer::render(square(40, Qt::black), std::nullopt, effects), x, 20);
|
||||
QVERIFY(std::abs(effects.innerShadow->offset().width()) > 3.9);
|
||||
QVERIFY(edge.redF() > 0.9 && edge.greenF() < 0.1);
|
||||
}
|
||||
|
||||
void InnerGlowTests::innerGlowPreservedInExport()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(100, 100);
|
||||
session.insert(ImportedImage(square(30, Qt::black), QImage(), QStringLiteral("InnerGlowLayer")), QPointF(35, 35));
|
||||
session.setEffects(glowing(8, 1, 0, 1, 0.9));
|
||||
const QImage exported = ImageExporter::render(session.projectSnapshot().value()).image;
|
||||
// The square spans 20 to 50: magenta at its edge.
|
||||
const QColor edge = exported.pixelColor(21, 35), centre = exported.pixelColor(35, 35);
|
||||
QVERIFY(edge.alphaF() > 0.8 && edge.redF() > 0.2 && edge.blueF() > 0.2 && edge.greenF() < 0.05);
|
||||
QVERIFY(centre.redF() < 0.1 && centre.blueF() < 0.1);
|
||||
QCOMPARE(exported.pixelColor(18, 35).alpha(), 0);
|
||||
}
|
||||
|
||||
void InnerGlowTests::theSessionAddsCopiesAndCancelsIt()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(40, 40);
|
||||
session.insert(ImportedImage(square(20, Qt::black), QImage(), QStringLiteral("One")));
|
||||
const QUuid first = session.activeLayerID().value();
|
||||
session.addEffect(LayerEffectKind::innerGlow);
|
||||
QCOMPARE(session.history.undoName(), QString("Add Inner Glow"));
|
||||
QVERIFY(session.activeEffects().innerGlow == InnerGlowEffect());
|
||||
session.changeEffects([](LayerEffects &effects) { effects.innerGlow->size = 44; });
|
||||
session.finishEffectsEditing(false);
|
||||
QVERIFY(!session.activeEffects().innerGlow);
|
||||
session.addEffect(LayerEffectKind::innerGlow);
|
||||
session.changeEffects([](LayerEffects &effects) { effects.innerGlow->size = 44; });
|
||||
session.finishEffectsEditing(true);
|
||||
// Adding it again keeps the glow it has.
|
||||
session.addEffect(LayerEffectKind::innerGlow);
|
||||
QCOMPARE(session.activeEffects().innerGlow.value().size, 44.0);
|
||||
session.finishEffectsEditing(true);
|
||||
session.insert(ImportedImage(square(20, Qt::black), QImage(), QStringLiteral("Two")));
|
||||
const QUuid second = session.activeLayerID().value();
|
||||
QVERIFY(session.canCopyEffect(LayerEffectKind::innerGlow, first, second));
|
||||
session.copyEffect(LayerEffectKind::innerGlow, first, second);
|
||||
QCOMPARE(session.history.undoName(), QString("Copy Inner Glow"));
|
||||
QCOMPARE(session.activeEffects().innerGlow.value().size, 44.0);
|
||||
}
|
||||
|
||||
QTEST_MAIN(InnerGlowTests)
|
||||
#include "InnerGlowTests.moc"
|
||||
@@ -98,6 +98,8 @@ void LayerAdjustmentTests::kindsKeepSwiftsNamesAndOrder()
|
||||
const std::pair<AdjustmentKind, const char *> kinds[] = {{AdjustmentKind::hsv, "Hue/Saturation"}, {AdjustmentKind::levels, "Levels"},
|
||||
{AdjustmentKind::curves, "Curves"}, {AdjustmentKind::exposure, "Exposure"},
|
||||
{AdjustmentKind::gradientMap, "Gradient Map"}, {AdjustmentKind::grain, "Grain"},
|
||||
{AdjustmentKind::addNoise, "Add Noise"}, {AdjustmentKind::gaussianBlur, "Gaussian Blur"},
|
||||
{AdjustmentKind::motionBlur, "Motion Blur"},
|
||||
{AdjustmentKind::invert, "Invert"}, {AdjustmentKind::blackWhite, "Black & White"},
|
||||
{AdjustmentKind::colorBalance, "Color Balance"}};
|
||||
QCOMPARE(allAdjustmentKinds.size(), std::size(kinds));
|
||||
@@ -112,6 +114,8 @@ void LayerAdjustmentTests::kindsKeepSwiftsNamesAndOrder()
|
||||
QVERIFY(filterKind(AdjustmentKind::curves) == FilterKind::curves && filterKind(AdjustmentKind::exposure) == FilterKind::exposure);
|
||||
QVERIFY(filterKind(AdjustmentKind::gradientMap) == FilterKind::gradientMap && filterKind(AdjustmentKind::grain) == FilterKind::grain);
|
||||
QVERIFY(filterKind(AdjustmentKind::blackWhite) == FilterKind::blackWhite && filterKind(AdjustmentKind::colorBalance) == FilterKind::colorBalance);
|
||||
QVERIFY(filterKind(AdjustmentKind::addNoise) == FilterKind::addNoise && filterKind(AdjustmentKind::gaussianBlur) == FilterKind::gaussianBlur);
|
||||
QVERIFY(filterKind(AdjustmentKind::motionBlur) == FilterKind::motionBlur);
|
||||
QVERIFY(!filterKind(AdjustmentKind::levels) && !filterKind(AdjustmentKind::hsv) && !filterKind(AdjustmentKind::invert));
|
||||
// Every kind but Invert opens an editor.
|
||||
for (const AdjustmentKind kind : allAdjustmentKinds)
|
||||
|
||||
@@ -119,7 +119,7 @@ void LayerAppearanceTests::appearancePersistsThroughSaveResizeAndTransparentExpo
|
||||
const QString path = folder.filePath("Appearance.comp");
|
||||
ProjectStore::save(snapshot, path);
|
||||
const ProjectSnapshot loaded = ProjectStore::load(path);
|
||||
QCOMPARE(loaded.manifest.version, qint64(8));
|
||||
QCOMPARE(loaded.manifest.version, qint64(9));
|
||||
const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 8, .height = 8, .resolution = 72});
|
||||
const ProjectSnapshot canvas = CanvasResizer::resize(resized, {.width = 12, .height = 12});
|
||||
const auto record = std::find_if(canvas.manifest.layers.begin(), canvas.manifest.layers.end(),
|
||||
|
||||
@@ -88,7 +88,8 @@ QImage replayed(const QImage &pixels, const LayerEffects &effects)
|
||||
}
|
||||
const auto on = [](const auto &effect) { return effect && effect->isEnabled() && effect->opacity > 0; };
|
||||
const bool stroke = on(effects.stroke) && effects.stroke->size > 0;
|
||||
std::vector<float> ring(shader.shape.size()), cast(shader.shape.size()), inner(shader.shape.size()), glow(shader.shape.size());
|
||||
std::vector<float> ring(shader.shape.size()), cast(shader.shape.size()), inner(shader.shape.size()), glow(shader.shape.size()),
|
||||
innerGlow(shader.shape.size());
|
||||
if (stroke) {
|
||||
const int reach = std::max(1, int(std::lround(effects.stroke->size)));
|
||||
const std::vector<float> moved = shader.spread(shader.spread(shader.shape, reach, effects.stroke->inside, true), reach, effects.stroke->inside, false);
|
||||
@@ -101,6 +102,12 @@ QImage replayed(const QImage &pixels, const LayerEffects &effects)
|
||||
const bool glowing = on(effects.outerGlow) && effects.outerGlow->size > 0;
|
||||
if (glowing)
|
||||
glow = shader.softened(shader.shape, float(effects.outerGlow->size / 2));
|
||||
const bool glowingInside = on(effects.innerGlow) && effects.innerGlow->size > 0;
|
||||
if (glowingInside) {
|
||||
const std::vector<float> softened = shader.softened(shader.shape, float(effects.innerGlow->size / 2));
|
||||
for (size_t index = 0; index < innerGlow.size(); ++index)
|
||||
innerGlow[index] = std::clamp(shader.shape[index] * (1.f - softened[index]), 0.f, 1.f);
|
||||
}
|
||||
if (on(effects.innerShadow)) {
|
||||
const std::vector<float> moved = shader.softened(shader.shift(shader.shape, float(effects.innerShadow->offset().width()),
|
||||
float(effects.innerShadow->offset().height())),
|
||||
@@ -133,6 +140,8 @@ QImage replayed(const QImage &pixels, const LayerEffects &effects)
|
||||
alpha = sourceAlpha + alpha * (1.f - sourceAlpha);
|
||||
if (on(effects.colorOverlay))
|
||||
blend(effects.colorOverlay->color(), std::clamp(shader.shape[index] * float(effects.colorOverlay->opacity), 0.f, 1.f));
|
||||
if (glowingInside)
|
||||
blend(effects.innerGlow->color(), std::clamp(innerGlow[index] * float(effects.innerGlow->opacity), 0.f, 1.f));
|
||||
if (on(effects.innerShadow))
|
||||
blend(effects.innerShadow->color(), std::clamp(inner[index] * float(effects.innerShadow->opacity), 0.f, 1.f));
|
||||
if (stroke && effects.stroke->inside)
|
||||
@@ -194,7 +203,7 @@ private slots:
|
||||
void LayerEffectsKernelTests::everyEffectMatchesTheShader_data()
|
||||
{
|
||||
QTest::addColumn<LayerEffects>("effects");
|
||||
LayerEffects outside, inside, shadow, overlay, inner, glow, hair, all, wide;
|
||||
LayerEffects outside, inside, shadow, overlay, inner, glow, hair, all, wide, innerGlow, innerHair;
|
||||
outside.stroke = StrokeEffect{.size = 3, .red = 1, .green = 0.5, .blue = 0.25, .opacity = 0.8};
|
||||
inside.stroke = StrokeEffect{.size = 2.5, .red = 0.1, .green = 0.2, .blue = 0.9, .opacity = 1, .inside = true};
|
||||
shadow.shadow = ShadowEffect{.angle = 45, .distance = 3.5, .blur = 4, .red = 0.3, .green = 0.1, .blue = 0.6, .opacity = 0.75};
|
||||
@@ -202,11 +211,14 @@ void LayerEffectsKernelTests::everyEffectMatchesTheShader_data()
|
||||
inner.innerShadow = InnerShadowEffect{.angle = -60, .distance = 2, .blur = 3, .red = 0, .green = 0.4, .blue = 0.2, .opacity = 0.9};
|
||||
glow.outerGlow = OuterGlowEffect{.size = 6, .red = 0.2, .green = 0.9, .blue = 0.4, .opacity = 0.8};
|
||||
hair.outerGlow = OuterGlowEffect{.size = 0.01, .opacity = 1};
|
||||
innerGlow.innerGlow = InnerGlowEffect{.size = 7, .red = 0.8, .green = 0.3, .blue = 0.1, .opacity = 0.7};
|
||||
innerHair.innerGlow = InnerGlowEffect{.size = 0.01, .red = 0, .opacity = 1};
|
||||
all = outside;
|
||||
all.shadow = shadow.shadow;
|
||||
all.colorOverlay = overlay.colorOverlay;
|
||||
all.innerShadow = inner.innerShadow;
|
||||
all.outerGlow = glow.outerGlow;
|
||||
all.innerGlow = innerGlow.innerGlow;
|
||||
all.shadow->blur = 0;
|
||||
wide.stroke = StrokeEffect{.size = 9, .red = 1, .opacity = 1};
|
||||
wide.shadow = ShadowEffect{.angle = 200, .distance = 0.5, .blur = 0.01, .opacity = 1};
|
||||
@@ -217,6 +229,8 @@ void LayerEffectsKernelTests::everyEffectMatchesTheShader_data()
|
||||
QTest::newRow("inner shadow") << inner;
|
||||
QTest::newRow("outer glow") << glow;
|
||||
QTest::newRow("a glow a hair wide") << hair;
|
||||
QTest::newRow("inner glow") << innerGlow;
|
||||
QTest::newRow("an inner glow a hair wide") << innerHair;
|
||||
QTest::newRow("all, a sharp shadow") << all;
|
||||
QTest::newRow("wide stroke, a barely blurred shadow") << wide;
|
||||
}
|
||||
@@ -244,14 +258,17 @@ void LayerEffectsKernelTests::switchedOffOrEmptyEffectsLeaveThePixels()
|
||||
hidden.colorOverlay = ColorOverlayEffect{.enabled = false, .red = 1};
|
||||
hidden.innerShadow = InnerShadowEffect{.enabled = false};
|
||||
hidden.outerGlow = OuterGlowEffect{.enabled = false};
|
||||
LayerEffects dim, flat;
|
||||
hidden.innerGlow = InnerGlowEffect{.enabled = false};
|
||||
LayerEffects dim, flat, dimInside, flatInside;
|
||||
dim.outerGlow = OuterGlowEffect{.opacity = 0};
|
||||
flat.outerGlow = OuterGlowEffect{.size = 0};
|
||||
dimInside.innerGlow = InnerGlowEffect{.opacity = 0};
|
||||
flatInside.innerGlow = InnerGlowEffect{.size = 0};
|
||||
LayerEffects thin;
|
||||
thin.stroke = StrokeEffect{.size = 0, .red = 1};
|
||||
LayerEffects clear;
|
||||
clear.shadow = ShadowEffect{.opacity = 0};
|
||||
for (const LayerEffects &effects : {LayerEffects(), hidden, thin, clear, dim, flat})
|
||||
for (const LayerEffects &effects : {LayerEffects(), hidden, thin, clear, dim, flat, dimInside, flatInside})
|
||||
QCOMPARE(gap(LayerEffectsKernel::render(pixels, effects), pixels), 0);
|
||||
// Stroke sizes round to whole pixels, one at least.
|
||||
LayerEffects fraction, one;
|
||||
|
||||
@@ -20,7 +20,7 @@ void addRows()
|
||||
{
|
||||
QTest::addColumn<LayerEffects>("effects");
|
||||
const LayerEffects all = everyKind();
|
||||
LayerEffects outside, inside, cast, overlay, inner, glow;
|
||||
LayerEffects outside, inside, cast, overlay, inner, glow, innerGlow;
|
||||
outside.stroke = all.stroke;
|
||||
inside.stroke = StrokeEffect{.size = 10, .red = 1, .green = 0, .blue = 1, .opacity = 0.9, .inside = true};
|
||||
cast.shadow = all.shadow;
|
||||
@@ -33,6 +33,8 @@ void addRows()
|
||||
QTest::newRow("overlay") << overlay;
|
||||
QTest::newRow("inner shadow") << inner;
|
||||
QTest::newRow("outer glow") << glow;
|
||||
innerGlow.innerGlow = InnerGlowEffect{.size = 20, .red = 1, .green = 0.5, .blue = 0, .opacity = 0.9};
|
||||
QTest::newRow("inner glow") << innerGlow;
|
||||
QTest::newRow("every kind") << all;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ LayerEffects every()
|
||||
effects.colorOverlay = ColorOverlayEffect{.enabled = true, .red = 0.9, .green = 0.25, .blue = 0.75, .opacity = 0.35};
|
||||
effects.innerShadow = InnerShadowEffect{.angle = -30, .distance = 3, .blur = 2, .red = 0.05, .green = 0.15, .blue = 0.95, .opacity = 0.65};
|
||||
effects.outerGlow = OuterGlowEffect{.size = 33, .red = 0.2, .green = 0.4, .blue = 0.6, .opacity = 0.55};
|
||||
effects.innerGlow = InnerGlowEffect{.enabled = false, .size = 14, .red = 0.3, .green = 0.7, .blue = 0.1, .opacity = 0.45};
|
||||
return effects;
|
||||
}
|
||||
|
||||
@@ -81,7 +82,7 @@ void LayerEffectsTests::kindsKeepSwiftsNamesAndOrder()
|
||||
{
|
||||
const std::pair<LayerEffectKind, const char *> kinds[] = {{LayerEffectKind::stroke, "Stroke"}, {LayerEffectKind::shadow, "Drop Shadow"},
|
||||
{LayerEffectKind::colorOverlay, "Color Overlay"}, {LayerEffectKind::innerShadow, "Inner Shadow"},
|
||||
{LayerEffectKind::outerGlow, "Outer Glow"}};
|
||||
{LayerEffectKind::outerGlow, "Outer Glow"}, {LayerEffectKind::innerGlow, "Inner Glow"}};
|
||||
QCOMPARE(allLayerEffectKinds.size(), std::size(kinds));
|
||||
for (size_t index = 0; index < std::size(kinds); ++index) {
|
||||
QVERIFY(allLayerEffectKinds[index] == kinds[index].first);
|
||||
@@ -91,6 +92,7 @@ void LayerEffectsTests::kindsKeepSwiftsNamesAndOrder()
|
||||
QVERIFY((StrokeEffect() == StrokeEffect{std::nullopt, 4, 0, 0, 0, 1, false}));
|
||||
QVERIFY((ShadowEffect() == ShadowEffect{std::nullopt, 90, 20, 20, 0, 0, 0, 0.5}));
|
||||
QVERIFY((OuterGlowEffect() == OuterGlowEffect{std::nullopt, 20, 1, 1, 1, 0.75}));
|
||||
QVERIFY((InnerGlowEffect() == InnerGlowEffect{std::nullopt, 10, 1, 1, 1, 0.75}));
|
||||
QVERIFY((ColorOverlayEffect() == ColorOverlayEffect{std::nullopt, 0, 0, 0, 1}));
|
||||
QVERIFY((InnerShadowEffect() == InnerShadowEffect{std::nullopt, 90, 10, 10, 0, 0, 0, 0.5}));
|
||||
QCOMPARE(StrokeEffect::maxSize, 500.0);
|
||||
@@ -168,7 +170,7 @@ void LayerEffectsTests::theHelpersReadAndWriteOneKind()
|
||||
for (const LayerEffectKind kind : allLayerEffectKinds) {
|
||||
LayerEffects full = every();
|
||||
full.remove(kind);
|
||||
QVERIFY(!full.contains(kind) && full.kinds().size() == 4);
|
||||
QVERIFY(!full.contains(kind) && full.kinds().size() == 5);
|
||||
full.setColor(PaletteColor{1, 0, 0}, kind);
|
||||
QVERIFY(!full.color(kind));
|
||||
}
|
||||
@@ -193,7 +195,7 @@ void LayerEffectsTests::theHelpersReadAndWriteOneKind()
|
||||
void LayerEffectsTests::theManifestWritesSwiftsKeys()
|
||||
{
|
||||
const QJsonObject encoded = ManifestJson::encoded(every());
|
||||
QCOMPARE(encoded.keys(), (QStringList{"colorOverlay", "innerShadow", "outerGlow", "shadow", "stroke"}));
|
||||
QCOMPARE(encoded.keys(), (QStringList{"colorOverlay", "innerGlow", "innerShadow", "outerGlow", "shadow", "stroke"}));
|
||||
const QJsonObject stroke = encoded.value("stroke").toObject();
|
||||
QCOMPARE(stroke.keys(), (QStringList{"blue", "enabled", "green", "inside", "opacity", "red", "size"}));
|
||||
QVERIFY(stroke.value("enabled") == false && stroke.value("inside") == true && stroke.value("size") == 7);
|
||||
@@ -203,6 +205,8 @@ void LayerEffectsTests::theManifestWritesSwiftsKeys()
|
||||
QCOMPARE(encoded.value("innerShadow").toObject().value("angle"), QJsonValue(-30));
|
||||
QCOMPARE(encoded.value("outerGlow").toObject().keys(), (QStringList{"blue", "green", "opacity", "red", "size"}));
|
||||
QCOMPARE(encoded.value("outerGlow").toObject().value("size"), QJsonValue(33));
|
||||
QCOMPARE(encoded.value("innerGlow").toObject().keys(), (QStringList{"blue", "enabled", "green", "opacity", "red", "size"}));
|
||||
QVERIFY(encoded.value("innerGlow").toObject().value("size") == 14 && encoded.value("innerGlow").toObject().value("enabled") == false);
|
||||
QVERIFY(ManifestJson::effects(encoded) == every());
|
||||
// Absent effects stay out; null reads as absent.
|
||||
LayerEffects one;
|
||||
|
||||
@@ -160,7 +160,7 @@ void LayerMaskSessionTests::projectAndPNGPreserveCoverageAndDisabledState()
|
||||
rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskEnabled = enabled; });
|
||||
ProjectStore::save(session->projectSnapshot().value(), path);
|
||||
const ProjectSnapshot loaded = ProjectStore::load(path);
|
||||
QCOMPARE(loaded.manifest.version, qint64(8));
|
||||
QCOMPARE(loaded.manifest.version, qint64(9));
|
||||
session->installProject(loaded, path);
|
||||
QCOMPARE(session->activeLayer().value().mask.value().isEnabled, enabled);
|
||||
const QImage image = QImage::fromData(ImageExporter::pngData(loaded), "PNG");
|
||||
@@ -240,7 +240,7 @@ void LayerMaskSessionTests::folderMasksSaveResizeAndNeedTheNewFormat()
|
||||
const ProjectSnapshot snapshot = session->projectSnapshot().value();
|
||||
ProjectStore::save(snapshot, path);
|
||||
const ProjectSnapshot loaded = ProjectStore::load(path);
|
||||
QVERIFY(loaded.manifest.version == 8 && loaded.masks.count(folder) == 1);
|
||||
QVERIFY(loaded.manifest.version == 9 && loaded.masks.count(folder) == 1);
|
||||
QCOMPARE(alphas(ImageExporter::render(loaded).image), (QList<int>{255, 0, 128, 255}));
|
||||
const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 4, .height = 4, .resolution = 72, .sampling = LayerSampling::nearest});
|
||||
QCOMPARE(alphas(ImageExporter::render(resized).image),
|
||||
|
||||
@@ -310,10 +310,10 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions_data()
|
||||
QTest::addColumn<std::optional<ProjectError::Kind>>("refused");
|
||||
const auto invalid = std::optional(ProjectError::Kind::invalid), tooLarge = std::optional(ProjectError::Kind::tooLarge);
|
||||
const std::optional<ProjectError::Kind> fine;
|
||||
for (int version = 1; version <= 8; ++version)
|
||||
for (int version = 1; version <= 9; ++version)
|
||||
QTest::addRow("plain at version %d", version) << "" << version << fine;
|
||||
QTest::newRow("version 0") << "" << 0 << std::optional(ProjectError::Kind::version);
|
||||
QTest::newRow("version 9") << "" << 9 << std::optional(ProjectError::Kind::version);
|
||||
QTest::newRow("version 10") << "" << 10 << std::optional(ProjectError::Kind::version);
|
||||
QTest::newRow("another format") << "format" << 7 << invalid;
|
||||
QTest::newRow("another colour space") << "colorSpace" << 7 << invalid;
|
||||
QTest::newRow("resolution 0.5") << "resolution=0.5" << 7 << invalid;
|
||||
|
||||
@@ -122,7 +122,7 @@ void ProjectSessionTests::aSnapshotGivesBackWhatWasInstalled()
|
||||
const ProjectSnapshot again = session.projectSnapshot().value();
|
||||
// The same manifest to the byte; pixels by identity.
|
||||
QCOMPARE(again.manifest.encoded(), snapshot.manifest.encoded());
|
||||
QCOMPARE(again.manifest.version, qint64(8));
|
||||
QCOMPARE(again.manifest.version, qint64(9));
|
||||
QCOMPARE(int(again.images.size()), 2);
|
||||
QCOMPARE(int(again.masks.size()), 2);
|
||||
for (const auto &[id, image] : snapshot.images)
|
||||
|
||||
+11
-9
@@ -161,13 +161,15 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
|
||||
const QString path = root.filePath("Invalid.comp"), metadata = path + "/manifest.json";
|
||||
ProjectStore::save(snapshot, path);
|
||||
const QByteArray good = contents(metadata);
|
||||
// Each edit below replaces this text; it must be there.
|
||||
QVERIFY(good.contains("\"version\": 9"));
|
||||
const auto loading = [&](const QByteArray &manifest) {
|
||||
overwrite(metadata, manifest);
|
||||
return projectError([&] { ProjectStore::load(path); });
|
||||
};
|
||||
// A version out of range is named whatever else fails.
|
||||
const QByteArray future = R"({"format": "com.compositor.project", "version": 42, "layers": "from the future"})";
|
||||
for (const qint64 strange : {qint64(42), qint64(9), qint64(0)}) {
|
||||
for (const qint64 strange : {qint64(42), qint64(10), qint64(0)}) {
|
||||
try {
|
||||
overwrite(metadata, QByteArray(future).replace("42", QByteArray::number(strange)));
|
||||
ProjectStore::load(path);
|
||||
@@ -182,12 +184,12 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
|
||||
QCOMPARE(loading(QByteArray(future).replace("42", "1")), std::optional(ProjectError::Kind::invalid));
|
||||
// Another format is invalid whatever version it claims.
|
||||
QCOMPARE(loading(QByteArray(future).replace("compositor", "example")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 0")), std::optional(ProjectError::Kind::version));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 0")), std::optional(ProjectError::Kind::version));
|
||||
// Swift's Int holds 64 bits, each one: no double between.
|
||||
const qint64 most = std::numeric_limits<qint64>::max(), least = std::numeric_limits<qint64>::min();
|
||||
for (const qint64 wild : {qint64(2147483648), qint64(-2147483648), qint64(-1), qint64(9007199254740993), most, least}) {
|
||||
try {
|
||||
overwrite(metadata, QByteArray(good).replace("\"version\": 8", "\"version\": " + QByteArray::number(wild)));
|
||||
overwrite(metadata, QByteArray(good).replace("\"version\": 9", "\"version\": " + QByteArray::number(wild)));
|
||||
ProjectStore::load(path);
|
||||
QFAIL("a wild version opened");
|
||||
} catch (const ProjectError &error) {
|
||||
@@ -197,19 +199,19 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
|
||||
}
|
||||
}
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"width\": 100", "\"width\": 3000000000")), std::optional(ProjectError::Kind::tooLarge));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
|
||||
// Declared: Qt rounds a token just past Int64's minimum.
|
||||
try {
|
||||
overwrite(metadata, QByteArray(good).replace("\"version\": 8", "\"version\": -9223372036854775809"));
|
||||
overwrite(metadata, QByteArray(good).replace("\"version\": 9", "\"version\": -9223372036854775809"));
|
||||
ProjectStore::load(path);
|
||||
QFAIL("a version below every integer opened");
|
||||
} catch (const ProjectError &error) {
|
||||
QCOMPARE(error.version, std::optional(least));
|
||||
}
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 9", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("com.compositor.project", "com.example.project")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading("not json"), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading("[1, 2]"), std::optional(ProjectError::Kind::invalid));
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "UI/LevelsSheet.h"
|
||||
#include "UI/NewCanvasSheet.h"
|
||||
#include "UI/TransformInspector.h"
|
||||
#include "UI/TrimSheet.h"
|
||||
#include <QShortcut>
|
||||
#include "UI/LassoControls.h"
|
||||
#include "UI/NativeLayerList.h"
|
||||
@@ -226,6 +227,7 @@ void RemappedKeysTests::everySheetBindsSwiftsKeys()
|
||||
const CanvasDocument canvas = session.document().value();
|
||||
QCOMPARE(keys(CanvasSizeSheet(canvas, PaletteColor::black(), PaletteColor::white(), [](std::optional<CanvasSizeOptions>) {})), both);
|
||||
QCOMPARE(keys(ImageSizeSheet(canvas, [](std::optional<ImageSizeOptions>) {})), both);
|
||||
QCOMPARE(keys(TrimSheet([](std::optional<TrimOptions>) {})), both);
|
||||
QCOMPARE(keys(JPEGExportSheet(ExportRaster{red, 72}, [](std::optional<QByteArray>) {})), both);
|
||||
QCOMPARE(keys(ColorPickerSheet(session, [](bool) {})), both);
|
||||
QCOMPARE(keys(NewCanvasSheet(session, [](int, int) {}, [] {})), QSet<QString>{"Ctrl+K"});
|
||||
|
||||
@@ -178,11 +178,15 @@ target_include_directories(FilterModelTests PRIVATE ${OMAPHOTO_KERNELS})
|
||||
compositor_test(FilterSessionTests)
|
||||
compositor_test(FilterFailureTests)
|
||||
compositor_test(LayerAdjustmentTests)
|
||||
compositor_test(BlurNoiseAdjustmentTests)
|
||||
# It checks the noise field against the kernel.
|
||||
target_include_directories(BlurNoiseAdjustmentTests PRIVATE ${OMAPHOTO_KERNELS})
|
||||
compositor_test(AdjustmentLayerTests)
|
||||
compositor_test(AdjustmentEditorTests)
|
||||
compositor_test(AdjustmentPanelTests)
|
||||
compositor_test(LayerEffectsTests)
|
||||
compositor_test(OuterGlowTests)
|
||||
compositor_test(InnerGlowTests)
|
||||
compositor_test(LayerEffectsSessionTests)
|
||||
compositor_test(LayerEffectsKernelTests)
|
||||
compositor_test(EffectsPreviewCacheTests)
|
||||
@@ -256,6 +260,7 @@ compositor_test(CropFailureTests)
|
||||
compositor_test(CanvasSizeSheetTests)
|
||||
compositor_test(ImageSizeSheetTests)
|
||||
compositor_test(ProjectResizeTests)
|
||||
compositor_test(ImageTrimTests)
|
||||
compositor_test(JPEGExportSheetTests)
|
||||
compositor_test(ProjectExportTests)
|
||||
compositor_test(ProjectExportFailureTests)
|
||||
|
||||
Reference in new issue
Block a user