Black & White, Color Balance and Invert adjustments (12.7b)
This commit is contained in:
1 parent
7d47cc367a
commit
4291f4249a
33 files changed
+819
-52
No files matched your search
@@ -122,6 +122,7 @@ Platform adaptations, each forced by a missing Qt analog:
|
||||
- Camera RAW (12.6a, Swift 1.2.1): Core Image's RAW filter has no Linux twin, so `IO/RawImporter` develops through LibRaw (the user's decision), its thread-safe `libraw_r` found by pkg-config; `Dockerfile.dev` installs `libraw-dev`, the PKGBUILD and the flake depend on `libraw`, the distro images carry it. A RAW is any file whose extension names a type inheriting `image/x-dcraw` in the MIME database (`matches`, `isRawType` for a dropped picture, `globs` for the importer's filter), Swift's `UTType.rawImage`. `RawDevelopSettings` keeps Swift's fields, `isAsShot` and `reset`; `RawDevelopRequest` is Swift's tuple. White balance: `planckian` is Kim et al.'s cubic (1667–25000 K), `whitePoint` moves off the locus along its greener uv normal by tint ÷ 3000, `temperatureOf` inverts it by a mired search and golden sections: Adobe's scale and sign (the DNG SDK's `kTintScale`), so a positive tint is a greener white and turns the picture magenta, and D65 reads about +10, as Lightroom's Daylight. Known difference: CIRAWFilter's temperature and tint follow Apple's own model; these follow Adobe's convention on CIE 1960 uv. `asShot` reads the camera's multipliers (`cam_mul`, else dcraw's daylight `pre_mul`) and turns the neutral into xy through LibRaw's `rgb_cam` (a DNG's `cam_xyz` stays zero) with `pre_mul` put back; `develop` works back from the chosen white to camera multipliers (`user_mul`, the second green the first's), reading the camera's `pre_mul`, `rgb_cam` and size from unpack's copy (`rawdata`), since a process overwrites `color` with its own multipliers and a half-size one halves `sizes`; a white the matrix maps to a response at or below zero is refused, logged, where LibRaw would drop the balance in silence; the refusal is `ImageImportError(whiteBalance)`, a kind of Linux's own with words of its own, since `CIRAWFilter` takes every white and real cameras' matrices reach it inside the sliders (the warm and green corner). It takes LibRaw's 16-bit sRGB at a power of one (linear), with neither auto-brightening nor the maximum's adjustment (`adjust_maximum_thr` 0, so a frame near white keeps its level), clipped at white in camera space (`highlight` 0, so a clipped photosite stays neutral: unclipped, the matrix turns it magenta), where LibRaw divides by the smallest multiplier; the twin multiplies the smallest back, so the balance is anchored on green, as Core Image's, where dcraw alone brightens the frame as the temperature moves. Known difference: where a multiplier falls below green's (the slider's far ends), the frame's white falls to that multiplier. It makes the look itself, rows side by side: exposure multiplies linear light by 2^EV, clamped, then Boost mixes sRGB's curve (0) with LibRaw's BT.709 curve (1). Known difference: Apple's Boost curve is undisclosed; at 1 this is LibRaw's default curve. The result is opaque `RGBA8888_Premultiplied` tagged sRGB, oriented by LibRaw. Only three-colour sensors develop; a four-colour one is `unsupported` (dcraw's matrices hold no fourth to invert). A preview past its limit decodes at half size, then fits smoothly. `Queue` is Swift's actor: one develop at a time under a mutex, the unpacked frame kept for the previews of one file, a full develop always fresh, `release` letting go; Swift keys the cache by the limit too, since its filter's scale is fixed when made, where LibRaw halves per develop. LibRaw's refusals are `unreadable`, logged with its message on `omaphoto.io`; a surface that cannot be made is `ExportError(render)`. In the session (`EditorSession+Imports.cpp`) a RAW goes before the Photoshop check: `pixelSize` (unreadable when it fails) and Swift's budget (`tooLarge` past 30,000 a side or the pixels left), then `developRaw` shows the sheet with the camera's settings (the defaults when they cannot be read) and keeps the answer, `finishRawDevelop` clears the request, releases the queue on a worker and answers from the event loop (a resumed task): nothing skips the file, settings develop the whole frame on the import's worker, which catches `ImageImportError` and `ExportError`, into a layer named for the file. `rawDevelop` alone stands for Swift's `rawDevelop` and `showsRawDevelop`, which are set and cleared together; the latter's `didSet` is dead (the sheet shows only while importing, which holds file requests anyway), and so is the test hook `confirmRawDevelop`. `UI/RawDevelopSheet` is Swift's sheet in a window-modal `QDialog` (`rawDevelopSheet`) that `ContentView` shows while the request stands, its close button and Escape cancelling and a sheet the session takes away answering nothing: the title at 17 pixels bold, the 560 by 340 preview on 35% black rounded 6 with a spinner while a develop runs, four rows (a 90-point title as the buddy of a 300-point slider, the value 80 points wide in `PlaceholderText`: Exposure in hundredths, Temperature and Tint whole, Boost in thousandths, printed as Swift's `%.Nf`), Reset (resting while as shot), Cancel and the default Line truncated
|
||||
- Middle-button pan, tool defaults, Auto Select and the icon (12.6b, Swift 1.2.1): `Document/ToolDefaults` twins Swift's enum as a namespace: `boolean` (Swift's `bool`, a C++ keyword) and `set` keep Auto Select, the transform controls, the pixel grid, the layout grid, guides, rulers, Snap, the four Snap To switches and Lock Guides in `QSettings` under Swift's `tool.` keys, read by the session's members as they are made and written by each setter, and by the two places that show guides by themselves (`beginGuideCreation`, `addGuide`), as Swift's `didSet` sees those writes. Swift returns the defaults under XCTest; Qt has no such flag, and most test binaries run outside `QStandardPaths`' test mode, so the twin keeps the defaults until `ToolDefaults::enable()`, which `main` calls first (not under test, as the theme's construction is not). The settings file keeps text, so a stored value reads as `true` or `false`; anything else is ignored with a warning on `omaphoto.app`, where Swift's `as? Bool` is silent. The canvas's middle button (Swift's `otherMouseDown` for button 2) pans from any tool while a document is open, on a drag point of its own (`m_middlePanPoint`), so a left drag under way goes on beside it: a move with both buttons pans, then gives the left its turn (so a Hand drag pans twice), and a right-button tip drag waits for the middle's release. Known difference: macOS sends one drag event a motion, so which button Swift's canvas follows then is AppKit's choice. The press hides the brush circle and shows the closed hand, which outranks every other cursor; the release puts the tool's cursor and circle back. Qt can lose a release: a move without the middle button ends the pan. Unlike Swift's `mouseDown`, the press takes no focus. `transformPressLayer` gains Swift's foreground rule: with Auto Select, a press inside the active layer's box goes to the layer under the pointer when that is painted above it (a background holding every press no longer hides the layer on top); `above` reads both layers through checked lookups, since the pointer's layer and the visible active one are both drawn, where Swift guards each index. Swift's `underPointer != active.id` is left out as dead (`above` refuses a layer against itself); equivalent mutant: `>=` in `above` (only the active layer equals itself, and `selectLayer` of the active layer changes nothing). The reshaped app icon (`45ab8fd`) replaces `packaging/icons`. Tests: `ToolDefaultsTests` (the defaults until enabled, every toggle across sessions and as text, guides shown by a guide, a strange value), `theMiddleButtonPansFromAnyTool` in `CanvasNavigationTests`, Swift's `autoSelectPicksForegroundLayerStackedOnSelectedBackground` and a mask box past its layer in `TransformPressTests`, whose older Auto Select test now expects the layer above.
|
||||
- Photoshop's blend modes (12.7a, Swift 1.2.2): `LayerBlendMode` takes Swift's order, Photoshop's, and `allLayerBlendModes` follows it, so the blend keys step Normal, Darken, Multiply…; the raw values are Swift's (`Linear Dodge (Add)` among them), so the manifest reads them without a new version, as Swift's. `blendGroup` stands for Swift's static `groups`: each group's modes sit together in the enum, so the group is the last first member a mode reaches. `BlendModePicker` draws a separator between groups, as Swift's pop-up does; QComboBox's keys step over them and never make one current, so no preview names one. Hard Light and Exclusion are QPainter's composition modes; the other eight go by hand (`SeparableBlend`, `HslBlend`). The PSD reader takes Swift's new keys (`lbrn`, `lddg`, `hLit`, `vLit`, `lLit`, `pLit`, `hMix`, `smud`, `fsub`, `fdiv`); Dissolve, Darker Color and Lighter Color fall to Normal with the conversion note, and Swift's renamed test uses Dissolve. A fix beyond Swift: Swift's `LiveMaskRenderer` composites a clipping stack's group and an adjustment's result through `cgMode`, which is Normal for the eight (v1.2.2's `prepareStacks`, `drawComposite` and `adjust`, unchanged through 1.3.3), so a base or an adjustment in one of them draws as Normal there; the twin hands every mode to `LayerRenderer::composite`, which blends the eight by hand, as Photoshop composites a clipping group with its base's mode (`ClippingStackTests::aStackBlendsOnceWithItsBasesMode`, `AdjustmentLayerTests::adjustmentBlendAndSoftMaskPreserveCoverage`). Swift's `coreImageFilter` has no twin: `needsSurface` names the modes. Equivalent mutants: `<=` against `<` at one half in Vivid and Pin Light (both branches give the backdrop there). Tests: the formulas in `BlendModeTests`, the menu's groups and keys in `BlendModePickerTests`, the order in `LayerAppearanceTests`, `BlendShortcutTests`, `EditorCanvasTests` and `ProjectManifestTests`, the keys in `PSDRoundTripTests` and `PSDImportTests`, the canvas against the export in `CanvasDisplayTests::photoshopsOwnModesShowAsTheExportDoes`.
|
||||
- Black & White, Color Balance and Invert (12.7b, Swift 1.2.2): `kernels/AdjustPixels.c/.h` are v1.2.2's, with `adjust_black_white` and `adjust_color_balance`, run through `ImageAdjustmentPixels::run` on one thread as Swift's. `BlackWhiteSettings` and `ColorBalanceSettings` keep Swift's fields, defaults and bounds (the `isFinite` terms left out, as the bounds refuse NaN and infinities); `apply` throws `ProjectError(invalid)` past them and Color Balance hands an untouched image back. Swift's `FilterSettings.normalized` does not clamp either, so a number typed past a slider's range fails the preview with that error and a commit sets `brushError` with no step, as in Swift: the filter's two workers catch `ProjectError` beside their other errors, where it would otherwise escape as `QUnhandledException` and abort. `FilterKind` gains both after Grain (`isImageAdjustment`), `AdjustmentKind` Invert, Black & White and Color Balance in Swift's order, with `isEditable` (all but Invert) and `filterKind`; `LayerAdjustment` gains the two optionals with Swift's computed getter and setter pairs, validity and `apply` (Invert through `PixelInvert::run`, whole image, colour only); the manifest's `blackWhiteSettings` and `colorBalanceSettings` are Swift's synthesized `Codable`, every key required, left out while absent. `addAdjustment` opens the editor for editable kinds alone: an Invert layer applies at once. The editors carry both settings through `beginAdjustmentEditing` and `editedAdjustment`. A fix beyond Swift: Swift's Edit Adjustment… stays enabled on an Invert layer, whose editor switch breaks after the original is kept and the transaction opened, so nothing ever closes them and the session stays refused; the twin's entry rests for a kind that is not editable, and the two switches throw a `logic_error` for Invert, which nothing reaches. As Swift's row, an Invert layer says "Adjustment · Double-click to edit", yet a double click on its glyph renames it (`m_editableAdjustment`, Swift's `isEditable` test). The Image menu lists Black & White… and Color Balance… before Exposure…, the Layer menu's New Adjustment Layer entries take an ellipsis when editable, and a menu doubles the ampersand of "Black & White" (a lone one is a mnemonic in Qt), in the kinds' entries and in Undo and Redo's step names alike; object names drop it (`blackWhite`, `newBlackWhiteAdjustment`). `LayerIcons` draws Swift's three new symbols as paths: `circle.righthalf.filled`, `circle.filled.pattern.diagonalline.rectangle` (a rounded frame, diagonal lines, a filled circle) and `scale.3d` (three axes with arrowheads). The filter sheet's two kinds live in `UI/FilterSheet+Adjustments.cpp` (the sheet's file neared 500 lines): the six families and Tint's Hue and Saturation, shown while Tint is on (Swift's `if`, the rows `m_shownWhen` holds with Advanced's), and Color Balance's three headlines (13 pixels, semibold, Swift's `.headline`, as the effects sheet's) over three rows each and Preserve Luminosity; each `flag` is Swift's `Toggle` with its help as the tooltip. `NativeLayerList` stops listening in its destructor, as `ContentView` does: closing a list window hides it first, and a rename's focus-out then commits into a list half destroyed. Tests: Swift's `invertAppliesWithoutAnEditor` in `AdjustmentLayerTests`, its editor test over the editable kinds in `AdjustmentEditorTests` and `FloatingPanelTests`, `ColorAdjustmentTests` (the kernels against Photoshop's defaults and a tint worked in HSL, the tones of Color Balance and Preserve Luminosity, bounds, every manifest key, the sheet), the kinds, keys and menus in `FilterModelTests`, `LayerAdjustmentTests`, `AdjustmentPanelTests`, `CompositorMenusTests` and `SelectMenuTests`. `ProjectExportFailureTests`' two panel limits rose from 2,048 to 2,304 KB: the thousand-layer snapshot grew by the two optionals of every record; a copy still fails under them (both copy mutants caught, in the review's runs).
|
||||
- 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.
|
||||
|
||||
@@ -115,6 +115,7 @@ add_library(compositor_core STATIC
|
||||
src/UI/TransformInspector.cpp
|
||||
src/UI/EffectsSheet.cpp
|
||||
src/UI/FilterSheet.cpp
|
||||
src/UI/FilterSheet+Adjustments.cpp
|
||||
src/UI/FloatingPanel.cpp
|
||||
src/UI/KeyboardShortcuts.cpp
|
||||
src/UI/KeyboardShortcuts+Sheet.cpp
|
||||
|
||||
@@ -340,7 +340,10 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
|
||||
- [x] 12.7a Version 1.2.2, part one: Photoshop's remaining blend modes in its groups.
|
||||
- 12.7a round 1: FAIL, one finding. A clipping stack's base and an adjustment in one of the eight new modes blend by it in the twin, where Swift's `cgMode` draws them as Normal; AGENTS.md had called Swift's cases dead. Named now as a fix beyond Swift and tested on both paths; three wordings taken. A parallel run showed `GradientCanvasTests` reading the edit a turn early; it waits now.
|
||||
- 12.7a round 2: PASS. One note taken: the stack test asserts the exact 136.
|
||||
- [ ] 12.7b Version 1.2.2, part two: Black & White, Color Balance and Invert adjustments.
|
||||
- [x] 12.7b Version 1.2.2, part two: Black & White, Color Balance and Invert adjustments.
|
||||
- 12.7b round 1: FAIL, three findings. A Black & White or Color Balance value past its range aborted the app: the filter's workers now catch `ProjectError`, the sheet shows it and a commit reports it with no step, as Swift; Undo and Redo double a step's ampersand; yellows and cyans are told apart in the kernel test. Headlines semibold, Preserve Luminosity's help asserted.
|
||||
- 12.7b round 2: FAIL, one finding: five Color Balance manifest keys sat at zero in every fixture; nine distinct amounts now, each key's value read.
|
||||
- 12.7b round 3: FAIL, one finding, the third and last round: the sheet's rows were not held to their fields. Fixed after the round: every Black & White and Color Balance slider is set to a value of its own and read back, Hue to 360; the review's three survivors fail now (`mut-12.7b-r3.log`); the item closes on that.
|
||||
- [ ] 12.8a Version 1.2.3, part one: the Camera Raw filter's model and pixel kernels.
|
||||
- [ ] 12.8b Version 1.2.3, part two: the Camera Raw panel beside the canvas, its grading wheels turning the way their hue runs (1.2.9's `96edd5f`).
|
||||
- [ ] 12.8c Version 1.2.3, part three: Inner Glow, Image Trim, blur and noise adjustment layers, the layer context menu without 1.2.5's dropped Layer Effects entry (`5d7adba`).
|
||||
|
||||
@@ -75,15 +75,21 @@ void EditorSession::openAdjustmentEditor(QUuid id, const LayerAdjustment &origin
|
||||
m_hueSaturation.emplace(layer.id, made.asset.value(), std::nullopt, layer.transform);
|
||||
m_hueSaturation->settings = original.resolvedHSV();
|
||||
break;
|
||||
case AdjustmentKind::invert:
|
||||
throw std::logic_error("Invert has no editor to open");
|
||||
case AdjustmentKind::curves:
|
||||
case AdjustmentKind::exposure:
|
||||
case AdjustmentKind::gradientMap:
|
||||
case AdjustmentKind::grain: {
|
||||
case AdjustmentKind::grain:
|
||||
case AdjustmentKind::blackWhite:
|
||||
case AdjustmentKind::colorBalance: {
|
||||
FilterSettings settings;
|
||||
settings.curves = original.curves;
|
||||
settings.exposure = original.exposure();
|
||||
settings.gradientMap = original.gradientMap();
|
||||
settings.grain = original.grain();
|
||||
settings.blackWhite = original.blackWhite();
|
||||
settings.colorBalance = original.colorBalance();
|
||||
m_filterEdit.emplace(filterKind(original.kind).value(), layer, std::nullopt, settings, std::nullopt);
|
||||
break;
|
||||
}
|
||||
@@ -113,6 +119,9 @@ LayerAdjustment EditorSession::editedAdjustment() const
|
||||
case AdjustmentKind::exposure: value.setExposure(m_filterEdit.value().settings.exposure); break;
|
||||
case AdjustmentKind::gradientMap: value.setGradientMap(m_filterEdit.value().settings.gradientMap); break;
|
||||
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::invert: throw std::logic_error("Invert has no editor to read");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -121,6 +121,9 @@ void EditorSession::renderFilterPreview()
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings};
|
||||
} catch (const ExportError &error) {
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings};
|
||||
} catch (const ProjectError &error) {
|
||||
// Settings past their bounds: Black & White, Color Balance.
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings};
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -228,6 +231,8 @@ void EditorSession::commitFilter(std::function<void()> done)
|
||||
return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())};
|
||||
} catch (const ExportError &error) {
|
||||
return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())};
|
||||
} catch (const ProjectError &error) {
|
||||
return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())};
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ QString rawValue(FilterKind kind)
|
||||
case FilterKind::exposure: return QStringLiteral("Exposure");
|
||||
case FilterKind::gradientMap: return QStringLiteral("Gradient Map");
|
||||
case FilterKind::grain: return QStringLiteral("Grain");
|
||||
case FilterKind::blackWhite: return QStringLiteral("Black & White");
|
||||
case FilterKind::colorBalance: return QStringLiteral("Color Balance");
|
||||
}
|
||||
throw std::logic_error("unknown filter kind");
|
||||
}
|
||||
@@ -46,7 +48,8 @@ QString rawValue(BackgroundQuality quality)
|
||||
|
||||
bool isImageAdjustment(FilterKind kind)
|
||||
{
|
||||
return kind == FilterKind::curves || kind == FilterKind::exposure || kind == FilterKind::gradientMap || kind == FilterKind::grain;
|
||||
return kind == FilterKind::curves || kind == FilterKind::exposure || kind == FilterKind::gradientMap || kind == FilterKind::grain
|
||||
|| kind == FilterKind::blackWhite || kind == FilterKind::colorBalance;
|
||||
}
|
||||
|
||||
FilterSettings FilterSettings::normalized() const
|
||||
@@ -113,6 +116,8 @@ QImage PixelFilter::run(const FilterJob &job)
|
||||
case FilterKind::curves: image = settings.curves.apply(job.image); break;
|
||||
case FilterKind::exposure: image = settings.exposure.apply(job.image); break;
|
||||
case FilterKind::gradientMap: image = settings.gradientMap.apply(job.image); break;
|
||||
case FilterKind::blackWhite: image = settings.blackWhite.apply(job.image); break;
|
||||
case FilterKind::colorBalance: image = settings.colorBalance.apply(job.image); break;
|
||||
// Grain sits in layer pixels; the job's seed patterns it.
|
||||
case FilterKind::grain: image = settings.grain.apply(job.image, QPointF(), 1 / job.scale, job.seed); break;
|
||||
case FilterKind::removeBackground: image = SubjectRemoval::run(job.image, settings); break;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
struct ImageLayer;
|
||||
|
||||
// The Filter and Image menus' filters.
|
||||
enum class FilterKind { gaussianBlur, motionBlur, addNoise, lensCorrection, removeBackground, contentAwareFill, curves, exposure, gradientMap, grain };
|
||||
enum class FilterKind { gaussianBlur, motionBlur, addNoise, lensCorrection, removeBackground, contentAwareFill, curves, exposure, gradientMap, grain, blackWhite, colorBalance };
|
||||
// The menu's title, Swift's rawValue.
|
||||
QString rawValue(FilterKind kind);
|
||||
// Made without settings: its preview is its result.
|
||||
@@ -25,7 +25,7 @@ bool isImageAdjustment(FilterKind kind);
|
||||
inline constexpr std::array allFilterKinds{FilterKind::gaussianBlur, FilterKind::motionBlur, FilterKind::addNoise,
|
||||
FilterKind::lensCorrection, FilterKind::removeBackground, FilterKind::contentAwareFill,
|
||||
FilterKind::curves, FilterKind::exposure, FilterKind::gradientMap,
|
||||
FilterKind::grain};
|
||||
FilterKind::grain, FilterKind::blackWhite, FilterKind::colorBalance};
|
||||
|
||||
// Remove Background's mask: the model's alone, or refined.
|
||||
enum class BackgroundQuality { basic, advanced };
|
||||
@@ -50,6 +50,8 @@ struct FilterSettings {
|
||||
ExposureSettings exposure{};
|
||||
GradientMapSettings gradientMap{};
|
||||
GrainSettings grain{};
|
||||
BlackWhiteSettings blackWhite{};
|
||||
ColorBalanceSettings colorBalance{};
|
||||
BackgroundQuality backgroundQuality = BackgroundQuality::basic;
|
||||
// How far, in pixels, the mask is pulled onto edges.
|
||||
double refineEdges = 12;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "IO/ProjectStore.h"
|
||||
#include <QPainter>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
extern "C" {
|
||||
@@ -131,3 +132,57 @@ QImage GrainSettings::apply(const QImage &image, QPointF origin, double unitsPer
|
||||
adjust_grain(pixels, size_t(width), size_t(height), size_t(stride), amount, size, roughness, pattern, origin.x(), origin.y(), unitsPerPixel);
|
||||
});
|
||||
}
|
||||
|
||||
bool BlackWhiteSettings::isValid() const
|
||||
{
|
||||
const std::array weights{reds, yellows, greens, cyans, blues, magentas};
|
||||
return std::all_of(weights.begin(), weights.end(), [](double weight) { return weight >= low && weight <= high; })
|
||||
&& tintHue >= 0 && tintHue <= 360 && tintSaturation >= 0 && tintSaturation <= 100;
|
||||
}
|
||||
|
||||
QImage BlackWhiteSettings::apply(const QImage &image) const
|
||||
{
|
||||
if (!isValid())
|
||||
throw ProjectError(ProjectError::Kind::invalid);
|
||||
// The kernel's order: red, yellow, green, cyan, blue, magenta.
|
||||
const std::array<float, 6> weights{float(reds / 100), float(yellows / 100), float(greens / 100),
|
||||
float(cyans / 100), float(blues / 100), float(magentas / 100)};
|
||||
return ImageAdjustmentPixels::run(image, [&](uchar *pixels, int width, int height, qsizetype stride) {
|
||||
adjust_black_white(pixels, size_t(width), size_t(height), size_t(stride), weights.data(), tint ? 1 : 0, tintHue, tintSaturation / 100);
|
||||
});
|
||||
}
|
||||
|
||||
namespace {
|
||||
std::array<double, 9> amounts(const ColorBalanceSettings &settings)
|
||||
{
|
||||
return {settings.shadowCyanRed, settings.shadowMagentaGreen, settings.shadowYellowBlue, settings.midCyanRed, settings.midMagentaGreen,
|
||||
settings.midYellowBlue, settings.highlightCyanRed, settings.highlightMagentaGreen, settings.highlightYellowBlue};
|
||||
}
|
||||
}
|
||||
|
||||
bool ColorBalanceSettings::isValid() const
|
||||
{
|
||||
const std::array<double, 9> all = amounts(*this);
|
||||
return std::all_of(all.begin(), all.end(), [](double amount) { return amount >= low && amount <= high; });
|
||||
}
|
||||
|
||||
bool ColorBalanceSettings::isIdentity() const
|
||||
{
|
||||
const std::array<double, 9> all = amounts(*this);
|
||||
return std::all_of(all.begin(), all.end(), [](double amount) { return amount == 0; });
|
||||
}
|
||||
|
||||
QImage ColorBalanceSettings::apply(const QImage &image) const
|
||||
{
|
||||
if (!isValid())
|
||||
throw ProjectError(ProjectError::Kind::invalid);
|
||||
if (isIdentity())
|
||||
return image;
|
||||
std::array<float, 9> ranges;
|
||||
const std::array<double, 9> all = amounts(*this);
|
||||
std::transform(all.begin(), all.end(), ranges.begin(), [](double amount) { return float(amount / 100); });
|
||||
return ImageAdjustmentPixels::run(image, [&](uchar *pixels, int width, int height, qsizetype stride) {
|
||||
adjust_color_balance(pixels, size_t(width), size_t(height), size_t(stride), ranges.data(), ranges.data() + 3, ranges.data() + 6,
|
||||
preserveLuminosity ? 1 : 0);
|
||||
});
|
||||
}
|
||||
@@ -74,3 +74,42 @@ struct GrainSettings {
|
||||
QImage apply(const QImage &image, QPointF origin = QPointF(), double unitsPerPixel = 1, std::optional<quint32> seed = std::nullopt) const;
|
||||
friend bool operator==(const GrainSettings &, const GrainSettings &) = default;
|
||||
};
|
||||
|
||||
// Photoshop's Black & White: how bright each colour family turns.
|
||||
struct BlackWhiteSettings {
|
||||
static constexpr double low = -200, high = 300;
|
||||
// Photoshop's defaults, a percent each.
|
||||
double reds = 40;
|
||||
double yellows = 60;
|
||||
double greens = 40;
|
||||
double cyans = 60;
|
||||
double blues = 20;
|
||||
double magentas = 80;
|
||||
// Colours the result, keeping its tones: sepia, cyanotype.
|
||||
bool tint = false;
|
||||
double tintHue = 40;
|
||||
double tintSaturation = 20;
|
||||
bool isValid() const;
|
||||
QImage apply(const QImage &image) const;
|
||||
friend bool operator==(const BlackWhiteSettings &, const BlackWhiteSettings &) = default;
|
||||
};
|
||||
|
||||
// Color Balance: each pair shifted in shadows, midtones and highlights.
|
||||
struct ColorBalanceSettings {
|
||||
static constexpr double low = -100, high = 100;
|
||||
double shadowCyanRed = 0;
|
||||
double shadowMagentaGreen = 0;
|
||||
double shadowYellowBlue = 0;
|
||||
double midCyanRed = 0;
|
||||
double midMagentaGreen = 0;
|
||||
double midYellowBlue = 0;
|
||||
double highlightCyanRed = 0;
|
||||
double highlightMagentaGreen = 0;
|
||||
double highlightYellowBlue = 0;
|
||||
// Puts each pixel's brightness back, so only colour moves.
|
||||
bool preserveLuminosity = true;
|
||||
bool isValid() const;
|
||||
bool isIdentity() const;
|
||||
QImage apply(const QImage &image) const;
|
||||
friend bool operator==(const ColorBalanceSettings &, const ColorBalanceSettings &) = default;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "Document/LayerAdjustment.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include "Document/PixelInvert.h"
|
||||
#include <QRandomGenerator>
|
||||
#include <cmath>
|
||||
|
||||
@@ -12,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::invert: return QStringLiteral("Invert");
|
||||
case AdjustmentKind::blackWhite: return QStringLiteral("Black & White");
|
||||
case AdjustmentKind::colorBalance: return QStringLiteral("Color Balance");
|
||||
}
|
||||
throw std::logic_error("unknown adjustment kind");
|
||||
}
|
||||
@@ -32,12 +36,21 @@ 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::blackWhite: return FilterKind::blackWhite;
|
||||
case AdjustmentKind::colorBalance: return FilterKind::colorBalance;
|
||||
// Panels of their own; Invert has nothing to set.
|
||||
case AdjustmentKind::hsv:
|
||||
case AdjustmentKind::levels: return std::nullopt;
|
||||
case AdjustmentKind::levels:
|
||||
case AdjustmentKind::invert: return std::nullopt;
|
||||
}
|
||||
throw std::logic_error("unknown adjustment kind");
|
||||
}
|
||||
|
||||
bool isEditable(AdjustmentKind kind)
|
||||
{
|
||||
return kind != AdjustmentKind::invert;
|
||||
}
|
||||
|
||||
HueSaturationSettings LayerAdjustment::resolvedHSV() const
|
||||
{
|
||||
return hsvSettings.value_or(HueSaturationSettings(hue, saturation, lightness, colorize));
|
||||
@@ -73,6 +86,26 @@ void LayerAdjustment::setGrain(const GrainSettings &value)
|
||||
grainSettings = value;
|
||||
}
|
||||
|
||||
BlackWhiteSettings LayerAdjustment::blackWhite() const
|
||||
{
|
||||
return blackWhiteSettings.value_or(BlackWhiteSettings());
|
||||
}
|
||||
|
||||
void LayerAdjustment::setBlackWhite(const BlackWhiteSettings &value)
|
||||
{
|
||||
blackWhiteSettings = value;
|
||||
}
|
||||
|
||||
ColorBalanceSettings LayerAdjustment::colorBalance() const
|
||||
{
|
||||
return colorBalanceSettings.value_or(ColorBalanceSettings());
|
||||
}
|
||||
|
||||
void LayerAdjustment::setColorBalance(const ColorBalanceSettings &value)
|
||||
{
|
||||
colorBalanceSettings = value;
|
||||
}
|
||||
|
||||
// Bounds refuse what is no number: no isFinite terms.
|
||||
bool LayerAdjustment::isValid() const
|
||||
{
|
||||
@@ -96,7 +129,8 @@ bool LayerAdjustment::isValid() const
|
||||
if (!(range == range.normalized()))
|
||||
return false;
|
||||
}
|
||||
return curves.isValid() && exposure().isValid() && gradientMap().isValid() && grain().isValid();
|
||||
return curves.isValid() && exposure().isValid() && gradientMap().isValid() && grain().isValid() && blackWhite().isValid()
|
||||
&& colorBalance().isValid();
|
||||
}
|
||||
|
||||
QImage LayerAdjustment::apply(const QImage &image, std::optional<QRectF> region) const
|
||||
@@ -108,6 +142,9 @@ QImage LayerAdjustment::apply(const QImage &image, std::optional<QRectF> region)
|
||||
case AdjustmentKind::curves: return curves.apply(image);
|
||||
case AdjustmentKind::exposure: return exposure().apply(image);
|
||||
case AdjustmentKind::gradientMap: return gradientMap().apply(image);
|
||||
case AdjustmentKind::blackWhite: return blackWhite().apply(image);
|
||||
case AdjustmentKind::colorBalance: return colorBalance().apply(image);
|
||||
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.
|
||||
const QRectF area = region.value_or(QRectF(0, 0, image.width(), image.height()));
|
||||
@@ -143,7 +180,9 @@ void EditorSession::addAdjustment(AdjustmentKind kind)
|
||||
m_collapsedGroupIDs.remove(*layer.parentID);
|
||||
setActiveLayerID(layer.id);
|
||||
endEdit();
|
||||
setAdjustmentEditingID(layer.id);
|
||||
// Invert has nothing to set: it applies, no editor opens.
|
||||
if (isEditable(kind))
|
||||
setAdjustmentEditingID(layer.id);
|
||||
}
|
||||
|
||||
void EditorSession::updateAdjustment(QUuid id, const LayerAdjustment &value)
|
||||
|
||||
@@ -10,15 +10,18 @@
|
||||
#include <optional>
|
||||
|
||||
// An adjustment layer's kind, in the Layer menu's order.
|
||||
enum class AdjustmentKind { hsv, levels, curves, exposure, gradientMap, grain };
|
||||
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::exposure, AdjustmentKind::gradientMap, AdjustmentKind::grain,
|
||||
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.
|
||||
std::optional<AdjustmentKind> adjustmentKind(const QString &text);
|
||||
// The filter panel that edits it; Levels and Hue/Saturation: none.
|
||||
// The filter panel that edits it; Levels, Hue/Saturation, Invert: none.
|
||||
std::optional<FilterKind> filterKind(AdjustmentKind kind);
|
||||
// Every kind but Invert opens an editor.
|
||||
bool isEditable(AdjustmentKind kind);
|
||||
|
||||
// An adjustment layer's settings; its kind reads only its own.
|
||||
struct LayerAdjustment {
|
||||
@@ -35,6 +38,8 @@ struct LayerAdjustment {
|
||||
std::optional<ExposureSettings> exposureSettings = std::nullopt;
|
||||
std::optional<GradientMapSettings> gradientMapSettings = std::nullopt;
|
||||
std::optional<GrainSettings> grainSettings = std::nullopt;
|
||||
std::optional<BlackWhiteSettings> blackWhiteSettings = std::nullopt;
|
||||
std::optional<ColorBalanceSettings> colorBalanceSettings = std::nullopt;
|
||||
HueSaturationSettings resolvedHSV() const;
|
||||
// Swift's computed properties over the optional settings.
|
||||
ExposureSettings exposure() const;
|
||||
@@ -43,6 +48,10 @@ struct LayerAdjustment {
|
||||
void setGradientMap(const GradientMapSettings &value);
|
||||
GrainSettings grain() const;
|
||||
void setGrain(const GrainSettings &value);
|
||||
BlackWhiteSettings blackWhite() const;
|
||||
void setBlackWhite(const BlackWhiteSettings &value);
|
||||
ColorBalanceSettings colorBalance() const;
|
||||
void setColorBalance(const ColorBalanceSettings &value);
|
||||
bool isValid() const;
|
||||
// `region`: the document `image` covers, whole pixels by default.
|
||||
QImage apply(const QImage &image, std::optional<QRectF> region = std::nullopt) const;
|
||||
|
||||
@@ -129,6 +129,25 @@ GrainSettings grain(const QJsonValue &value)
|
||||
.seed = quint32(seed)};
|
||||
}
|
||||
|
||||
BlackWhiteSettings blackWhite(const QJsonValue &value)
|
||||
{
|
||||
const QJsonObject object = value.toObject();
|
||||
return {.reds = number(object.value("reds")), .yellows = number(object.value("yellows")), .greens = number(object.value("greens")),
|
||||
.cyans = number(object.value("cyans")), .blues = number(object.value("blues")), .magentas = number(object.value("magentas")),
|
||||
.tint = boolean(object.value("tint")), .tintHue = number(object.value("tintHue")),
|
||||
.tintSaturation = number(object.value("tintSaturation"))};
|
||||
}
|
||||
|
||||
ColorBalanceSettings colorBalance(const QJsonValue &value)
|
||||
{
|
||||
const QJsonObject object = value.toObject();
|
||||
return {.shadowCyanRed = number(object.value("shadowCyanRed")), .shadowMagentaGreen = number(object.value("shadowMagentaGreen")),
|
||||
.shadowYellowBlue = number(object.value("shadowYellowBlue")), .midCyanRed = number(object.value("midCyanRed")),
|
||||
.midMagentaGreen = number(object.value("midMagentaGreen")), .midYellowBlue = number(object.value("midYellowBlue")),
|
||||
.highlightCyanRed = number(object.value("highlightCyanRed")), .highlightMagentaGreen = number(object.value("highlightMagentaGreen")),
|
||||
.highlightYellowBlue = number(object.value("highlightYellowBlue")), .preserveLuminosity = boolean(object.value("preserveLuminosity"))};
|
||||
}
|
||||
|
||||
QJsonObject encoded(const AdjustmentColor &colour)
|
||||
{
|
||||
return {{"red", colour.red}, {"green", colour.green}, {"blue", colour.blue}};
|
||||
@@ -184,7 +203,9 @@ LayerAdjustment ManifestJson::adjustment(const QJsonValue &value)
|
||||
.curves = curves(object.value("curves")),
|
||||
.exposureSettings = optional(object, "exposureSettings", exposure),
|
||||
.gradientMapSettings = optional(object, "gradientMapSettings", gradientMap),
|
||||
.grainSettings = optional(object, "grainSettings", grain)};
|
||||
.grainSettings = optional(object, "grainSettings", grain),
|
||||
.blackWhiteSettings = optional(object, "blackWhiteSettings", blackWhite),
|
||||
.colorBalanceSettings = optional(object, "colorBalanceSettings", colorBalance)};
|
||||
}
|
||||
|
||||
QJsonObject ManifestJson::encoded(const LayerAdjustment &adjustment)
|
||||
@@ -212,5 +233,20 @@ QJsonObject ManifestJson::encoded(const LayerAdjustment &adjustment)
|
||||
object.insert("grainSettings", QJsonObject{{"amount", grain.amount}, {"size", grain.size}, {"roughness", grain.roughness},
|
||||
{"seed", qint64(grain.seed)}});
|
||||
}
|
||||
if (adjustment.blackWhiteSettings) {
|
||||
const BlackWhiteSettings &gray = *adjustment.blackWhiteSettings;
|
||||
object.insert("blackWhiteSettings", QJsonObject{{"reds", gray.reds}, {"yellows", gray.yellows}, {"greens", gray.greens}, {"cyans", gray.cyans},
|
||||
{"blues", gray.blues}, {"magentas", gray.magentas}, {"tint", gray.tint},
|
||||
{"tintHue", gray.tintHue}, {"tintSaturation", gray.tintSaturation}});
|
||||
}
|
||||
if (adjustment.colorBalanceSettings) {
|
||||
const ColorBalanceSettings &balance = *adjustment.colorBalanceSettings;
|
||||
object.insert("colorBalanceSettings",
|
||||
QJsonObject{{"shadowCyanRed", balance.shadowCyanRed}, {"shadowMagentaGreen", balance.shadowMagentaGreen},
|
||||
{"shadowYellowBlue", balance.shadowYellowBlue}, {"midCyanRed", balance.midCyanRed},
|
||||
{"midMagentaGreen", balance.midMagentaGreen}, {"midYellowBlue", balance.midYellowBlue},
|
||||
{"highlightCyanRed", balance.highlightCyanRed}, {"highlightMagentaGreen", balance.highlightMagentaGreen},
|
||||
{"highlightYellowBlue", balance.highlightYellowBlue}, {"preserveLuminosity", balance.preserveLuminosity}});
|
||||
}
|
||||
return object;
|
||||
}
|
||||
@@ -18,14 +18,14 @@ NativeLayerList *focusedList()
|
||||
// An entry's name: Hue/Saturation is newHueSaturationAdjustment.
|
||||
QString adjustmentName(AdjustmentKind kind)
|
||||
{
|
||||
return QStringLiteral("new%1Adjustment").arg(rawValue(kind).remove(QLatin1Char(' ')).remove(QLatin1Char('/')));
|
||||
return QStringLiteral("new%1Adjustment").arg(rawValue(kind).remove(QLatin1Char(' ')).remove(QLatin1Char('/')).remove(QLatin1Char('&')));
|
||||
}
|
||||
|
||||
// A filter's entry from Swift's title: gaussianBlur.
|
||||
QString filterName(FilterKind kind)
|
||||
{
|
||||
QString name = rawValue(kind);
|
||||
name.remove(QLatin1Char(' '));
|
||||
name.remove(QLatin1Char(' ')).remove(QLatin1Char('&'));
|
||||
name[0] = name[0].toLower();
|
||||
return name;
|
||||
}
|
||||
@@ -203,8 +203,8 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
|
||||
add(image, QStringLiteral("levels"), QStringLiteral("Levels…"), QKeySequence(Qt::CTRL | Qt::Key_L), [this] { session().beginLevels(); });
|
||||
add(image, QStringLiteral("hueSaturation"), QStringLiteral("Hue/Saturation…"), QKeySequence(Qt::CTRL | Qt::Key_U),
|
||||
[this] { session().beginHueSaturation(); });
|
||||
for (const FilterKind kind : {FilterKind::exposure, FilterKind::gradientMap, FilterKind::grain})
|
||||
add(image, filterName(kind), rawValue(kind) + QStringLiteral("…"), QKeySequence(), [this, kind] { session().beginFilter(kind); });
|
||||
for (const FilterKind kind : {FilterKind::blackWhite, FilterKind::colorBalance, FilterKind::exposure, FilterKind::gradientMap, FilterKind::grain})
|
||||
add(image, filterName(kind), rawValue(kind).replace(QLatin1Char('&'), QStringLiteral("&&")) + QStringLiteral("…"), QKeySequence(), [this, kind] { session().beginFilter(kind); });
|
||||
add(image, QStringLiteral("invert"), QStringLiteral("Invert"), QKeySequence(Qt::CTRL | Qt::Key_I), [this] { session().invertPixels(); });
|
||||
image->addSeparator();
|
||||
add(image, QStringLiteral("canvasSize"), QStringLiteral("Canvas Size…"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_C), [this] { projects().canvasSize(); });
|
||||
@@ -223,7 +223,9 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
|
||||
QMenu *adjustments = layer->addMenu(QStringLiteral("New Adjustment Layer"));
|
||||
adjustments->menuAction()->setObjectName(QStringLiteral("newAdjustmentLayer"));
|
||||
for (const AdjustmentKind kind : allAdjustmentKinds)
|
||||
add(adjustments, adjustmentName(kind), rawValue(kind) + QStringLiteral("…"), QKeySequence(), [this, kind] { session().addAdjustment(kind); });
|
||||
// A menu reads a lone ampersand as a mnemonic: doubled.
|
||||
add(adjustments, adjustmentName(kind), rawValue(kind).replace(QLatin1Char('&'), QStringLiteral("&&")) + (isEditable(kind) ? QStringLiteral("…") : QString()), QKeySequence(),
|
||||
[this, kind] { session().addAdjustment(kind); });
|
||||
add(layer, QStringLiteral("editAdjustment"), QStringLiteral("Edit Adjustment…"), QKeySequence(),
|
||||
[this] { session().setAdjustmentEditingID(session().activeLayerID()); });
|
||||
layer->addSeparator();
|
||||
@@ -323,10 +325,12 @@ void CompositorMenus::synchronize()
|
||||
action(QStringLiteral("exportPNG"))->setEnabled(drawn && p.canStart());
|
||||
action(QStringLiteral("exportJPEG"))->setEnabled(drawn && p.canStart());
|
||||
action(QStringLiteral("closeProject"))->setEnabled(p.canStart());
|
||||
// A step's ampersand, doubled: a lone one is a mnemonic.
|
||||
const auto shown = [](QString name) { return name.replace(QLatin1Char('&'), QStringLiteral("&&")); };
|
||||
// In a field, Undo is the field's and bare.
|
||||
action(QStringLiteral("undo"))->setText(!typing && s.canUndo() ? QStringLiteral("Undo %1").arg(s.history.undoName()) : QStringLiteral("Undo"));
|
||||
action(QStringLiteral("undo"))->setText(!typing && s.canUndo() ? QStringLiteral("Undo %1").arg(shown(s.history.undoName())) : QStringLiteral("Undo"));
|
||||
action(QStringLiteral("undo"))->setEnabled(typing || s.canUndo());
|
||||
action(QStringLiteral("redo"))->setText(!typing && s.canRedo() ? QStringLiteral("Redo %1").arg(s.history.redoName()) : QStringLiteral("Redo"));
|
||||
action(QStringLiteral("redo"))->setText(!typing && s.canRedo() ? QStringLiteral("Redo %1").arg(shown(s.history.redoName())) : QStringLiteral("Redo"));
|
||||
action(QStringLiteral("redo"))->setEnabled(typing || s.canRedo());
|
||||
for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"})
|
||||
action(QString::fromLatin1(name))->setEnabled(drawn);
|
||||
@@ -364,7 +368,8 @@ void CompositorMenus::synchronize()
|
||||
action(QStringLiteral("flipCanvasHorizontal"))->setEnabled(s.canEditLayers());
|
||||
action(QStringLiteral("flipCanvasVertical"))->setEnabled(s.canEditLayers());
|
||||
action(QStringLiteral("newAdjustmentLayer"))->setEnabled(s.canEditLayers());
|
||||
action(QStringLiteral("editAdjustment"))->setEnabled(s.canEditLayers() && active && active->adjustment);
|
||||
// Beyond Swift: Invert has no editor, so nothing to open.
|
||||
action(QStringLiteral("editAdjustment"))->setEnabled(s.canEditLayers() && active && active->adjustment && isEditable(active->adjustment->kind));
|
||||
action(QStringLiteral("transformLayer"))->setText(s.canTransformSelection() ? QStringLiteral("Transform Selection") : QStringLiteral("Transform Layer"));
|
||||
action(QStringLiteral("transformLayer"))->setEnabled(s.canTransform() || s.canTransformSelection());
|
||||
action(QStringLiteral("clippingMask"))->setText(active && active->maskSourceID ? QStringLiteral("Release Clipping Mask") : QStringLiteral("Create Clipping Mask"));
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#include "UI/FilterSheet.h"
|
||||
#include <QCheckBox>
|
||||
#include <QLabel>
|
||||
#include <QSlider>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
// Black & White's and Color Balance's rows, as Swift's FilterSheet.
|
||||
void FilterSheet::blackWhite()
|
||||
{
|
||||
// Each slider: how bright that family of colours becomes.
|
||||
const std::vector<std::pair<QString, double BlackWhiteSettings::*>> families{
|
||||
{QStringLiteral("Reds"), &BlackWhiteSettings::reds}, {QStringLiteral("Yellows"), &BlackWhiteSettings::yellows},
|
||||
{QStringLiteral("Greens"), &BlackWhiteSettings::greens}, {QStringLiteral("Cyans"), &BlackWhiteSettings::cyans},
|
||||
{QStringLiteral("Blues"), &BlackWhiteSettings::blues}, {QStringLiteral("Magentas"), &BlackWhiteSettings::magentas}};
|
||||
for (const auto &[title, field] : families)
|
||||
control(title, [field](FilterSettings &settings) -> double & { return settings.blackWhite.*field; }, BlackWhiteSettings::low,
|
||||
BlackWhiteSettings::high, QStringLiteral("%"), 0, false);
|
||||
flag(QStringLiteral("Tint"), QStringLiteral("Color the result while keeping its tones, for a sepia or a cyanotype"),
|
||||
[](FilterSettings &settings) -> bool & { return settings.blackWhite.tint; });
|
||||
control(QStringLiteral("Hue"), [](FilterSettings &settings) -> double & { return settings.blackWhite.tintHue; }, 0, 360, QStringLiteral("°"), 0,
|
||||
false);
|
||||
control(QStringLiteral("Saturation"), [](FilterSettings &settings) -> double & { return settings.blackWhite.tintSaturation; }, 0, 100,
|
||||
QStringLiteral("%"), 0, false);
|
||||
for (size_t index = m_controls.size() - 2; index < m_controls.size(); ++index)
|
||||
m_shownWhen.emplace_back(m_controls[index].slider->parentWidget(), [](const FilterSettings &settings) { return settings.blackWhite.tint; });
|
||||
}
|
||||
|
||||
void FilterSheet::colorBalance()
|
||||
{
|
||||
const std::vector<std::pair<QString, std::array<double ColorBalanceSettings::*, 3>>> ranges{
|
||||
{QStringLiteral("Shadows"),
|
||||
{&ColorBalanceSettings::shadowCyanRed, &ColorBalanceSettings::shadowMagentaGreen, &ColorBalanceSettings::shadowYellowBlue}},
|
||||
{QStringLiteral("Midtones"), {&ColorBalanceSettings::midCyanRed, &ColorBalanceSettings::midMagentaGreen, &ColorBalanceSettings::midYellowBlue}},
|
||||
{QStringLiteral("Highlights"),
|
||||
{&ColorBalanceSettings::highlightCyanRed, &ColorBalanceSettings::highlightMagentaGreen, &ColorBalanceSettings::highlightYellowBlue}}};
|
||||
const std::array<QString, 3> pairs{QStringLiteral("Cyan / Red"), QStringLiteral("Magenta / Green"), QStringLiteral("Yellow / Blue")};
|
||||
for (const auto &[range, fields] : ranges) {
|
||||
headline(range);
|
||||
for (size_t pair = 0; pair < 3; ++pair)
|
||||
control(pairs[pair], [field = fields[pair]](FilterSettings &settings) -> double & { return settings.colorBalance.*field; },
|
||||
ColorBalanceSettings::low, ColorBalanceSettings::high, QString(), 0, false);
|
||||
}
|
||||
flag(QStringLiteral("Preserve Luminosity"), QStringLiteral("Put each pixel's brightness back afterwards, so only the color moves"),
|
||||
[](FilterSettings &settings) -> bool & { return settings.colorBalance.preserveLuminosity; });
|
||||
}
|
||||
|
||||
void FilterSheet::flag(const QString &title, const QString &help, std::function<bool &(FilterSettings &)> key)
|
||||
{
|
||||
auto *box = new QCheckBox(title, this);
|
||||
box->setToolTip(help);
|
||||
connect(box, &QCheckBox::clicked, this, [this, key](bool on) { update([&key, on](FilterSettings &settings) { key(settings) = on; }); });
|
||||
m_column->addWidget(box);
|
||||
m_flags.emplace_back(box, std::move(key));
|
||||
}
|
||||
|
||||
void FilterSheet::headline(const QString &title)
|
||||
{
|
||||
auto *label = new QLabel(title, this);
|
||||
QFont font = label->font();
|
||||
font.setPixelSize(13);
|
||||
font.setWeight(QFont::DemiBold);
|
||||
label->setFont(font);
|
||||
m_column->addWidget(label);
|
||||
}
|
||||
@@ -181,6 +181,8 @@ FilterSheet::FilterSheet(EditorSession &session, QWidget *parent)
|
||||
GrainSettings::roughnessLow, GrainSettings::roughnessHigh, QString(), 0, false);
|
||||
break;
|
||||
case FilterKind::removeBackground: background(); break;
|
||||
case FilterKind::blackWhite: blackWhite(); break;
|
||||
case FilterKind::colorBalance: colorBalance(); break;
|
||||
case FilterKind::contentAwareFill:
|
||||
m_column->addWidget(words(QStringLiteral("Fill the selection using surrounding pixels from this layer."), false, this));
|
||||
break;
|
||||
@@ -356,7 +358,7 @@ void FilterSheet::background()
|
||||
for (qsizetype index = 0; index < helps.size(); ++index) {
|
||||
QWidget *advanced = m_controls[size_t(index)].slider->parentWidget();
|
||||
advanced->setToolTip(helps[index]);
|
||||
m_advanced.push_back(advanced);
|
||||
m_shownWhen.emplace_back(advanced, [](const FilterSettings &settings) { return settings.backgroundQuality == BackgroundQuality::advanced; });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,8 +411,12 @@ void FilterSheet::synchronize()
|
||||
m_monochromatic->setChecked(edit.settings.monochromatic);
|
||||
if (m_quality)
|
||||
m_quality->button(int(edit.settings.backgroundQuality))->setChecked(true);
|
||||
for (QWidget *advanced : m_advanced)
|
||||
advanced->setVisible(edit.settings.backgroundQuality == BackgroundQuality::advanced);
|
||||
for (auto &[box, key] : m_flags) {
|
||||
FilterSettings copy = edit.settings;
|
||||
box->setChecked(key(copy));
|
||||
}
|
||||
for (const auto &[row, shows] : m_shownWhen)
|
||||
row->setVisible(shows(edit.settings));
|
||||
m_preview->setChecked(edit.preview);
|
||||
m_error->setText(edit.previewError.value_or(QString()));
|
||||
m_error->setVisible(edit.previewError.has_value());
|
||||
|
||||
+10
-2
@@ -55,6 +55,13 @@ private:
|
||||
void noise();
|
||||
// Remove Background's words, Quality, and Advanced's three rows.
|
||||
void background();
|
||||
// Black & White's six families and Tint; Color Balance's nine.
|
||||
void blackWhite();
|
||||
void colorBalance();
|
||||
// Swift's Toggle over a flag, with its help.
|
||||
void flag(const QString &title, const QString &help, std::function<bool &(FilterSettings &)> key);
|
||||
// Swift's headline over a group of rows.
|
||||
void headline(const QString &title);
|
||||
// Swift's `settings` and `update`: the defaults once the edit goes.
|
||||
FilterSettings settings() const;
|
||||
void update(const std::function<void(FilterSettings &)> &change);
|
||||
@@ -68,8 +75,9 @@ private:
|
||||
QButtonGroup *m_distribution = nullptr;
|
||||
QCheckBox *m_monochromatic = nullptr;
|
||||
QButtonGroup *m_quality = nullptr;
|
||||
// Shown while the quality is Advanced, as Swift's `if`.
|
||||
std::vector<QWidget *> m_advanced;
|
||||
std::vector<std::pair<QCheckBox *, std::function<bool &(FilterSettings &)>>> m_flags;
|
||||
// Rows under Swift's `if`: Advanced's, Tint's.
|
||||
std::vector<std::pair<QWidget *, std::function<bool(const FilterSettings &)>>> m_shownWhen;
|
||||
QCheckBox *const m_preview;
|
||||
QLabel *const m_error;
|
||||
QLabel *const m_limited;
|
||||
|
||||
@@ -93,6 +93,45 @@ void halfFilledCircle(QPainter &painter)
|
||||
painter.drawEllipse(QPointF(9, 9), 7, 7);
|
||||
}
|
||||
|
||||
// Swift's circle.righthalf.filled: the other half, Invert's.
|
||||
void rightHalfCircle(QPainter &painter)
|
||||
{
|
||||
QPainterPath half;
|
||||
half.moveTo(9, 2);
|
||||
half.arcTo(QRectF(2, 2, 14, 14), 90, -180);
|
||||
half.closeSubpath();
|
||||
painter.fillPath(half, painter.pen().color());
|
||||
painter.drawEllipse(QPointF(9, 9), 7, 7);
|
||||
}
|
||||
|
||||
// Swift's circle.filled.pattern.diagonalline.rectangle: Black & White's.
|
||||
void hatchedCircle(QPainter &painter)
|
||||
{
|
||||
const QRectF frame(2, 4, 14, 10);
|
||||
painter.drawRoundedRect(frame, 2, 2);
|
||||
painter.save();
|
||||
painter.setClipRect(frame);
|
||||
for (double x = -6; x < 16; x += 3)
|
||||
painter.drawLine(QPointF(x, 14), QPointF(x + 10, 4));
|
||||
painter.restore();
|
||||
painter.setBrush(painter.pen().color());
|
||||
painter.drawEllipse(QPointF(9, 9), 3.2, 3.2);
|
||||
}
|
||||
|
||||
// Swift's scale.3d: three axes from one corner, Color Balance's.
|
||||
void axes(QPainter &painter)
|
||||
{
|
||||
const QPointF origin(7, 11);
|
||||
const auto arrow = [&](QPointF tip, QPointF back1, QPointF back2) {
|
||||
painter.drawLine(origin, tip);
|
||||
painter.drawLine(tip, back1);
|
||||
painter.drawLine(tip, back2);
|
||||
};
|
||||
arrow(QPointF(7, 2.5), QPointF(5.2, 4.5), QPointF(8.8, 4.5));
|
||||
arrow(QPointF(16, 11), QPointF(14, 9.2), QPointF(14, 12.8));
|
||||
arrow(QPointF(2.5, 15.5), QPointF(2.6, 12.9), QPointF(5.1, 15.4));
|
||||
}
|
||||
|
||||
// Swift's slider.horizontal.3: three tracks, each with a knob.
|
||||
void sliders(QPainter &painter)
|
||||
{
|
||||
@@ -201,6 +240,9 @@ void LayerIcons::paint(QPainter &painter, LayerIcon icon, QPointF origin, double
|
||||
case LayerIcon::paintPalette: paintPalette(painter); break;
|
||||
case LayerIcon::circleGrid: circleGrid(painter); break;
|
||||
case LayerIcon::sparkles: sparkles(painter); break;
|
||||
case LayerIcon::rightHalfCircle: rightHalfCircle(painter); break;
|
||||
case LayerIcon::hatchedCircle: hatchedCircle(painter); break;
|
||||
case LayerIcon::axes: axes(painter); break;
|
||||
}
|
||||
painter.restore();
|
||||
}
|
||||
@@ -224,6 +266,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::invert: return LayerIcon::rightHalfCircle;
|
||||
case AdjustmentKind::blackWhite: return LayerIcon::hatchedCircle;
|
||||
case AdjustmentKind::colorBalance: return LayerIcon::axes;
|
||||
}
|
||||
throw std::logic_error("unknown adjustment kind");
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
// The layers panel's icons, drawn: SF Symbols have no twin.
|
||||
enum class LayerIcon {
|
||||
eye, eyeSlash, chevronRight, chevronDown, folder, link, newLayer, newFolder, addMask, trash, layers, text,
|
||||
halfFilledCircle, sliders, curvePath, plusMinusCircle, paintPalette, circleGrid, sparkles
|
||||
halfFilledCircle, sliders, curvePath, plusMinusCircle, paintPalette, circleGrid, sparkles, rightHalfCircle, hatchedCircle, axes
|
||||
};
|
||||
|
||||
namespace LayerIcons {
|
||||
|
||||
@@ -93,7 +93,7 @@ LayersPanel::LayersPanel(EditorSession &session, QWidget *parent)
|
||||
m_adjustments = footerButton(QStringLiteral("addAdjustment"), QStringLiteral("New adjustment layer"), QStringLiteral("New adjustment layer"), [] {});
|
||||
auto *kinds = new QMenu(m_adjustments);
|
||||
for (const AdjustmentKind kind : allAdjustmentKinds)
|
||||
kinds->addAction(rawValue(kind), this, [this, kind] { m_session.addAdjustment(kind); });
|
||||
kinds->addAction(rawValue(kind).replace(QLatin1Char('&'), QStringLiteral("&&")), this, [this, kind] { m_session.addAdjustment(kind); });
|
||||
m_adjustments->setMenu(kinds);
|
||||
m_adjustments->setPopupMode(QToolButton::InstantPopup);
|
||||
footer->addWidget(m_adjustments);
|
||||
|
||||
@@ -120,6 +120,7 @@ void LayerCell::configure(const ImageLayer &layer, bool enabled, int depth, bool
|
||||
const QSizeF canvas = session.document() ? session.document()->size() : QSizeF(1, 1);
|
||||
m_editableText = layer.liveText().has_value();
|
||||
m_isAdjustment = layer.adjustment.has_value();
|
||||
m_editableAdjustment = m_isAdjustment && isEditable(layer.adjustment->kind);
|
||||
m_thumbnailSize = layer.isGroup || m_editableText || m_isAdjustment ? QSize(36, 36) : CanvasThumbnail::fittedSize(canvas, 36);
|
||||
const ThumbnailKey key{layer.asset ? std::optional(layer.asset->identity()) : std::nullopt, layer.transform, canvas, ink, m_editableText};
|
||||
if (m_layerID != layer.id || m_thumbnailKey != key) {
|
||||
@@ -306,7 +307,7 @@ void LayerCell::mouseDoubleClickEvent(QMouseEvent *event)
|
||||
session.editActiveText();
|
||||
return;
|
||||
}
|
||||
if (m_isAdjustment) {
|
||||
if (m_editableAdjustment) {
|
||||
session.setAdjustmentEditingID(m_layerID);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,6 +155,12 @@ void NativeLayerList::focusList()
|
||||
setFocus(Qt::MouseFocusReason);
|
||||
}
|
||||
|
||||
// Closing hides it: a rename's focus-out commits, unheard here.
|
||||
NativeLayerList::~NativeLayerList()
|
||||
{
|
||||
disconnect(&m_session, &EditorSession::changed, this, &NativeLayerList::update);
|
||||
}
|
||||
|
||||
// Tab reaches the keys, not the focus chain, as Swift.
|
||||
bool NativeLayerList::event(QEvent *event)
|
||||
{
|
||||
|
||||
@@ -166,6 +166,8 @@ private:
|
||||
bool m_isGroup = false;
|
||||
bool m_editableText = false;
|
||||
bool m_isAdjustment = false;
|
||||
// Swift's isEditable: Invert's row renames instead.
|
||||
bool m_editableAdjustment = false;
|
||||
int m_indent = 0;
|
||||
std::optional<QPoint> m_press;
|
||||
// Alt at the press makes the drag a copy.
|
||||
@@ -203,6 +205,7 @@ class NativeLayerList : public QScrollArea {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit NativeLayerList(EditorSession &session, QWidget *parent = nullptr);
|
||||
~NativeLayerList() override;
|
||||
// A dragged mask carries its layer's id under this type.
|
||||
static const QString maskType;
|
||||
// A row drag names its list, as Swift checks tables.
|
||||
|
||||
@@ -58,8 +58,10 @@ private slots:
|
||||
void AdjustmentEditorTests::sharedEditorsKeepPixelsDynamicAndSupportCancel_data()
|
||||
{
|
||||
QTest::addColumn<AdjustmentKind>("kind");
|
||||
// Invert has no settings, so no editor: held apart.
|
||||
for (const AdjustmentKind kind : allAdjustmentKinds)
|
||||
QTest::newRow(qPrintable(rawValue(kind))) << kind;
|
||||
if (isEditable(kind))
|
||||
QTest::newRow(qPrintable(rawValue(kind))) << kind;
|
||||
}
|
||||
|
||||
void AdjustmentEditorTests::sharedEditorsKeepPixelsDynamicAndSupportCancel()
|
||||
@@ -101,18 +103,25 @@ void AdjustmentEditorTests::sharedEditorsKeepPixelsDynamicAndSupportCancel()
|
||||
}
|
||||
case AdjustmentKind::exposure:
|
||||
case AdjustmentKind::gradientMap:
|
||||
case AdjustmentKind::grain: {
|
||||
case AdjustmentKind::grain:
|
||||
case AdjustmentKind::blackWhite:
|
||||
case AdjustmentKind::colorBalance: {
|
||||
QVERIFY(session.filterEdit().value().kind == filterKind(kind));
|
||||
FilterSettings settings = session.filterEdit().value().settings;
|
||||
if (kind == AdjustmentKind::exposure)
|
||||
settings.exposure.exposure = 1;
|
||||
else if (kind == AdjustmentKind::gradientMap)
|
||||
settings.gradientMap.reversed = true;
|
||||
else if (kind == AdjustmentKind::blackWhite)
|
||||
settings.blackWhite.reds = 100;
|
||||
else if (kind == AdjustmentKind::colorBalance)
|
||||
settings.colorBalance.midCyanRed = 50;
|
||||
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);
|
||||
QVERIFY(live.exposure() == settings.exposure && live.gradientMap() == settings.gradientMap && live.grain() == settings.grain
|
||||
&& live.blackWhite() == settings.blackWhite && live.colorBalance() == settings.colorBalance);
|
||||
QVERIFY(awaited([&](std::function<void()> done) { session.commitFilter(std::move(done)); }));
|
||||
break;
|
||||
}
|
||||
@@ -127,6 +136,8 @@ void AdjustmentEditorTests::sharedEditorsKeepPixelsDynamicAndSupportCancel()
|
||||
QVERIFY(awaited([&](std::function<void()> done) { session.commitHueSaturation(std::move(done)); }));
|
||||
break;
|
||||
}
|
||||
case AdjustmentKind::invert:
|
||||
QFAIL("Invert has no editor");
|
||||
}
|
||||
const LayerAdjustment saved = session.activeLayer().value().adjustment.value();
|
||||
QVERIFY(!session.adjustmentEditingID() && saved != created);
|
||||
@@ -144,10 +155,12 @@ void AdjustmentEditorTests::sharedEditorsKeepPixelsDynamicAndSupportCancel()
|
||||
case AdjustmentKind::curves:
|
||||
case AdjustmentKind::exposure:
|
||||
case AdjustmentKind::gradientMap:
|
||||
case AdjustmentKind::grain: {
|
||||
case AdjustmentKind::grain:
|
||||
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.grain == saved.grain() && reopened.blackWhite == saved.blackWhite() && reopened.colorBalance == saved.colorBalance());
|
||||
session.updateFilter(FilterSettings(), true);
|
||||
session.cancelFilter();
|
||||
break;
|
||||
@@ -157,6 +170,8 @@ void AdjustmentEditorTests::sharedEditorsKeepPixelsDynamicAndSupportCancel()
|
||||
session.updateHueSaturation(HueSaturationSettings(), true);
|
||||
session.cancelHueSaturation();
|
||||
break;
|
||||
case AdjustmentKind::invert:
|
||||
QFAIL("Invert has no editor");
|
||||
}
|
||||
QVERIFY(session.activeLayer().value().adjustment.value() == saved);
|
||||
QVERIFY(!session.adjustmentEditingID());
|
||||
|
||||
@@ -64,6 +64,7 @@ private slots:
|
||||
void theLiveCompositeDrawsAsTheExport();
|
||||
void opacityMixesAndGrainSitsInTheDocument();
|
||||
void everyKindAdjustsThroughItsSettings();
|
||||
void invertAppliesWithoutAnEditor();
|
||||
void aFoldersMaskClipsTheAdjustmentsInside();
|
||||
void anyImageTargetTakesTheAdjustment();
|
||||
};
|
||||
@@ -310,6 +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});
|
||||
for (const LayerAdjustment &adjustment : kinds) {
|
||||
EditorSession s;
|
||||
s.createDocument(2, 2);
|
||||
@@ -320,6 +323,32 @@ void AdjustmentLayerTests::everyKindAdjustsThroughItsSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// Swift's: nothing to set, no editor; the pixels invert.
|
||||
void AdjustmentLayerTests::invertAppliesWithoutAnEditor()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(2, 2);
|
||||
session.insert(image(PaletteColor::white()));
|
||||
session.addAdjustment(AdjustmentKind::invert);
|
||||
QVERIFY(!session.adjustmentEditingID());
|
||||
QCOMPARE(session.history.undoName(), QString("New Invert Adjustment"));
|
||||
const LayerAdjustment adjustment = session.activeLayer().value().adjustment.value();
|
||||
QVERIFY(adjustment.kind == AdjustmentKind::invert && !isEditable(adjustment.kind));
|
||||
QImage source(2, 2, QImage::Format_RGBA8888_Premultiplied);
|
||||
source.fill(QColor::fromRgbF(0.2f, 0.4f, 0.6f));
|
||||
source.setPixelColor(1, 1, QColor(102, 51, 153, 128));
|
||||
const QImage inverted = adjustment.apply(source);
|
||||
for (const QPoint at : {QPoint(0, 0), QPoint(1, 1)}) {
|
||||
const QColor before = source.pixelColor(at), after = inverted.pixelColor(at);
|
||||
QVERIFY(std::abs(before.red() + after.red() - 255) <= 1 && std::abs(before.green() + after.green() - 255) <= 1);
|
||||
QVERIFY(std::abs(before.blue() + after.blue() - 255) <= 1);
|
||||
// Transparency is left alone.
|
||||
QCOMPARE(after.alpha(), before.alpha());
|
||||
}
|
||||
// The export inverts the white beneath to black.
|
||||
QCOMPARE(ImageExporter::render(session.projectSnapshot().value()).image.pixelColor(0, 0), QColor(0, 0, 0));
|
||||
}
|
||||
|
||||
void AdjustmentLayerTests::aFoldersMaskClipsTheAdjustmentsInside()
|
||||
{
|
||||
// White beneath; a folder whose mask hides its left column.
|
||||
|
||||
@@ -65,11 +65,12 @@ void AdjustmentPanelTests::theFooterAddsEachKindAndItsEditorOpens()
|
||||
// Swift's circle.lefthalf.filled, in the footer's ink.
|
||||
const QImage icon = LayerIcons::pixmap(LayerIcon::halfFilledCircle, 16, panel.palette().color(QPalette::PlaceholderText), panel.devicePixelRatio()).toImage();
|
||||
QCOMPARE(button.icon().pixmap(QSize(16, 16), panel.devicePixelRatio()).toImage().convertToFormat(icon.format()), icon);
|
||||
// Swift's Menu: the six kinds by name, no ellipsis.
|
||||
// Swift's Menu: the kinds by name, no ellipsis, & doubled.
|
||||
const QList<QAction *> entries = button.menu()->actions();
|
||||
QCOMPARE(entries.size(), qsizetype(allAdjustmentKinds.size()));
|
||||
for (size_t index = 0; index < allAdjustmentKinds.size(); ++index)
|
||||
QCOMPARE(entries[qsizetype(index)]->text(), rawValue(allAdjustmentKinds[index]));
|
||||
QCOMPARE(entries[qsizetype(index)]->text(), rawValue(allAdjustmentKinds[index]).replace("&", "&&"));
|
||||
QCOMPARE(entries[7]->text(), QString("Black && White"));
|
||||
session.createDocument(4, 4);
|
||||
session.insert(whiteImage(4, 4));
|
||||
QVERIFY(button.isEnabled());
|
||||
@@ -106,7 +107,7 @@ void AdjustmentPanelTests::rowsShowTheirGlyphAndOpenOnADoubleClick()
|
||||
list.show();
|
||||
QVERIFY(QTest::qWaitForWindowActive(&list));
|
||||
// Top first: the last kind added heads the list.
|
||||
QCOMPARE(list.cells().size(), size_t(7));
|
||||
QCOMPARE(list.cells().size(), size_t(10));
|
||||
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);
|
||||
@@ -118,7 +119,9 @@ void AdjustmentPanelTests::rowsShowTheirGlyphAndOpenOnADoubleClick()
|
||||
// Each kind its own glyph, as Swift's symbols.
|
||||
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::gradientMap, LayerIcon::paintPalette}, {AdjustmentKind::grain, LayerIcon::circleGrid},
|
||||
{AdjustmentKind::invert, LayerIcon::rightHalfCircle}, {AdjustmentKind::blackWhite, LayerIcon::hatchedCircle},
|
||||
{AdjustmentKind::colorBalance, LayerIcon::axes}};
|
||||
QList<QImage> glyphs;
|
||||
for (const auto &[kind, symbol] : symbols) {
|
||||
QVERIFY(LayerIcons::symbol(kind) == symbol);
|
||||
@@ -156,6 +159,13 @@ void AdjustmentPanelTests::rowsShowTheirGlyphAndOpenOnADoubleClick()
|
||||
QVERIFY(!session.adjustmentEditingID());
|
||||
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);
|
||||
QTest::mouseDClick(&invert.thumbnail(), Qt::LeftButton);
|
||||
QCOMPARE(session.activeLayerID(), std::optional(ids[6]));
|
||||
QVERIFY(!session.adjustmentEditingID());
|
||||
QCOMPARE(session.renamingLayerID(), std::optional(ids[6]));
|
||||
QCOMPARE(invert.findChild<QLabel *>("layerDimensions")->text(), QString("Adjustment · Double-click to edit"));
|
||||
}
|
||||
|
||||
void AdjustmentPanelTests::theLayerMenuAddsAndEditsAdjustments()
|
||||
@@ -167,7 +177,9 @@ void AdjustmentPanelTests::theLayerMenuAddsAndEditsAdjustments()
|
||||
QVERIFY(!adjustments.isEnabled() && !edit.isEnabled());
|
||||
const std::pair<const char *, const char *> entries[] = {{"newHueSaturationAdjustment", "Hue/Saturation…"}, {"newLevelsAdjustment", "Levels…"},
|
||||
{"newCurvesAdjustment", "Curves…"}, {"newExposureAdjustment", "Exposure…"},
|
||||
{"newGradientMapAdjustment", "Gradient Map…"}, {"newGrainAdjustment", "Grain…"}};
|
||||
{"newGradientMapAdjustment", "Gradient Map…"}, {"newGrainAdjustment", "Grain…"},
|
||||
{"newInvertAdjustment", "Invert"}, {"newBlackWhiteAdjustment", "Black && White…"},
|
||||
{"newColorBalanceAdjustment", "Color Balance…"}};
|
||||
const QList<QAction *> listed = adjustments.menu()->actions();
|
||||
QCOMPARE(listed.size(), qsizetype(std::size(entries)));
|
||||
for (qsizetype index = 0; index < listed.size(); ++index) {
|
||||
@@ -192,6 +204,18 @@ void AdjustmentPanelTests::theLayerMenuAddsAndEditsAdjustments()
|
||||
bar.session().cancelFilter();
|
||||
bar.session().selectLayer(pixels);
|
||||
QVERIFY(adjustments.isEnabled() && !edit.isEnabled());
|
||||
// Invert applies at once; beyond Swift, nothing to edit.
|
||||
bar.action("newInvertAdjustment").trigger();
|
||||
QVERIFY(bar.session().activeLayer().value().adjustment.value().kind == AdjustmentKind::invert);
|
||||
QVERIFY(!bar.session().adjustmentEditingID() && adjustments.isEnabled() && !edit.isEnabled());
|
||||
QCOMPARE(bar.session().history.undoName(), QString("New Invert Adjustment"));
|
||||
// Undo and Redo double a step's ampersand, no mnemonic.
|
||||
bar.action("newBlackWhiteAdjustment").trigger();
|
||||
QTRY_VERIFY(bar.session().filterEdit().has_value());
|
||||
bar.session().cancelFilter();
|
||||
QCOMPARE(bar.action("undo").text(), QString("Undo New Black && White Adjustment"));
|
||||
bar.session().undo();
|
||||
QCOMPARE(bar.action("redo").text(), QString("Redo New Black && White Adjustment"));
|
||||
}
|
||||
|
||||
void AdjustmentPanelTests::theSheetsSayTheyReadWhatLiesBeneath()
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include "IO/ProjectStore+Json.h"
|
||||
#include "UI/FilterSheet.h"
|
||||
#include <QCheckBox>
|
||||
#include <QJsonObject>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QSlider>
|
||||
#include <QtTest>
|
||||
#include <cmath>
|
||||
|
||||
// Black & White, Color Balance: kernels, bounds, keys, sheet.
|
||||
namespace {
|
||||
QImage solid(QColor colour)
|
||||
{
|
||||
QImage image(2, 2, QImage::Format_RGBA8888_Premultiplied);
|
||||
image.fill(colour);
|
||||
return image;
|
||||
}
|
||||
|
||||
QColor first(const QImage &image)
|
||||
{
|
||||
return image.pixelColor(0, 0);
|
||||
}
|
||||
|
||||
bool near(const QColor &actual, const QColor &expected)
|
||||
{
|
||||
return std::abs(actual.red() - expected.red()) <= 1 && std::abs(actual.green() - expected.green()) <= 1
|
||||
&& std::abs(actual.blue() - expected.blue()) <= 1 && actual.alpha() == expected.alpha();
|
||||
}
|
||||
|
||||
// Photoshop's tint: the gray as lightness at a hue, HSL.
|
||||
QColor tinted(double gray, double hue, double saturation)
|
||||
{
|
||||
const double c = (1 - std::abs(2 * gray - 1)) * saturation, h = hue / 60, x = c * (1 - std::abs(std::fmod(h, 2) - 1)), m = gray - c / 2;
|
||||
const std::array<double, 3> rgb = h < 1 ? std::array{c, x, 0.0} : h < 2 ? std::array{x, c, 0.0} : h < 3 ? std::array{0.0, c, x}
|
||||
: h < 4 ? std::array{0.0, x, c} : h < 5 ? std::array{x, 0.0, c} : std::array{c, 0.0, x};
|
||||
return QColor(int(std::lround((rgb[0] + m) * 255)), int(std::lround((rgb[1] + m) * 255)), int(std::lround((rgb[2] + m) * 255)));
|
||||
}
|
||||
|
||||
struct Sheet {
|
||||
EditorSession session;
|
||||
std::unique_ptr<FilterSheet> sheet;
|
||||
explicit Sheet(FilterKind kind)
|
||||
{
|
||||
session.createDocument(4, 4);
|
||||
const QImage image = solid(QColor(120, 160, 200));
|
||||
session.insert(ImportedImage(image, image, QStringLiteral("Colour")));
|
||||
session.beginFilter(kind);
|
||||
sheet = std::make_unique<FilterSheet>(session);
|
||||
}
|
||||
const FilterSettings &settings() const { return session.filterEdit().value().settings; }
|
||||
QCheckBox &box(const QString &title) const
|
||||
{
|
||||
for (QCheckBox *each : sheet->findChildren<QCheckBox *>())
|
||||
if (each->text() == title)
|
||||
return *each;
|
||||
throw std::runtime_error("no such box");
|
||||
}
|
||||
QStringList labels() const
|
||||
{
|
||||
QStringList words;
|
||||
for (const QLabel *each : sheet->findChildren<QLabel *>())
|
||||
words << each->text();
|
||||
return words;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class ColorAdjustmentTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void blackWhiteWeighsEachFamilyOfColours();
|
||||
void blackWhiteTintsTheGrayItMakes();
|
||||
void colorBalanceShiftsEachToneAndCanKeepBrightness();
|
||||
void boundsFollowSwifts();
|
||||
void theManifestKeepsEveryKey();
|
||||
void theSheetShowsBlackAndWhitesFamiliesAndTint();
|
||||
void theSheetShowsColorBalancesThreeRanges();
|
||||
void theImageMenusFiltersChangeTheLayer();
|
||||
void aValuePastItsRangeIsExplained();
|
||||
};
|
||||
|
||||
void ColorAdjustmentTests::blackWhiteWeighsEachFamilyOfColours()
|
||||
{
|
||||
const BlackWhiteSettings defaults;
|
||||
// Photoshop's defaults: red at 40%, yellow 60%, blue 20%.
|
||||
QVERIFY(near(first(defaults.apply(solid(Qt::red))), QColor(102, 102, 102)));
|
||||
QVERIFY(near(first(defaults.apply(solid(Qt::yellow))), QColor(153, 153, 153)));
|
||||
QVERIFY(near(first(defaults.apply(solid(Qt::blue))), QColor(51, 51, 51)));
|
||||
QVERIFY(near(first(defaults.apply(solid(Qt::magenta))), QColor(204, 204, 204)));
|
||||
QVERIFY(near(first(defaults.apply(solid(Qt::white))), QColor(255, 255, 255)));
|
||||
// Gray keeps its level; a colour splits primary, secondary, gray.
|
||||
QVERIFY(near(first(defaults.apply(solid(QColor(90, 90, 90)))), QColor(90, 90, 90)));
|
||||
const double mixed = 0.2 + (0.6 - 0.2) * 0.6 + (1 - 0.6) * 0.4;
|
||||
const int level = int(std::lround(mixed * 255));
|
||||
QVERIFY(near(first(defaults.apply(solid(QColor::fromRgbF(1, 0.6f, 0.2f)))), QColor(level, level, level)));
|
||||
// Premultiplied pixels keep their alpha.
|
||||
QImage half(1, 1, QImage::Format_RGBA8888_Premultiplied);
|
||||
half.setPixel(0, 0, qRgba(128, 0, 0, 128));
|
||||
QCOMPARE(defaults.apply(half).pixel(0, 0), qRgba(51, 51, 51, 128));
|
||||
BlackWhiteSettings bright;
|
||||
bright.reds = 100;
|
||||
QVERIFY(near(first(bright.apply(solid(Qt::red))), QColor(255, 255, 255)));
|
||||
// Yellows and cyans reach the kernel each in its place.
|
||||
const BlackWhiteSettings apart{.yellows = 100, .cyans = 0};
|
||||
QVERIFY(near(first(apart.apply(solid(Qt::yellow))), QColor(255, 255, 255)));
|
||||
QVERIFY(near(first(apart.apply(solid(Qt::cyan))), QColor(0, 0, 0)));
|
||||
}
|
||||
|
||||
void ColorAdjustmentTests::blackWhiteTintsTheGrayItMakes()
|
||||
{
|
||||
BlackWhiteSettings settings;
|
||||
settings.tint = true;
|
||||
settings.tintHue = 30;
|
||||
settings.tintSaturation = 50;
|
||||
const double gray = 0.4;
|
||||
QVERIFY(near(first(settings.apply(solid(Qt::red))), tinted(gray, 30, 0.5)));
|
||||
settings.tintHue = 250;
|
||||
QVERIFY(near(first(settings.apply(solid(Qt::red))), tinted(gray, 250, 0.5)));
|
||||
// No saturation tints nothing.
|
||||
settings.tintSaturation = 0;
|
||||
QVERIFY(near(first(settings.apply(solid(Qt::red))), QColor(102, 102, 102)));
|
||||
}
|
||||
|
||||
void ColorAdjustmentTests::colorBalanceShiftsEachToneAndCanKeepBrightness()
|
||||
{
|
||||
// Untouched, the image is handed back as it is.
|
||||
const QImage gray = solid(QColor(128, 128, 128));
|
||||
QCOMPARE(ColorBalanceSettings().apply(gray).cacheKey(), gray.cacheKey());
|
||||
ColorBalanceSettings settings{.midCyanRed = 60, .preserveLuminosity = false};
|
||||
// A midtone weighs 0.7 in the midtones: red gains 0.42.
|
||||
QVERIFY(near(first(settings.apply(gray)), QColor(235, 128, 128)));
|
||||
// Shadows and highlights leave a midtone alone.
|
||||
QVERIFY(near(first(ColorBalanceSettings{.shadowCyanRed = 60, .preserveLuminosity = false}.apply(gray)), QColor(128, 128, 128)));
|
||||
QVERIFY(near(first(ColorBalanceSettings{.highlightCyanRed = 60, .preserveLuminosity = false}.apply(gray)), QColor(128, 128, 128)));
|
||||
// A shadow takes the shadows' shift.
|
||||
QVERIFY(near(first(ColorBalanceSettings{.shadowYellowBlue = 50, .preserveLuminosity = false}.apply(solid(QColor(20, 20, 20)))),
|
||||
QColor(20, 20, 109)));
|
||||
// Preserve Luminosity scales the result back to its brightness.
|
||||
settings.preserveLuminosity = true;
|
||||
const QColor kept = first(settings.apply(gray));
|
||||
const double before = 128 / 255.0, after = 0.299 * (235 / 255.0) + 0.587 * before + 0.114 * before;
|
||||
QVERIFY(near(kept, QColor(int(std::lround(235 * before / after)), int(std::lround(128 * before / after)), int(std::lround(128 * before / after)))));
|
||||
}
|
||||
|
||||
void ColorAdjustmentTests::boundsFollowSwifts()
|
||||
{
|
||||
QVERIFY((BlackWhiteSettings{.reds = 300, .blues = -200}.isValid()));
|
||||
for (const BlackWhiteSettings &broken : {BlackWhiteSettings{.reds = 300.5}, BlackWhiteSettings{.magentas = -200.5},
|
||||
BlackWhiteSettings{.greens = std::nan("")}, BlackWhiteSettings{.tintHue = 360.5}, BlackWhiteSettings{.tintHue = -0.5},
|
||||
BlackWhiteSettings{.tintSaturation = -1}, BlackWhiteSettings{.tintSaturation = 100.5}}) {
|
||||
QVERIFY(!broken.isValid());
|
||||
QVERIFY_THROWS_EXCEPTION(ProjectError, broken.apply(solid(Qt::red)));
|
||||
}
|
||||
QVERIFY((ColorBalanceSettings{.shadowCyanRed = -100, .highlightYellowBlue = 100}.isValid()));
|
||||
for (const ColorBalanceSettings &broken : {ColorBalanceSettings{.midMagentaGreen = 100.5}, ColorBalanceSettings{.shadowYellowBlue = -100.5},
|
||||
ColorBalanceSettings{.highlightCyanRed = std::nan("")}}) {
|
||||
QVERIFY(!broken.isValid());
|
||||
QVERIFY_THROWS_EXCEPTION(ProjectError, broken.apply(solid(Qt::red)));
|
||||
}
|
||||
// An adjustment layer refuses settings of either past them.
|
||||
LayerAdjustment adjustment{AdjustmentKind::blackWhite};
|
||||
adjustment.setBlackWhite(BlackWhiteSettings{.cyans = 301});
|
||||
QVERIFY(!adjustment.isValid());
|
||||
adjustment = LayerAdjustment{AdjustmentKind::colorBalance};
|
||||
adjustment.setColorBalance(ColorBalanceSettings{.midYellowBlue = -101});
|
||||
QVERIFY(!adjustment.isValid());
|
||||
}
|
||||
|
||||
void ColorAdjustmentTests::theManifestKeepsEveryKey()
|
||||
{
|
||||
LayerAdjustment adjustment{AdjustmentKind::blackWhite};
|
||||
adjustment.setBlackWhite(BlackWhiteSettings{.yellows = 10, .tint = true});
|
||||
// Nine distinct amounts: no two keys can trade places unseen.
|
||||
adjustment.setColorBalance(ColorBalanceSettings{-40, -30, -20, -10, 10, 20, 30, 40, 50, false});
|
||||
const QJsonObject object = ManifestJson::encoded(adjustment);
|
||||
const QJsonObject gray = object.value("blackWhiteSettings").toObject(), balance = object.value("colorBalanceSettings").toObject();
|
||||
QCOMPARE(gray.keys(), (QStringList{"blues", "cyans", "greens", "magentas", "reds", "tint", "tintHue", "tintSaturation", "yellows"}));
|
||||
QCOMPARE(balance.keys(), (QStringList{"highlightCyanRed", "highlightMagentaGreen", "highlightYellowBlue", "midCyanRed", "midMagentaGreen",
|
||||
"midYellowBlue", "preserveLuminosity", "shadowCyanRed", "shadowMagentaGreen", "shadowYellowBlue"}));
|
||||
QVERIFY(ManifestJson::adjustment(object) == adjustment);
|
||||
const std::vector<std::pair<const char *, double>> amounts{
|
||||
{"shadowCyanRed", -40}, {"shadowMagentaGreen", -30}, {"shadowYellowBlue", -20}, {"midCyanRed", -10}, {"midMagentaGreen", 10},
|
||||
{"midYellowBlue", 20}, {"highlightCyanRed", 30}, {"highlightMagentaGreen", 40}, {"highlightYellowBlue", 50}};
|
||||
for (const auto &[key, amount] : amounts)
|
||||
QCOMPARE(balance.value(key).toDouble(), amount);
|
||||
QCOMPARE(balance.value("preserveLuminosity").toBool(), false);
|
||||
// Synthesized decoding needs every key, defaults or not.
|
||||
for (const QString &key : gray.keys()) {
|
||||
QJsonObject missing = object, inner = gray;
|
||||
inner.remove(key);
|
||||
missing.insert("blackWhiteSettings", inner);
|
||||
QVERIFY_THROWS_EXCEPTION(ProjectError, ManifestJson::adjustment(missing));
|
||||
}
|
||||
for (const QString &key : balance.keys()) {
|
||||
QJsonObject missing = object, inner = balance;
|
||||
inner.remove(key);
|
||||
missing.insert("colorBalanceSettings", inner);
|
||||
QVERIFY_THROWS_EXCEPTION(ProjectError, ManifestJson::adjustment(missing));
|
||||
}
|
||||
}
|
||||
|
||||
void ColorAdjustmentTests::theSheetShowsBlackAndWhitesFamiliesAndTint()
|
||||
{
|
||||
Sheet shown(FilterKind::blackWhite);
|
||||
shown.sheet->show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(shown.sheet.get()));
|
||||
for (const char *family : {"Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas"})
|
||||
QVERIFY2(shown.labels().contains(family), family);
|
||||
QSlider &reds = *shown.sheet->findChild<QSlider *>("redsSlider");
|
||||
QCOMPARE(reds.value(), int(std::lround((40.0 + 200) / 500 * 1000)));
|
||||
reds.setValue(1000);
|
||||
QCOMPARE(shown.settings().blackWhite.reds, 300.0);
|
||||
// Tint's rows show while it is on, as Swift's `if`.
|
||||
QSlider &hue = *shown.sheet->findChild<QSlider *>("hueSlider");
|
||||
QVERIFY(hue.parentWidget()->isHidden());
|
||||
QCheckBox &tint = shown.box("Tint");
|
||||
QCOMPARE(tint.toolTip(), QString("Color the result while keeping its tones, for a sepia or a cyanotype"));
|
||||
tint.click();
|
||||
QVERIFY(shown.settings().blackWhite.tint && !hue.parentWidget()->isHidden());
|
||||
QVERIFY(!shown.sheet->findChild<QSlider *>("saturationSlider")->parentWidget()->isHidden());
|
||||
// Every row writes its own field, each set apart.
|
||||
const std::vector<std::pair<const char *, double BlackWhiteSettings::*>> rows{
|
||||
{"redsSlider", &BlackWhiteSettings::reds}, {"yellowsSlider", &BlackWhiteSettings::yellows}, {"greensSlider", &BlackWhiteSettings::greens},
|
||||
{"cyansSlider", &BlackWhiteSettings::cyans}, {"bluesSlider", &BlackWhiteSettings::blues}, {"magentasSlider", &BlackWhiteSettings::magentas},
|
||||
{"hueSlider", &BlackWhiteSettings::tintHue}, {"saturationSlider", &BlackWhiteSettings::tintSaturation}};
|
||||
int position = 100;
|
||||
for (const auto &[name, field] : rows) {
|
||||
shown.sheet->findChild<QSlider *>(name)->setValue(position);
|
||||
position += 100;
|
||||
}
|
||||
const auto at = [](int travel, double low, double high) { return low + (high - low) * travel / 1000; };
|
||||
position = 100;
|
||||
for (size_t row = 0; row < rows.size(); ++row, position += 100) {
|
||||
const bool tone = row >= 6;
|
||||
const double expected = tone ? std::round(at(position, 0, row == 6 ? 360 : 100)) : std::round(at(position, -200, 300));
|
||||
QCOMPARE(shown.settings().blackWhite.*rows[row].second, expected);
|
||||
}
|
||||
// Hue runs to 360 degrees at the slider's end.
|
||||
hue.setValue(1000);
|
||||
QCOMPARE(shown.settings().blackWhite.tintHue, 360.0);
|
||||
tint.click();
|
||||
QVERIFY(!shown.settings().blackWhite.tint && hue.parentWidget()->isHidden());
|
||||
}
|
||||
|
||||
void ColorAdjustmentTests::theSheetShowsColorBalancesThreeRanges()
|
||||
{
|
||||
Sheet shown(FilterKind::colorBalance);
|
||||
const QStringList words = shown.labels();
|
||||
for (const char *range : {"Shadows", "Midtones", "Highlights"})
|
||||
QCOMPARE(words.count(range), 1);
|
||||
// Swift's headline: bold, 13 pixels.
|
||||
for (const QLabel *label : shown.sheet->findChildren<QLabel *>())
|
||||
if (label->text() == "Midtones")
|
||||
QVERIFY(label->font().bold() && label->font().pixelSize() == 13);
|
||||
QCOMPARE(words.count("Cyan / Red"), 3);
|
||||
QCOMPARE(words.count("Magenta / Green"), 3);
|
||||
QCOMPARE(words.count("Yellow / Blue"), 3);
|
||||
QCheckBox &keep = shown.box("Preserve Luminosity");
|
||||
QCOMPARE(keep.toolTip(), QString("Put each pixel's brightness back afterwards, so only the color moves"));
|
||||
QVERIFY(keep.isChecked());
|
||||
keep.click();
|
||||
QVERIFY(!shown.settings().colorBalance.preserveLuminosity);
|
||||
// Each range's three rows, in Shadows, Midtones, Highlights order.
|
||||
const std::array<const char *, 3> pairs{"cyan/RedSlider", "magenta/GreenSlider", "yellow/BlueSlider"};
|
||||
const std::array<std::array<double ColorBalanceSettings::*, 3>, 3> fields{{
|
||||
{&ColorBalanceSettings::shadowCyanRed, &ColorBalanceSettings::midCyanRed, &ColorBalanceSettings::highlightCyanRed},
|
||||
{&ColorBalanceSettings::shadowMagentaGreen, &ColorBalanceSettings::midMagentaGreen, &ColorBalanceSettings::highlightMagentaGreen},
|
||||
{&ColorBalanceSettings::shadowYellowBlue, &ColorBalanceSettings::midYellowBlue, &ColorBalanceSettings::highlightYellowBlue}}};
|
||||
for (size_t pair = 0; pair < 3; ++pair) {
|
||||
const QList<QSlider *> rows = shown.sheet->findChildren<QSlider *>(pairs[pair]);
|
||||
QCOMPARE(rows.size(), 3);
|
||||
for (size_t range = 0; range < 3; ++range)
|
||||
rows[qsizetype(range)]->setValue(int(100 + 100 * (pair * 3 + range)));
|
||||
}
|
||||
for (size_t pair = 0; pair < 3; ++pair)
|
||||
for (size_t range = 0; range < 3; ++range)
|
||||
QCOMPARE(shown.settings().colorBalance.*fields[pair][range], -100 + 200 * (100 + 100 * double(pair * 3 + range)) / 1000);
|
||||
}
|
||||
|
||||
// From the Image menu: one step that changes the pixels.
|
||||
void ColorAdjustmentTests::theImageMenusFiltersChangeTheLayer()
|
||||
{
|
||||
for (const FilterKind kind : {FilterKind::blackWhite, FilterKind::colorBalance}) {
|
||||
Sheet shown(kind);
|
||||
FilterSettings settings = shown.settings();
|
||||
settings.colorBalance.midCyanRed = 100;
|
||||
shown.session.updateFilter(settings, true);
|
||||
bool done = false;
|
||||
shown.session.commitFilter([&done] { done = true; });
|
||||
QTRY_VERIFY(done);
|
||||
QCOMPARE(shown.session.history.undoName(), rawValue(kind));
|
||||
const QColor made = shown.session.activeLayer().value().asset.value().image().pixelColor(1, 1);
|
||||
const QColor expected = first(kind == FilterKind::blackWhite ? BlackWhiteSettings().apply(solid(QColor(120, 160, 200)))
|
||||
: settings.colorBalance.apply(solid(QColor(120, 160, 200))));
|
||||
QCOMPARE(made, expected);
|
||||
QVERIFY(made != QColor(120, 160, 200));
|
||||
}
|
||||
}
|
||||
|
||||
// Swift reports the refusal; its preview and commit never crash.
|
||||
void ColorAdjustmentTests::aValuePastItsRangeIsExplained()
|
||||
{
|
||||
const QString invalid = ProjectError(ProjectError::Kind::invalid).what();
|
||||
for (const FilterKind kind : {FilterKind::blackWhite, FilterKind::colorBalance}) {
|
||||
Sheet shown(kind);
|
||||
const ImageLayer before = shown.session.activeLayer().value();
|
||||
const int steps = shown.session.history.undoCount();
|
||||
FilterSettings settings = shown.settings();
|
||||
settings.blackWhite.reds = 301;
|
||||
settings.colorBalance.midCyanRed = 101;
|
||||
shown.session.updateFilter(settings, true);
|
||||
QTRY_VERIFY(!shown.session.filterEdit().value().preparing);
|
||||
QCOMPARE(shown.session.filterEdit().value().previewError, std::optional(invalid));
|
||||
shown.session.updateFilter(settings, false);
|
||||
bool done = false;
|
||||
shown.session.commitFilter([&done] { done = true; });
|
||||
QTRY_VERIFY(done);
|
||||
QCOMPARE(shown.session.brushError(), std::optional(invalid));
|
||||
QVERIFY(!shown.session.filterEdit());
|
||||
QCOMPARE(shown.session.history.undoCount(), steps);
|
||||
QVERIFY(shown.session.activeLayer().value().asset.value().identity() == before.asset.value().identity());
|
||||
}
|
||||
// Typed into the sheet's field, as a person would.
|
||||
Sheet typed(FilterKind::blackWhite);
|
||||
QLineEdit &field = *typed.sheet->findChild<QLineEdit *>("redsField");
|
||||
field.setFocus();
|
||||
field.selectAll();
|
||||
QTest::keyClicks(&field, "301");
|
||||
QTest::keyClick(&field, Qt::Key_Return);
|
||||
QTRY_COMPARE(typed.session.filterEdit().value().previewError, std::optional(invalid));
|
||||
}
|
||||
|
||||
QTEST_MAIN(ColorAdjustmentTests)
|
||||
#include "ColorAdjustmentTests.moc"
|
||||
@@ -35,11 +35,12 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
|
||||
{"moveOutOfFolder", ""}, {"newBlankLayer", "Ctrl+Shift+N"}, {"renameLayer", ""}, {"layerVisibility", ""}, {"moveLayerUp", "Ctrl+]"}, {"moveLayerDown", "Ctrl+["},
|
||||
{"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"}, {"exposure", ""}, {"gradientMap", ""}, {"grain", ""}, {"invert", "Ctrl+I"},
|
||||
{"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", ""},
|
||||
{"gaussianBlur", ""}, {"motionBlur", ""}, {"addNoise", ""}, {"lensCorrection", ""}, {"removeBackground", ""}, {"newAdjustmentLayer", ""}, {"editAdjustment", ""},
|
||||
{"newHueSaturationAdjustment", ""}, {"newLevelsAdjustment", ""}, {"newCurvesAdjustment", ""}, {"newExposureAdjustment", ""},
|
||||
{"newGradientMapAdjustment", ""}, {"newGrainAdjustment", ""},
|
||||
{"newGradientMapAdjustment", ""}, {"newGrainAdjustment", ""}, {"newInvertAdjustment", ""}, {"newBlackWhiteAdjustment", ""},
|
||||
{"newColorBalanceAdjustment", ""},
|
||||
};
|
||||
for (const auto &[name, key] : keys)
|
||||
QCOMPARE(bar.action(name).shortcut().toString(), QString::fromLatin1(key));
|
||||
@@ -53,7 +54,12 @@ 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(), 13);
|
||||
QCOMPARE(menus[4]->actions().size(), 15);
|
||||
// Swift's Image menu: Black & White and Color Balance first.
|
||||
QStringList adjustments;
|
||||
for (int index = 3; index < 8; ++index)
|
||||
adjustments << menus[4]->actions()[index]->objectName();
|
||||
QCOMPARE(adjustments, (QStringList{"blackWhite", "colorBalance", "exposure", "gradientMap", "grain"}));
|
||||
QCOMPARE(menus[5]->actions().size(), 5);
|
||||
QCOMPARE(menus[6]->actions().size(), 22);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,8 @@ void FilterModelTests::kindsKeepSwiftsNames()
|
||||
{FilterKind::gaussianBlur, "Gaussian Blur"}, {FilterKind::motionBlur, "Motion Blur"}, {FilterKind::addNoise, "Add Noise"},
|
||||
{FilterKind::lensCorrection, "Lens Correction"}, {FilterKind::removeBackground, "Remove Background"},
|
||||
{FilterKind::contentAwareFill, "Content-Aware Fill"}, {FilterKind::curves, "Curves"}, {FilterKind::exposure, "Exposure"},
|
||||
{FilterKind::gradientMap, "Gradient Map"}, {FilterKind::grain, "Grain"}};
|
||||
{FilterKind::gradientMap, "Gradient Map"}, {FilterKind::grain, "Grain"}, {FilterKind::blackWhite, "Black & White"},
|
||||
{FilterKind::colorBalance, "Color Balance"}};
|
||||
// Swift's allCases is this order, the menus'.
|
||||
QCOMPARE(allFilterKinds.size(), std::size(kinds));
|
||||
for (size_t index = 0; index < std::size(kinds); ++index) {
|
||||
|
||||
@@ -101,8 +101,10 @@ void FloatingPanelTests::adjustmentPanelSurvivesALayoutPass()
|
||||
void FloatingPanelTests::adjustmentEditorsUseMovableNonmodalPanels_data()
|
||||
{
|
||||
QTest::addColumn<AdjustmentKind>("kind");
|
||||
// Invert has no settings, so no editor, as Swift's.
|
||||
for (const AdjustmentKind kind : allAdjustmentKinds)
|
||||
QTest::newRow(qPrintable(rawValue(kind))) << kind;
|
||||
if (isEditable(kind))
|
||||
QTest::newRow(qPrintable(rawValue(kind))) << kind;
|
||||
}
|
||||
|
||||
void FloatingPanelTests::adjustmentEditorsUseMovableNonmodalPanels()
|
||||
|
||||
@@ -28,6 +28,8 @@ LayerAdjustment full()
|
||||
adjustment.setExposure(ExposureSettings{.exposure = 1, .offset = 0.1, .gamma = 2});
|
||||
adjustment.setGradientMap(GradientMapSettings{{1, 0, 0}, {0, 0, 1}, true});
|
||||
adjustment.setGrain(GrainSettings{.amount = 40, .size = 3, .roughness = 10, .seed = 9});
|
||||
adjustment.setBlackWhite(BlackWhiteSettings{.reds = -50, .magentas = 250, .tint = true, .tintHue = 200, .tintSaturation = 35});
|
||||
adjustment.setColorBalance(ColorBalanceSettings{.shadowYellowBlue = -30, .midCyanRed = 20, .highlightMagentaGreen = 90, .preserveLuminosity = false});
|
||||
return adjustment;
|
||||
}
|
||||
|
||||
@@ -95,7 +97,9 @@ 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::gradientMap, "Gradient Map"}, {AdjustmentKind::grain, "Grain"},
|
||||
{AdjustmentKind::invert, "Invert"}, {AdjustmentKind::blackWhite, "Black & White"},
|
||||
{AdjustmentKind::colorBalance, "Color Balance"}};
|
||||
QCOMPARE(allAdjustmentKinds.size(), std::size(kinds));
|
||||
for (size_t index = 0; index < std::size(kinds); ++index) {
|
||||
const auto &[kind, name] = kinds[index];
|
||||
@@ -107,7 +111,11 @@ void LayerAdjustmentTests::kindsKeepSwiftsNamesAndOrder()
|
||||
// The filter panel edits four; Levels and Hue/Saturation have theirs.
|
||||
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::levels) && !filterKind(AdjustmentKind::hsv));
|
||||
QVERIFY(filterKind(AdjustmentKind::blackWhite) == FilterKind::blackWhite && filterKind(AdjustmentKind::colorBalance) == FilterKind::colorBalance);
|
||||
QVERIFY(!filterKind(AdjustmentKind::levels) && !filterKind(AdjustmentKind::hsv) && !filterKind(AdjustmentKind::invert));
|
||||
// Every kind but Invert opens an editor.
|
||||
for (const AdjustmentKind kind : allAdjustmentKinds)
|
||||
QCOMPARE(isEditable(kind), kind != AdjustmentKind::invert);
|
||||
}
|
||||
|
||||
void LayerAdjustmentTests::validityFollowsSwiftsBounds()
|
||||
@@ -166,8 +174,8 @@ void LayerAdjustmentTests::theOptionalSettingsFallBackToTheirDefaults()
|
||||
void LayerAdjustmentTests::theManifestWritesSwiftsKeys()
|
||||
{
|
||||
const QJsonObject object = ManifestJson::encoded(full());
|
||||
QCOMPARE(object.keys(), (QStringList{"colorize", "curves", "exposureSettings", "gradientMapSettings", "grainSettings", "hsvSettings", "hue", "kind",
|
||||
"levels", "lightness", "saturation"}));
|
||||
QCOMPARE(object.keys(), (QStringList{"blackWhiteSettings", "colorBalanceSettings", "colorize", "curves", "exposureSettings", "gradientMapSettings",
|
||||
"grainSettings", "hsvSettings", "hue", "kind", "levels", "lightness", "saturation"}));
|
||||
QCOMPARE(object.value("kind").toString(), QString("Hue/Saturation"));
|
||||
const QJsonObject hsv = object.value("hsvSettings").toObject();
|
||||
QCOMPARE(hsv.keys(), (QStringList{"adjustments", "bands", "colorize", "invertRange", "range"}));
|
||||
@@ -216,7 +224,7 @@ void LayerAdjustmentTests::decodingRefusesWhatSwiftRefuses()
|
||||
// Synthesized decoding needs every key but the optionals.
|
||||
for (const char *key : {"kind", "hue", "saturation", "lightness", "colorize", "levels", "curves"})
|
||||
QVERIFY2(refused(without(key)), key);
|
||||
for (const char *key : {"hsvSettings", "exposureSettings", "gradientMapSettings", "grainSettings"}) {
|
||||
for (const char *key : {"hsvSettings", "exposureSettings", "gradientMapSettings", "grainSettings", "blackWhiteSettings", "colorBalanceSettings"}) {
|
||||
QVERIFY2(!refused(without(key)) && !refused(with(key, QJsonValue::Null)), key);
|
||||
QVERIFY2(refused(with(key, QJsonArray())), key);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ void ProjectExportFailureTests::thePanelOpensWithoutCopyingTheSnapshot()
|
||||
desk.alerts.replies = {"<cancel>"};
|
||||
{
|
||||
malloc_trim(0);
|
||||
const AddressSpaceLimit limit(2048ll * 1024);
|
||||
const AddressSpaceLimit limit(2304ll * 1024);
|
||||
desk.controller.exportPNG([&desk] { desk.done = true; });
|
||||
}
|
||||
QTRY_VERIFY(desk.done);
|
||||
@@ -195,7 +195,7 @@ void ProjectExportFailureTests::theJPEGRenderTakesTheSnapshotWithoutACopy()
|
||||
entered.acquire();
|
||||
{
|
||||
malloc_trim(0);
|
||||
const AddressSpaceLimit limit(2048ll * 1024);
|
||||
const AddressSpaceLimit limit(2304ll * 1024);
|
||||
desk.controller.exportJPEG([&desk] { desk.done = true; });
|
||||
}
|
||||
release.release();
|
||||
|
||||
@@ -218,11 +218,13 @@ void SelectMenuTests::filtersOpenFromTheImageAndFilterMenus()
|
||||
{
|
||||
Bar bar;
|
||||
const std::pair<const char *, FilterKind> entries[] = {
|
||||
{"curves", FilterKind::curves}, {"exposure", FilterKind::exposure}, {"gradientMap", FilterKind::gradientMap},
|
||||
{"curves", FilterKind::curves}, {"blackWhite", FilterKind::blackWhite}, {"colorBalance", FilterKind::colorBalance},
|
||||
{"exposure", FilterKind::exposure}, {"gradientMap", FilterKind::gradientMap},
|
||||
{"grain", FilterKind::grain}, {"gaussianBlur", FilterKind::gaussianBlur}, {"motionBlur", FilterKind::motionBlur},
|
||||
{"addNoise", FilterKind::addNoise}, {"lensCorrection", FilterKind::lensCorrection}, {"removeBackground", FilterKind::removeBackground}};
|
||||
for (const auto &[name, kind] : entries) {
|
||||
QCOMPARE(bar.action(name).text(), rawValue(kind) + QStringLiteral("…"));
|
||||
// A menu doubles an ampersand, else read as a mnemonic.
|
||||
QCOMPARE(bar.action(name).text(), rawValue(kind).replace("&", "&&") + QStringLiteral("…"));
|
||||
QVERIFY2(!bar.action(name).isEnabled(), name);
|
||||
}
|
||||
bar.session().createDocument(8, 8, true);
|
||||
|
||||
@@ -205,6 +205,7 @@ compositor_test(PSDImportTests)
|
||||
compositor_test(RawImporterTests)
|
||||
compositor_test(RawDevelopTests)
|
||||
compositor_test(ToolDefaultsTests)
|
||||
compositor_test(ColorAdjustmentTests)
|
||||
compositor_test(LayerEffectsRendererTests)
|
||||
compositor_test(LayerFlipTests)
|
||||
compositor_test(LayerMergeTests)
|
||||
|
||||
Reference in new issue
Block a user