Crop ratios and selection start, Vignette on empty layers, text colour previews (12.10c); mutation runs use 12 cores
This commit is contained in:
1 parent
7a873eb9c7
commit
ffba1753c6
27 files changed
+555
-55
No files matched your search
@@ -132,6 +132,7 @@ Platform adaptations, each forced by a missing Qt analog:
|
||||
- Keyboard zoom, the rail and the tab strip (12.9b, Swift 1.2.4: `383548c`, `8053fb6`, `75c4219`). `CanvasViewport::keyboardZoomLevels` holds Swift's seventeen stops (12.5% to 1600%) and `keyboardZoomTarget` the next stop past the zoom within a billionth, or the zoom itself at an end; `EditorSession::zoomKeyboard` steps about the view's centre, leaves fit mode and stays silent when nothing moves; Swift's `step != 0` there is dead and left out (the viewport answers the zoom itself for a step of 0). The View menu's Zoom In and Zoom Out and the toolbar's zoom buttons step through the stops, the menu entries doing nothing while a text field or open text holds the keys (Swift's `NSText` guard; the entries' `m_field` and `m_canvas` with a draft). Swift's `handleKeyboardZoom` catches its zoom keys on key-down and repeat because a Mac menu key equivalent does not repeat; Qt's shortcuts repeat, and a matched shortcut never reaches a key filter. The menu's Ctrl+= and Ctrl+- also take Ctrl with the keypad's minus (probed in the app), so the canvas's window-wide filter (`handleKeyboardZoom`, beside the blend keys, outside text) takes only what Zoom In's entry cannot hold: Ctrl with Shift and = (Qt's Ctrl++) and the keypad's plus, never with Alt or Meta, and only while that entry keeps its default key (a remapped entry leaves the plus alone, as Swift's check of `ShortcutSettings`). Swift's zoom-out chords are all the menu's here, so the filter has no zoom-out branch. Swift's `8053fb6` pads the zoom buttons by 4 points; Qt's toolbar spaces its actions itself, left as it is (cosmetic). The rail already scrolls without scroll bars at 56 points (4.3), what `IndicatorlessScrollView` gives macOS. The tab strip follows `ProjectTabScroller`: each title button is Swift's `projectTabLabelWidth` wide (the title's width in its 12-pixel medium or semibold font, 10 more when unsaved, 35 to 155) plus Swift's trailing 8, the title elided within it and painted from the left (`TabTitle`, Swift's leading frame; a `QToolButton` centres its text), so a title moves only the tabs after it; the unsaved dot is a character, wider than Swift's 10 points of dot and gap, and widens its button by the excess (known difference). A new front tab scrolls into view, posted as Swift posts its reveal, once the row is laid out; any other change keeps the offset, which Qt clamps, where the strip used to follow every layout (Swift's drop slot has no twin, 4.7). Tests: `KeyboardZoomTests` (Swift's five, the tolerance from each side, and the key test in the app's own window: each chord's route, repeat, Shift-minus, Alt, Meta, text fields, a remapped entry, no document), `ProjectTabStripTests`, the menu in `ViewMenuTests`, the toolbar and strip in `ProjectWorkspaceViewTests`.
|
||||
- The format version in one place and the layer context menu (12.10a, Swift 1.2.5: `4cc9951`; `6fa115e`, `2f1e796`, `5c5f05b`, `1366f68`, `5d7adba`). `ProjectManifest::current` (9) is what saves write and `supported` (1 to it) what loads accept; the manifest's default, both version checks (`supports`) and the version error's range read them, so a bump is one edit. Swift moved the layer menu from each cell to the table; the twin moves it from `LayerCell::actions()` to the list: `NativeLayerList::contextMenu` is Swift's `contextMenu(for:)` (a row pressed with nothing selected is selected first; Duplicate Layer, Rename… for one layer alone, Delete Layer, Delete Selected Layers or Delete Mask, Create or Release Clipping Mask, Group Selected Layers, Move Out of Folder, the merge entry under `mergeTitle`, Add Mask with Reveal All (White) and Hide All (Black), Enable or Disable Mask, Delete Mask, Link or Unlink Mask, Show or Hide Layer, with Swift's separators and each entry enabled as Swift's `validateMenuItem`, the mask entries targeting the pixels first; 1.2.5 left Layer Effects out; a selection refused (Levels open) leaves no active layer, and every entry then rests); `menuFor` is Swift's `menu(for:)`, routing the press first: on an effect's row that effect is chosen (Swift's `selectEffect(at:)`), elsewhere the chosen effect goes, a row counts as selected only while no effect is chosen (`selectedEffect`, which a stale choice an undo left fails, as the row's painting reads it; Swift's table then shows none), the pressed row's id is read first (Swift's `targetLayer`), since a selection may finish text and rebuild the rows, a selected row keeps the selection with the pressed layer primary unless a thumbnail targets its mask or pixels, and an unselected row becomes the selection, the list takes the keys and its mask or pixels are targeted. `LayerCell::contextMenuEvent` executes that menu; below the rows none shows. Left out as dead: Delete Mask's `mask != nil` (the session chooses a mask only while it exists) and the routing's `effectSelection = nil` (`selectLayers` and `selectLayerTarget`, which every path calls, drop a chosen effect themselves). The entries act on the layer active when chosen, as Swift's `@objc` actions read it, where the old cell menu acted on its own row; Rename asks `canEditLayers` again then, as Swift's `renameSelectedLayer`. Tests: `LayerContextMenuTests` (Swift's regression tests, the folder duplicate included; titles; a real right-click), `LayerContextMenuEdgeTests` (the gates at their edges, a refused selection, action-time targets and an entry with no active layer, a chosen or stale effect, a rebuild under the press), `LayerContextMenuFixtures.h`, `theCurrentFormatVersionIsOneTheReaderAccepts` in `ProjectTests`, the old cell-menu cases in `NativeLayerListTests` and `LayersPanelTests` moved to the list's menu.
|
||||
- Whole layers on the clipboard and several duplicates (12.10b, Swift 1.2.5: `5ef5b0a`, `c873453`, `245db93`, `2f1537f`). `CopiedLayer` (in `Document/SelectionClipboard.h`) is the session's `copiedLayer`: Copy with no selection and no chosen mask (`canCopyLayer`) takes the selected layers whole; a layer with pixels still copies them as well, for other apps, and a folder or adjustment puts only its id on the clipboard under Swift's `com.compositor.copied-layer`, dropping the pixel copy. Any copy of pixels drops it; it is judged as a pixel copy is (`clipboardHolds`), so a copy anywhere since, in this app or another, ends it for good, a history entry set again included, and Wayland's second announcement of the same copy does not. The copies' active one is the active layer's copy, else the first id's with a copy in order (Swift's `values.first` is any). `ProjectWorkspace::pasteCopiedLayer` finds the tab whose copy still stands, keeps the ids its document still holds, and duplicates them in place as one `Paste` step, refused while that project cannot edit its layers, or copies them into the front tab through `copyLayers`; false leaves Paste to pixels. The Edit menu's Copy runs with `canCopyPixels` or `canCopyLayer`, and Paste asks the workspace first, a text field and open text before both. Ctrl+J without a selection duplicates a folder too (`layerViaCopy`), and its entry is enabled for any active layer; a folder under a selection copies nothing. `beginDuplicateTransform` stacks its copies through `duplicateLayers`, the copies being what is selected after it that was not before, the active copy leading. The copy between projects moved to `ProjectWorkspace+Copy.cpp`, since the workspace's file reached 500 lines. Tests: `LayerClipboardTests` (Swift has none), the updated folder cases in `SelectionClipboardTests`, `CompositorMenusTests` and `TransformTests`, and `effectsTravelWithTheCopy` in `ProjectCopyTests`.
|
||||
- Crop, Vignette and text colour from Swift 1.2.5 (12.10c: `8b5a6af`, `5ece82b`, `0488cab`, `3317d02`, `4e5f4ec`). The crop ratios gain 3:4 and 9:16 (`cropRatio`, `CropControls`). Choosing the Crop tool with a selection that is not empty starts the frame at the outline's box (`boundingRect`, which leaves control points out as CoreGraphics' `boundingBoxOfPath` does; `controlPointRect` would not) rounded out to whole pixels (`integral`) and cut to the canvas, the canvas when that is no valid frame (an outline nudged wholly off it; an empty line between rows, which rounds to a row, stays out through `isEmpty`). Vignette opens round (`vignetteRoundness` 100, the default a number that is none falls back to). On an empty layer (no pixels, no adjustment, no folder: `canVignette`, the gate the Filter menu's entry reads) it begins from clear pixels the size of the layer's box, grows its grid to the canvas and frames the canvas (`FilterJob::canvas`, mapped into the image's pixels through the inverse mapping; top-left rows, so Swift's flip is gone), painting clear pixels too (`AdjustPixels.c`'s `fillsClear`, Swift's kernel as it stands); on a layer with pixels it frames them and recolours only them, as before. The commit lands on a layer still without pixels (`startedEmpty`), and the canvas draws an empty layer while a filter previews it. `canAdjustColors` and `canVignette` share `canAdjust(allowingEmpty)`; Swift's separate `adjustment == nil` and `isGroup` terms are the twin's argument and `canAdjust`'s own. The text colour previews while a picker is open: the Type bar's picker and the foreground picker opened over open text (`ColorPickerState::editedText`, the draft and its colour), through `previewTextColor`, which the Type bar calls on every change of the working colour (Swift's `onChange`), and only on the draft the picker opened for; Cancel puts the draft's colour back, OK keeps it and moves the foreground, and a Cancel with no text open leaves the next text's colour. Swift's `background: false` term in `previewTextColor` is dead (only the foreground picker records `editedText`) and left out. The tab strip's edge fade (`1fb6a3f`, `e8ed83b`) has no subject here: the twin's strip draws no fade, and it sits in a `QToolBar` under the desktop's own title bar, which drags the window itself. Tests: Swift's `textColorPickerPreviewsAndRestoresDraft` and `theForegroundPickerPreviewsOpenTextToo` in `TypeToolTests`, `thePickersColourPreviewsOnOpenText` in `TypeControlsTests`, `theFrameStartsAtTheSelection` and the ratios in `CropTests` and `CropControlsTests`, `VignetteCanvasTests` (the kernel's frame and fill, the gate and menu, an empty layer's commit and canvas preview, a layer with pixels), the defaults and the kernel's arguments in `FinishingFilterTests`.
|
||||
- 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.
|
||||
|
||||
@@ -377,7 +377,7 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
|
||||
- 12.9b round 2: PASS, no findings.
|
||||
- [x] 12.10a Versions 1.2.5–1.2.7, part one: the project format's version in one place (`4cc9951`); the layer context menu, routed by the list, with its layer actions and validation (`6fa115e`, `2f1e796`, `5c5f05b`, `1366f68`), without the Layer Effects entry (`5d7adba`). Codex review: 3 rounds, PASS.
|
||||
- [x] 12.10b Part two: copying and pasting whole layers within and between projects (`5ef5b0a`, `c873453`); duplicating every selected layer, stacked above the topmost, folders with Ctrl+J (`245db93`, `2f1537f`). Codex review: 3 rounds, the third's finding (a history manager re-setting the current entry) fixed and closed.
|
||||
- [ ] 12.10c Part three: crop ratios 3:4 and 9:16 (`8b5a6af`) and a crop that starts at the selection (`5ece82b`); Vignette round by default, an empty layer painted across the canvas (`0488cab`); text colour previews from the pickers (`3317d02`, `4e5f4ec`); the tab strip's steady edge fade (`1fb6a3f`) and the title bar's free space dragging the window (`e8ed83b`), each where the twin has its subject. `c4244c4` is reverted in `67301d2`; the format documents (`156c38c`, `4455a72`) are 12.13's.
|
||||
- [x] 12.10c Part three: crop ratios 3:4 and 9:16 (`8b5a6af`) and a crop that starts at the selection (`5ece82b`); Vignette round by default, an empty layer painted across the canvas (`0488cab`); text colour previews from the pickers (`3317d02`, `4e5f4ec`); the tab strip's steady edge fade (`1fb6a3f`) and the title bar's free space dragging the window (`e8ed83b`), each where the twin has its subject. `c4244c4` is reverted in `67301d2`; the format documents (`156c38c`, `4455a72`) are 12.13's. Codex review: 2 rounds, PASS. The tab strip's two commits have no subject here (no fade; the desktop's title bar drags).
|
||||
- [ ] 12.11 Versions 1.2.8–1.2.10: the document's own pixel budget, PSB, simple Photoshop text as editable text, background-only PSDs, oversized Photoshop layers cropped, clicked text starting on its first baseline, live text opened by a double click with the Move tool (`a5721b2`), marching ants at screen resolution, SVG import (Qt SVG).
|
||||
- [ ] 12.12 Version 1.2.11: masks painted anywhere on the canvas, moved unlinked masks under effects, effects kept on screen while text is edited, masks painted and steps undone (`b683acb`), mask thumbnails' ground, text shown as its committed pixels, numeric label scrubbing.
|
||||
- [ ] 12.13 Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), editing while a project saves, images decoded from memory, the guide for AI agents writing projects.
|
||||
|
||||
+36
-16
@@ -408,10 +408,11 @@ static void effects_dehaze(double *r, double *g, double *b, double amount) {
|
||||
*b = camera_clamp(y2 + (*b - y2) * sat);
|
||||
}
|
||||
|
||||
static double vignette_mask(size_t x, size_t y, size_t width, size_t height,
|
||||
double midpoint, double roundness, double feather) {
|
||||
double nx = ((double)x + 0.5) / (double)width * 2.0 - 1.0;
|
||||
double ny = ((double)y + 0.5) / (double)height * 2.0 - 1.0;
|
||||
/// The vignette's strength at a point `px`, `py` of a `width` × `height` frame (0 at its middle, 1 past its edges).
|
||||
static double vignette_mask_at(double px, double py, double width, double height,
|
||||
double midpoint, double roundness, double feather) {
|
||||
double nx = px / width * 2.0 - 1.0;
|
||||
double ny = py / height * 2.0 - 1.0;
|
||||
double square = fmax(fabs(nx), fabs(ny));
|
||||
double circle = hypot(nx, ny) / sqrt(2.0);
|
||||
double shape = (1.0 - roundness / 100.0) * 0.5;
|
||||
@@ -424,6 +425,11 @@ static double vignette_mask(size_t x, size_t y, size_t width, size_t height,
|
||||
return t * t * (3.0 - 2.0 * t);
|
||||
}
|
||||
|
||||
static double vignette_mask(size_t x, size_t y, size_t width, size_t height,
|
||||
double midpoint, double roundness, double feather) {
|
||||
return vignette_mask_at((double)x + 0.5, (double)y + 0.5, (double)width, (double)height, midpoint, roundness, feather);
|
||||
}
|
||||
|
||||
static void effects_vignette(double *r, double *g, double *b, size_t x, size_t y, size_t width, size_t height,
|
||||
double amount, double midpoint, double roundness, double feather, double highlights,
|
||||
int style) {
|
||||
@@ -453,28 +459,42 @@ static void effects_vignette(double *r, double *g, double *b, size_t x, size_t y
|
||||
}
|
||||
|
||||
void adjust_colored_vignette(uint8_t *rgba, size_t width, size_t height, size_t stride,
|
||||
double frameX, double frameY, double frameWidth, double frameHeight, int fillsClear,
|
||||
double amount, double midpoint, double roundness, double feather,
|
||||
double highlights, double red, double green, double blue) {
|
||||
if (!rgba || amount <= 0 || width == 0 || height == 0) return;
|
||||
if (!rgba || amount <= 0 || width == 0 || height == 0 || frameWidth <= 0 || frameHeight <= 0) return;
|
||||
double strength = camera_clamp(amount / 100.0);
|
||||
red = camera_clamp(red); green = camera_clamp(green); blue = camera_clamp(blue);
|
||||
for (size_t y = 0; y < height; ++y) {
|
||||
uint8_t *row = rgba + y * stride;
|
||||
for (size_t x = 0; x < width; ++x) {
|
||||
uint8_t *p = row + x * 4;
|
||||
double alpha = p[3];
|
||||
if (!alpha) continue;
|
||||
double mask = vignette_mask(x, y, width, height, midpoint, roundness, feather);
|
||||
if (!p[3] && !fillsClear) continue;
|
||||
double mask = vignette_mask_at((double)x + 0.5 - frameX, (double)y + 0.5 - frameY, frameWidth, frameHeight,
|
||||
midpoint, roundness, feather);
|
||||
if (mask <= 0) continue;
|
||||
double r = fmin(1.0, p[0] / alpha);
|
||||
double g = fmin(1.0, p[1] / alpha);
|
||||
double b = fmin(1.0, p[2] / alpha);
|
||||
double bright = camera_clamp((rec709(r, g, b) - 0.45) / 0.55);
|
||||
double alpha = p[3] / 255.0;
|
||||
double r = 0, g = 0, b = 0, bright = 0;
|
||||
if (p[3]) {
|
||||
r = fmin(1.0, p[0] / (double)p[3]);
|
||||
g = fmin(1.0, p[1] / (double)p[3]);
|
||||
b = fmin(1.0, p[2] / (double)p[3]);
|
||||
bright = camera_clamp((rec709(r, g, b) - 0.45) / 0.55);
|
||||
}
|
||||
double effect = strength * mask * (1.0 - (highlights / 100.0) * bright);
|
||||
r += (red - r) * effect;
|
||||
g += (green - g) * effect;
|
||||
b += (blue - b) * effect;
|
||||
write_premultiplied(p, r, g, b, alpha);
|
||||
if (!fillsClear) {
|
||||
// Only the pixels that are there change color; their coverage stays as it was.
|
||||
write_premultiplied(p, r + (red - r) * effect, g + (green - g) * effect, b + (blue - b) * effect, p[3]);
|
||||
continue;
|
||||
}
|
||||
// The color painted over the pixel at `effect`: an opaque pixel moves toward it, a clear one takes it on.
|
||||
double out = alpha + effect * (1.0 - alpha);
|
||||
if (out <= 0) continue;
|
||||
r = (red * effect + r * alpha * (1.0 - effect)) / out;
|
||||
g = (green * effect + g * alpha * (1.0 - effect)) / out;
|
||||
b = (blue * effect + b * alpha * (1.0 - effect)) / out;
|
||||
p[3] = (uint8_t)fmin(255.0, round(out * 255.0));
|
||||
write_premultiplied(p, r, g, b, p[3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,10 @@ void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_
|
||||
double scale);
|
||||
// Standalone Vignette: blends straight sRGB toward the selected edge color using Camera Raw's
|
||||
// falloff shape and Highlight Priority. Preserves the source alpha and premultiplied storage.
|
||||
// The vignette is shaped to the frame (in the image's pixels). With fillsClear it paints transparent pixels too;
|
||||
// without, it recolors only the pixels that are there.
|
||||
void adjust_colored_vignette(uint8_t *rgba, size_t width, size_t height, size_t stride,
|
||||
double frameX, double frameY, double frameWidth, double frameHeight, int fillsClear,
|
||||
double amount, double midpoint, double roundness, double feather,
|
||||
double highlights, double red, double green, double blue);
|
||||
// Local luminance contrast with independent shadow, midtone, and highlight gains.
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ def docker(command):
|
||||
uid, gid = os.getuid(), os.getgid()
|
||||
runtime = '/run/user/%d' % uid
|
||||
done = subprocess.run(
|
||||
['docker', 'run', '--rm', '--init', '-u', '%d:%d' % (uid, gid), '-e', 'HOME=/tmp', '-e', 'QT_QPA_PLATFORM=offscreen',
|
||||
['docker', 'run', '--rm', '--init', '--cpus=12', '-u', '%d:%d' % (uid, gid), '-e', 'HOME=/tmp', '-e', 'QT_QPA_PLATFORM=offscreen',
|
||||
'--tmpfs', '%s:uid=%d,gid=%d,mode=0700' % (runtime, uid, gid), '-e', 'XDG_RUNTIME_DIR=' + runtime,
|
||||
'-v', '%s:%s' % (repo, repo), '-w', repo, 'omaphoto-dev', 'timeout', '600', 'sh', '-c', command],
|
||||
capture_output=True, text=True)
|
||||
@@ -38,7 +38,7 @@ def docker(command):
|
||||
|
||||
def build():
|
||||
wanted = ' --target ' + ' '.join(targets) if targets else ''
|
||||
return docker('cmake -S . -B build -G Ninja -DOMAPHOTO_WERROR=ON >/dev/null && cmake --build build' + wanted)
|
||||
return docker('cmake -S . -B build -G Ninja -DOMAPHOTO_WERROR=ON >/dev/null && cmake --build build -j12' + wanted)
|
||||
|
||||
|
||||
def test():
|
||||
|
||||
@@ -25,6 +25,8 @@ public:
|
||||
void openEffectColorPicker(LayerEffectKind kind);
|
||||
// The effect follows the picker's working colour.
|
||||
void previewEffectColor();
|
||||
// The open text draft previews the picker's working colour.
|
||||
void previewTextColor();
|
||||
// Swift writes the picker's hsb in place; here, the session.
|
||||
void setColorPickerHSB(const PickerHSB &hsb);
|
||||
void sampleIntoColorPicker(QPointF point);
|
||||
@@ -32,5 +34,6 @@ public:
|
||||
std::optional<PaletteColor> sampleCompositeColor(QPointF point) const;
|
||||
|
||||
private:
|
||||
void paintText(const PaletteColor &color);
|
||||
void setGradientMapColor(const PaletteColor &color, bool highlights);
|
||||
void setVignetteColor(const PaletteColor &color);
|
||||
@@ -195,7 +195,11 @@ void EditorSession::openColorPicker(bool background)
|
||||
{
|
||||
if (!canEditPalette() || m_isMaskSelected)
|
||||
return;
|
||||
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::palette, background}, paletteColor(background));
|
||||
ColorPickerState picker({ColorPickerTarget::Kind::palette, background}, paletteColor(background));
|
||||
// Open text follows the foreground: it previews, Cancel restores.
|
||||
if (!background && m_tool == NavigationTool::type && m_textDraft)
|
||||
picker.editedText = ColorPickerState::EditedText{m_textDraft->id, {m_textDraft->style.red, m_textDraft->style.green, m_textDraft->style.blue}};
|
||||
m_colorPicker = picker;
|
||||
notify();
|
||||
}
|
||||
|
||||
@@ -225,20 +229,21 @@ void EditorSession::closeColorPicker(bool commit)
|
||||
const std::optional<QUuid> draftID = m_textDraft ? std::optional(m_textDraft->id) : std::nullopt;
|
||||
if (commit && picker.target.kind == ColorPickerTarget::Kind::palette && !m_isMaskSelected) {
|
||||
setPaletteColor(color, picker.target.background);
|
||||
} else if (commit && picker.target.kind == ColorPickerTarget::Kind::text && m_tool == NavigationTool::type && draftID == picker.target.draftID) {
|
||||
} else if (!commit && picker.target.kind == ColorPickerTarget::Kind::palette && picker.editedText && m_tool == NavigationTool::type
|
||||
&& draftID == picker.editedText->draftID) {
|
||||
paintText(picker.editedText->color);
|
||||
} else if (picker.target.kind == ColorPickerTarget::Kind::text && m_tool == NavigationTool::type && draftID == picker.target.draftID) {
|
||||
// The text previewed the working colour; Cancel puts it back.
|
||||
const PaletteColor chosen = commit ? color : picker.original;
|
||||
if (draftID) {
|
||||
changeTextStyle([&color](LayerTextStyle &style) {
|
||||
style.red = color.red;
|
||||
style.green = color.green;
|
||||
style.blue = color.blue;
|
||||
});
|
||||
} else {
|
||||
m_textDefaults.red = color.red;
|
||||
m_textDefaults.green = color.green;
|
||||
m_textDefaults.blue = color.blue;
|
||||
paintText(chosen);
|
||||
} else if (commit) {
|
||||
m_textDefaults.red = chosen.red;
|
||||
m_textDefaults.green = chosen.green;
|
||||
m_textDefaults.blue = chosen.blue;
|
||||
}
|
||||
// The text colour is the foreground: the swatch follows.
|
||||
if (!m_isMaskSelected)
|
||||
if (commit && !m_isMaskSelected)
|
||||
setForegroundColor(color);
|
||||
} else if (picker.target.kind == ColorPickerTarget::Kind::gradientMap) {
|
||||
// The end previewed the working colour; Cancel puts it back.
|
||||
@@ -280,6 +285,29 @@ void EditorSession::openEffectColorPicker(LayerEffectKind kind)
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::previewTextColor()
|
||||
{
|
||||
if (!m_colorPicker)
|
||||
return;
|
||||
const ColorPickerTarget &target = m_colorPicker->target;
|
||||
const std::optional<QUuid> draftID = target.kind == ColorPickerTarget::Kind::text ? target.draftID
|
||||
: target.kind == ColorPickerTarget::Kind::palette && m_colorPicker->editedText
|
||||
? std::optional(m_colorPicker->editedText->draftID)
|
||||
: std::nullopt;
|
||||
if (draftID && m_tool == NavigationTool::type && m_textDraft && m_textDraft->id == *draftID)
|
||||
paintText(m_colorPicker->color());
|
||||
}
|
||||
|
||||
// The open draft's colour.
|
||||
void EditorSession::paintText(const PaletteColor &color)
|
||||
{
|
||||
changeTextStyle([&color](LayerTextStyle &style) {
|
||||
style.red = color.red;
|
||||
style.green = color.green;
|
||||
style.blue = color.blue;
|
||||
});
|
||||
}
|
||||
|
||||
void EditorSession::previewEffectColor()
|
||||
{
|
||||
if (!m_colorPicker || m_colorPicker->target.kind != ColorPickerTarget::Kind::effect)
|
||||
|
||||
@@ -57,5 +57,11 @@ struct ColorPickerState {
|
||||
PickerHSB hsb;
|
||||
// Swift's object identity: a new picker opens its panel anew.
|
||||
QUuid id = QUuid::createUuid();
|
||||
// The foreground picker over open text: that text, its colour.
|
||||
struct EditedText {
|
||||
QUuid draftID;
|
||||
PaletteColor color;
|
||||
};
|
||||
std::optional<EditedText> editedText;
|
||||
PaletteColor color() const { return hsb.rgb().quantized(); }
|
||||
};
|
||||
@@ -188,8 +188,12 @@ std::optional<double> EditorSession::cropRatio() const
|
||||
return 1;
|
||||
if (m_cropRatioChoice == QStringLiteral("4:3"))
|
||||
return 4.0 / 3;
|
||||
if (m_cropRatioChoice == QStringLiteral("3:4"))
|
||||
return 3.0 / 4;
|
||||
if (m_cropRatioChoice == QStringLiteral("16:9"))
|
||||
return 16.0 / 9;
|
||||
if (m_cropRatioChoice == QStringLiteral("9:16"))
|
||||
return 9.0 / 16;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "Document/EditorSession.h"
|
||||
#include "Document/Crop.h"
|
||||
#include "Logging.h"
|
||||
#include <cmath>
|
||||
|
||||
@@ -146,10 +147,19 @@ void EditorSession::selectTool(NavigationTool value)
|
||||
m_brushSettings.opacity = parked.opacity;
|
||||
}
|
||||
m_tool = value;
|
||||
// The Crop tool opens on the whole canvas, freely.
|
||||
// The Crop tool opens freely: the selection, else the canvas.
|
||||
if (value == NavigationTool::crop && !m_cropRect && m_document) {
|
||||
m_cropRatioChoice = QStringLiteral("Free");
|
||||
m_cropRect = QRectF(QPointF(0, 0), m_document->size());
|
||||
const QRectF canvas(QPointF(0, 0), m_document->size());
|
||||
m_cropRect = canvas;
|
||||
if (m_document->selection && !m_document->selection->isEmpty()) {
|
||||
// CoreGraphics' `integral`: whole pixels, rounded outwards.
|
||||
const QRectF box = m_document->selection->path.boundingRect();
|
||||
const QRectF whole = QRectF(QPointF(std::floor(box.left()), std::floor(box.top())), QPointF(std::ceil(box.right()), std::ceil(box.bottom())))
|
||||
.intersected(canvas);
|
||||
if (CropGeometry::valid(whole))
|
||||
m_cropRect = whole;
|
||||
}
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ void EditorSession::beginFilter(FilterKind kind)
|
||||
{
|
||||
if (kind == FilterKind::contentAwareFill && !canContentAwareFill())
|
||||
return;
|
||||
// Swift beeps; `canAdjustColors` reads the open filter.
|
||||
if (m_hueSaturation || !canAdjustColors()) {
|
||||
// Swift beeps; the gates read the open filter.
|
||||
if (m_hueSaturation || !(kind == FilterKind::vignette ? canVignette() : canAdjustColors())) {
|
||||
qCWarning(lcApp) << "a filter cannot open here";
|
||||
return;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ void EditorSession::beginFilter(FilterKind kind)
|
||||
commitTransform();
|
||||
cancelCrop();
|
||||
cancelLasso();
|
||||
const ImageLayer layer = activeLayer().value();
|
||||
ImageLayer layer = activeLayer().value();
|
||||
const QSizeF size = m_document->size();
|
||||
const std::optional<DocumentSelection> current = selection();
|
||||
FilterSettings settings = m_filterSettings;
|
||||
@@ -41,14 +41,26 @@ void EditorSession::beginFilter(FilterKind kind)
|
||||
if (kind == FilterKind::gradientMap)
|
||||
settings.gradientMap = GradientMapSettings{AdjustmentColor(foregroundColor()), AdjustmentColor(m_backgroundColor)};
|
||||
try {
|
||||
// Content-Aware Fill extends the layer over the selection's reach.
|
||||
std::optional<QRectF> area;
|
||||
// An empty layer has no pixels: Vignette starts them clear.
|
||||
const bool startedEmpty = !layer.asset;
|
||||
if (startedEmpty) {
|
||||
const QImage clear = BrushRaster::context(std::max(1, int(std::round(layer.transform.size.width()))),
|
||||
std::max(1, int(std::round(layer.transform.size.height()))), false);
|
||||
layer.asset = ImportedImage(clear, PixelAdjust::thumbnail(clear), layer.name);
|
||||
}
|
||||
// Fill reaches the selection; Vignette the canvas it frames.
|
||||
const QRectF canvas(QPointF(0, 0), size);
|
||||
const bool fillsCanvas = kind == FilterKind::vignette && startedEmpty;
|
||||
std::optional<QRectF> area = fillsCanvas ? std::optional(canvas) : std::nullopt;
|
||||
if (kind == FilterKind::contentAwareFill && current) {
|
||||
const QRectF box = current->path.boundingRect().intersected(QRectF(QPointF(0, 0), size));
|
||||
const QRectF box = current->path.boundingRect().intersected(canvas);
|
||||
if (!box.isEmpty())
|
||||
area = box;
|
||||
}
|
||||
m_filterEdit.emplace(kind, layer, current ? std::optional(current->clip(size)) : std::nullopt, settings, area);
|
||||
if (fillsCanvas)
|
||||
m_filterEdit->canvas = canvas;
|
||||
m_filterEdit->startedEmpty = startedEmpty;
|
||||
updateFilter(m_filterEdit->settings, true);
|
||||
} catch (const ProjectError &error) {
|
||||
setBrushError(QString::fromUtf8(error.what()));
|
||||
@@ -239,9 +251,11 @@ void EditorSession::commitFilter(std::function<void()> done)
|
||||
// A painted layer flattens in the worker, which catches failures.
|
||||
m_filterCommit.setFuture(QtConcurrent::run([kind = edit.kind, original = edit.original, grownImage = edit.grownImage, settings = rendered,
|
||||
selection = edit.selection, mapping = edit.mapping, seed = edit.seed, cached,
|
||||
grown = edit.grownTransform, spreads]() -> FilterMade {
|
||||
grown = edit.grownTransform, spreads, canvas = edit.canvas]() -> FilterMade {
|
||||
try {
|
||||
QImage image = cached ? *cached : PixelFilter::run(FilterJob{kind, grownImage ? *grownImage : original.image(), settings, 1, selection, mapping, seed});
|
||||
FilterJob job{kind, grownImage ? *grownImage : original.image(), settings, 1, selection, mapping, seed};
|
||||
job.canvas = canvas;
|
||||
QImage image = cached ? *cached : PixelFilter::run(job);
|
||||
std::optional<LayerTransform> placed = grown;
|
||||
// A blur is cut back to the pixels it left.
|
||||
if (spreads && grown) {
|
||||
@@ -316,7 +330,9 @@ void EditorSession::finishFilterCommit()
|
||||
return;
|
||||
}
|
||||
const ImageLayer current = m_document->layers[index];
|
||||
if (!current.asset || current.asset->identity() != edit.original.identity() || current.transform != edit.transform) {
|
||||
// An empty layer the filter began on stays empty.
|
||||
const bool kept = current.asset ? current.asset->identity() == edit.original.identity() : edit.startedEmpty;
|
||||
if (!kept || current.transform != edit.transform) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ FilterSettings FilterSettings::normalized() const
|
||||
result.vignetteAmount = clamp(vignetteAmount, 0, 100, 35);
|
||||
result.vignetteColor = vignetteColor.clamped();
|
||||
result.vignetteMidpoint = clamp(vignetteMidpoint, 0, 100, 50);
|
||||
result.vignetteRoundness = clamp(vignetteRoundness, -100, 100, 0);
|
||||
result.vignetteRoundness = clamp(vignetteRoundness, -100, 100, 100);
|
||||
result.vignetteFeather = clamp(vignetteFeather, 0, 100, 60);
|
||||
result.vignetteHighlights = clamp(vignetteHighlights, 0, 100, 25);
|
||||
result.bloomAmount = clamp(bloomAmount, 0, 100, 40);
|
||||
@@ -167,12 +167,16 @@ QImage PixelFilter::run(const FilterJob &job)
|
||||
noise_add_at(image.bits(), size_t(width), size_t(height), size_t(image.bytesPerLine()), float(settings.amount), settings.gaussian ? 1 : 0,
|
||||
settings.monochromatic ? 1 : 0, job.seed, int64_t(std::floor(job.noiseOrigin.x())), int64_t(std::floor(job.noiseOrigin.y())));
|
||||
break;
|
||||
case FilterKind::vignette:
|
||||
case FilterKind::vignette: {
|
||||
image = drawn(job.image);
|
||||
adjust_colored_vignette(image.bits(), size_t(width), size_t(height), size_t(image.bytesPerLine()), settings.vignetteAmount,
|
||||
// The canvas in this image's pixels, else the image itself.
|
||||
const QRectF frame = job.canvas ? job.mapping.inverted().mapRect(*job.canvas) : QRectF(0, 0, width, height);
|
||||
adjust_colored_vignette(image.bits(), size_t(width), size_t(height), size_t(image.bytesPerLine()), frame.x(), frame.y(), frame.width(),
|
||||
frame.height(), job.canvas ? 1 : 0, settings.vignetteAmount,
|
||||
settings.vignetteMidpoint, settings.vignetteRoundness, settings.vignetteFeather, settings.vignetteHighlights,
|
||||
settings.vignetteColor.red, settings.vignetteColor.green, settings.vignetteColor.blue);
|
||||
break;
|
||||
}
|
||||
case FilterKind::bloomGlow: image = bloom(drawn(job.image), settings.bloomRadius * job.scale, settings.bloomAmount / 50); break;
|
||||
case FilterKind::tonalContrast: {
|
||||
// The detail's base: the layer softened, clear past its edge.
|
||||
@@ -306,7 +310,9 @@ FilterJob FilterEdit::previewJob() const
|
||||
rawPanel.showsShadowClipping,
|
||||
rawPanel.showsHighlightClipping,
|
||||
rawPanel.pointColorVisualizeIndex(settings.cameraRaw),
|
||||
rawPanel.sharpenMask};
|
||||
rawPanel.sharpenMask,
|
||||
{},
|
||||
canvas};
|
||||
}
|
||||
|
||||
FilterSettings FilterEdit::renderSettings() const
|
||||
|
||||
@@ -54,7 +54,7 @@ struct FilterSettings {
|
||||
double vignetteAmount = 35;
|
||||
AdjustmentColor vignetteColor = AdjustmentColor(0, 0, 0);
|
||||
double vignetteMidpoint = 50;
|
||||
double vignetteRoundness = 0;
|
||||
double vignetteRoundness = 100;
|
||||
double vignetteFeather = 60;
|
||||
double vignetteHighlights = 25;
|
||||
// Bloom / Glow: strength, and blur radius in layer pixels.
|
||||
@@ -106,6 +106,8 @@ struct FilterJob {
|
||||
bool showsSharpenMask = false;
|
||||
// A live adjustment's noise field, fixed in its region's pixels.
|
||||
QPointF noiseOrigin{};
|
||||
// Vignette on an empty layer: the canvas it fills.
|
||||
std::optional<QRectF> canvas = std::nullopt;
|
||||
};
|
||||
|
||||
// Swift's PixelFilter.
|
||||
@@ -172,6 +174,10 @@ public:
|
||||
quint64 previewSourceVersion = 0;
|
||||
std::optional<FilterJob> pending;
|
||||
CameraRawPanel rawPanel;
|
||||
// Vignette on an empty layer: the canvas it fills.
|
||||
std::optional<QRectF> canvas;
|
||||
// The layer had no pixels: the filter began clear.
|
||||
bool startedEmpty = false;
|
||||
|
||||
private:
|
||||
void grow(const QRectF &extent);
|
||||
|
||||
@@ -17,11 +17,23 @@ SessionJobs::Adjusted adjusted(const HueSaturationJob &job)
|
||||
|
||||
// Swift's session extension in HueSaturation.swift.
|
||||
bool EditorSession::canAdjustColors() const
|
||||
{
|
||||
return canAdjust(false);
|
||||
}
|
||||
|
||||
// Vignette also paints an empty layer, without pixels yet.
|
||||
bool EditorSession::canVignette() const
|
||||
{
|
||||
const std::optional<ImageLayer> layer = activeLayer();
|
||||
return canAdjust(!(layer && layer->adjustment));
|
||||
}
|
||||
|
||||
bool EditorSession::canAdjust(bool allowingEmpty) const
|
||||
{
|
||||
const std::optional<ImageLayer> layer = activeLayer();
|
||||
const std::optional<DocumentSelection> current = selection();
|
||||
return !m_levels && !m_filterEdit && m_document && layer && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_pixelMove && !m_renamingLayerID && !m_showsNewDocument
|
||||
&& !m_showsImporter && m_selectedLayerIDs.size() == 1 && !layer->isGroup && !m_isMaskSelected && layer->asset
|
||||
&& !m_showsImporter && m_selectedLayerIDs.size() == 1 && !layer->isGroup && !m_isMaskSelected && (layer->asset || allowingEmpty)
|
||||
&& m_document->effectiveVisibleIDs().contains(layer->id) && !(current && current->isEmpty());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Swift's HueSaturation extension.
|
||||
public:
|
||||
bool canAdjustColors() const;
|
||||
bool canVignette() const;
|
||||
void beginHueSaturation();
|
||||
// Previews coalesce: the newest waits for the running one.
|
||||
void updateHueSaturation(const HueSaturationSettings &settings, bool preview);
|
||||
@@ -21,6 +22,7 @@ public:
|
||||
void setHueTargeting(bool targeting);
|
||||
|
||||
private:
|
||||
bool canAdjust(bool allowingEmpty) const;
|
||||
void renderPendingHuePreview();
|
||||
void finishHueSaturationPreview();
|
||||
void finishHueSaturationCommit();
|
||||
@@ -336,7 +336,8 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
|
||||
: gradient && gradient->raster->layer.id == id ? gradient->raster.get()
|
||||
: move && move->raster->layer.id == id ? move->raster.get()
|
||||
: nullptr;
|
||||
if (!layer.asset && !stroke)
|
||||
// An empty layer draws nothing, unless Vignette previews onto it.
|
||||
if (!layer.asset && !stroke && !(m_session.filterEdit() && m_session.filterEdit()->previewImage(id)))
|
||||
return;
|
||||
// Smudge or Liquify under way: the reshaped copy, canvas-wide.
|
||||
if (const WarpStroke *warp = m_session.warpStroke(); warp && warp->layer.id == id) {
|
||||
|
||||
@@ -366,7 +366,7 @@ void CompositorMenus::synchronize()
|
||||
action(QStringLiteral("levels"))->setEnabled(s.canAdjustColors() && !s.hueSaturation());
|
||||
for (const FilterKind kind : allFilterKinds) {
|
||||
if (kind != FilterKind::contentAwareFill)
|
||||
action(filterName(kind))->setEnabled(s.canAdjustColors() && !s.hueSaturation());
|
||||
action(filterName(kind))->setEnabled((kind == FilterKind::vignette ? s.canVignette() : s.canAdjustColors()) && !s.hueSaturation());
|
||||
}
|
||||
action(QStringLiteral("hueSaturation"))->setEnabled(s.canAdjustColors());
|
||||
action(QStringLiteral("invert"))->setText(s.isMaskSelected() ? QStringLiteral("Invert Mask") : QStringLiteral("Invert"));
|
||||
|
||||
@@ -19,7 +19,8 @@ CropControls::CropControls(EditorSession &session, QWidget *parent)
|
||||
pickerRow->addWidget(label);
|
||||
pickerRow->addWidget(m_ratio, 1);
|
||||
m_ratio->setObjectName(QStringLiteral("cropRatio"));
|
||||
for (const QString &choice : {QStringLiteral("Free"), QStringLiteral("Original"), QStringLiteral("1:1"), QStringLiteral("4:3"), QStringLiteral("16:9")})
|
||||
for (const QString &choice : {QStringLiteral("Free"), QStringLiteral("Original"), QStringLiteral("1:1"), QStringLiteral("4:3"), QStringLiteral("3:4"), QStringLiteral("16:9"),
|
||||
QStringLiteral("9:16")})
|
||||
m_ratio->addItem(choice);
|
||||
// Swift's onChange: the frame takes a ratio that changed.
|
||||
connect(m_ratio, &QComboBox::activated, this, [this](int index) {
|
||||
|
||||
@@ -272,6 +272,12 @@ QToolButton *TypeControls::alignment(TextAlignment value)
|
||||
|
||||
void TypeControls::synchronize()
|
||||
{
|
||||
// Swift's onChange: open text previews the picker's colour.
|
||||
const std::optional<PaletteColor> picked = m_session.colorPicker() ? std::optional(m_session.colorPicker()->color()) : std::nullopt;
|
||||
if (picked != m_pickerColour) {
|
||||
m_pickerColour = picked;
|
||||
m_session.previewTextColor();
|
||||
}
|
||||
const LayerTextStyle style = m_session.currentTextStyle();
|
||||
m_font->sync(style.fontName);
|
||||
for (size_t index = 0; index < m_alignments.size(); ++index)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#pragma once
|
||||
#include "Document/ColorPalette.h"
|
||||
#include "Document/TypeTool.h"
|
||||
#include "UI/ToolHeaderStyle.h"
|
||||
#include <QComboBox>
|
||||
@@ -74,4 +75,5 @@ private:
|
||||
QPushButton *const m_cancel;
|
||||
QPushButton *const m_done;
|
||||
QPushButton *const m_edit;
|
||||
std::optional<PaletteColor> m_pickerColour;
|
||||
};
|
||||
@@ -35,7 +35,7 @@ void CropControlsTests::theBarShowsTheFrameAndItsRatio()
|
||||
QStringList choices;
|
||||
for (int index = 0; index < ratio.count(); ++index)
|
||||
choices << ratio.itemText(index);
|
||||
QCOMPARE(choices, (QStringList{"Free", "Original", "1:1", "4:3", "16:9"}));
|
||||
QCOMPARE(choices, (QStringList{"Free", "Original", "1:1", "4:3", "3:4", "16:9", "9:16"}));
|
||||
QCOMPARE(ratio.currentText(), QString("Free"));
|
||||
// Labelled as Swift's picker, the two 170 points wide.
|
||||
const QList<QLabel *> labels = bar.findChildren<QLabel *>();
|
||||
|
||||
+66
-1
@@ -1,5 +1,6 @@
|
||||
#include "CanvasFixtures.h"
|
||||
#include "CropFixtures.h"
|
||||
#include "SelectionFixtures.h"
|
||||
#include "Document/Crop.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include <QPainter>
|
||||
@@ -11,6 +12,7 @@
|
||||
class CropTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void theFrameStartsAtTheSelection();
|
||||
void dragGeometrySupportsReverseRatioMoveAndEveryHandle();
|
||||
void cropTranslatesWithoutResamplingAndUndoRestoresBounds();
|
||||
void sameSizeOffsetCropAndExpansionUseExactBounds();
|
||||
@@ -279,7 +281,7 @@ void CropTests::ratiosReshapeTheFrameAboutItsMiddle()
|
||||
session.selectTool(NavigationTool::crop);
|
||||
session.setCropRect(QRectF(10, 20, 120, 60));
|
||||
const std::pair<const char *, std::optional<double>> ratios[] = {
|
||||
{"Original", 1.5}, {"1:1", 1.0}, {"4:3", 4.0 / 3}, {"16:9", 16.0 / 9}, {"Free", std::nullopt}};
|
||||
{"Original", 1.5}, {"1:1", 1.0}, {"4:3", 4.0 / 3}, {"3:4", 0.75}, {"16:9", 16.0 / 9}, {"9:16", 9.0 / 16}, {"Free", std::nullopt}};
|
||||
for (const auto &[choice, ratio] : ratios) {
|
||||
session.setCropRatioChoice(QString::fromLatin1(choice));
|
||||
QCOMPARE(session.cropRatio(), ratio);
|
||||
@@ -316,5 +318,68 @@ void CropTests::ratiosReshapeTheFrameAboutItsMiddle()
|
||||
QCOMPARE(empty.visibleCropRect(), std::nullopt);
|
||||
}
|
||||
|
||||
void CropTests::theFrameStartsAtTheSelection()
|
||||
{
|
||||
// Swift 1.2.5 (5ece82b): C, then Return, crops to the selection.
|
||||
EditorSession session;
|
||||
session.createDocument(300, 200);
|
||||
session.applySelection(rectPath(QRectF(10.3, 20.6, 50.2, 30)), SelectionMode::replace, QStringLiteral("Select"));
|
||||
session.selectTool(NavigationTool::crop);
|
||||
// Rounded out to whole pixels.
|
||||
QCOMPARE(session.cropRect(), std::optional(QRectF(10, 20, 51, 31)));
|
||||
QCOMPARE(session.cropRatioChoice(), QString("Free"));
|
||||
// A frame already drawn stays as it is.
|
||||
session.setCropRect(QRectF(0, 0, 40, 40));
|
||||
session.selectTool(NavigationTool::crop);
|
||||
QCOMPARE(session.cropRect(), std::optional(QRectF(0, 0, 40, 40)));
|
||||
session.cancelCrop();
|
||||
// Past the canvas, the frame keeps to it.
|
||||
session.selectTool(NavigationTool::move);
|
||||
session.applySelection(rectPath(QRectF(280, 190, 50, 50)), SelectionMode::replace, QStringLiteral("Select"));
|
||||
session.selectTool(NavigationTool::crop);
|
||||
QCOMPARE(session.cropRect(), std::optional(QRectF(280, 190, 20, 10)));
|
||||
session.cancelCrop();
|
||||
// Nudged partly off the canvas, the frame keeps to it.
|
||||
session.selectTool(NavigationTool::move);
|
||||
session.applySelection(rectPath(QRectF(270, 20, 20, 20)), SelectionMode::replace, QStringLiteral("Select"));
|
||||
session.nudgeSelection(20, 0);
|
||||
QCOMPARE(session.selection().value().path.controlPointRect(), QRectF(290, 20, 20, 20));
|
||||
session.selectTool(NavigationTool::crop);
|
||||
QCOMPARE(session.cropRect(), std::optional(QRectF(290, 20, 10, 20)));
|
||||
session.cancelCrop();
|
||||
// Wholly off it, the frame is the canvas.
|
||||
session.selectTool(NavigationTool::move);
|
||||
session.nudgeSelection(20, 0);
|
||||
session.selectTool(NavigationTool::crop);
|
||||
QCOMPARE(session.cropRect(), std::optional(QRectF(0, 0, 300, 200)));
|
||||
session.cancelCrop();
|
||||
// An empty selection starts at the whole canvas.
|
||||
session.selectTool(NavigationTool::move);
|
||||
session.applySelection(rectPath(QRectF(10, 10, 10, 10)), SelectionMode::replace, QStringLiteral("Select"));
|
||||
session.applySelection(rectPath(QRectF(0, 0, 100, 40)), SelectionMode::subtract, QStringLiteral("Subtract"));
|
||||
QVERIFY(session.selection().value().isEmpty());
|
||||
session.selectTool(NavigationTool::crop);
|
||||
QCOMPARE(session.cropRect(), std::optional(QRectF(0, 0, 300, 200)));
|
||||
session.cancelCrop();
|
||||
// An empty line between rows would round to a row.
|
||||
session.selectTool(NavigationTool::move);
|
||||
QPainterPath line(QPointF(10, 5.5));
|
||||
line.lineTo(20, 5.5);
|
||||
session.setSelection(DocumentSelection{line}, QStringLiteral("Select"));
|
||||
QVERIFY(session.selection().value().isEmpty());
|
||||
session.selectTool(NavigationTool::crop);
|
||||
QCOMPARE(session.cropRect(), std::optional(QRectF(0, 0, 300, 200)));
|
||||
session.cancelCrop();
|
||||
// A turned ellipse: its curve, not its control points.
|
||||
session.selectTool(NavigationTool::move);
|
||||
QPainterPath ellipse;
|
||||
ellipse.addEllipse(QRectF(60, 60, 120, 40));
|
||||
const QTransform turn = QTransform::fromTranslate(120, 80).rotate(45).translate(-120, -80);
|
||||
session.setSelection(DocumentSelection{turn.map(ellipse)}, QStringLiteral("Select"));
|
||||
QVERIFY(session.selection().value().path.controlPointRect().width() > 95);
|
||||
session.selectTool(NavigationTool::crop);
|
||||
QCOMPARE(session.cropRect(), std::optional(QRectF(75, 35, 90, 90)));
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(CropTests)
|
||||
#include "CropTests.moc"
|
||||
@@ -158,7 +158,8 @@ void FinishingFilterTests::theKernelsTakeTheSettingsAtTheScale()
|
||||
FilterSettings vignette{.vignetteAmount = 70, .vignetteColor = AdjustmentColor(0.2, 0.9, 0.4), .vignetteMidpoint = 30,
|
||||
.vignetteRoundness = -40, .vignetteFeather = 20, .vignetteHighlights = 60};
|
||||
QImage expected = source.copy();
|
||||
adjust_colored_vignette(expected.bits(), 40, 30, size_t(expected.bytesPerLine()), 70, 30, -40, 20, 60, 0.2, 0.9, 0.4);
|
||||
// The layer's own pixels frame it; clear ones stay clear.
|
||||
adjust_colored_vignette(expected.bits(), 40, 30, size_t(expected.bytesPerLine()), 0, 0, 40, 30, 0, 70, 30, -40, 20, 60, 0.2, 0.9, 0.4);
|
||||
QCOMPARE(run(FilterKind::vignette, source, vignette, 3), expected);
|
||||
// Tonal Contrast: its base blurred at radius times scale.
|
||||
const FilterSettings tonal{.tonalAmount = 80, .tonalRadius = 3, .tonalShadows = -30, .tonalMidtones = 70, .tonalHighlights = 45};
|
||||
@@ -181,7 +182,7 @@ void FinishingFilterTests::settingsKeepSwiftsDefaultsAndBounds()
|
||||
{
|
||||
const FilterSettings defaults;
|
||||
QVERIFY(defaults.vignetteAmount == 35 && defaults.vignetteColor == AdjustmentColor(0, 0, 0) && defaults.vignetteMidpoint == 50);
|
||||
QVERIFY(defaults.vignetteRoundness == 0 && defaults.vignetteFeather == 60 && defaults.vignetteHighlights == 25);
|
||||
QVERIFY(defaults.vignetteRoundness == 100 && defaults.vignetteFeather == 60 && defaults.vignetteHighlights == 25);
|
||||
QVERIFY(defaults.bloomAmount == 40 && defaults.bloomRadius == 24);
|
||||
QVERIFY(defaults.tonalAmount == 50 && defaults.tonalRadius == 16 && defaults.tonalShadows == 40 && defaults.tonalMidtones == 60);
|
||||
QCOMPARE(defaults.tonalHighlights, 30.0);
|
||||
|
||||
@@ -257,6 +257,8 @@ compositor_test(FilterControlsTests)
|
||||
compositor_test(FinishingFilterTests)
|
||||
# It checks the filters against their kernels.
|
||||
target_include_directories(FinishingFilterTests PRIVATE ${OMAPHOTO_KERNELS})
|
||||
compositor_test(VignetteCanvasTests)
|
||||
target_include_directories(VignetteCanvasTests PRIVATE ${OMAPHOTO_KERNELS})
|
||||
compositor_test(FloatingSelectionTests)
|
||||
compositor_test(CropTests)
|
||||
compositor_test(GuideTests)
|
||||
|
||||
@@ -43,6 +43,7 @@ private slots:
|
||||
void theButtonsFollowTheDraft();
|
||||
void theBarRestsWithoutADocumentOrWhileBusy();
|
||||
void aTextRowShowsAndOpensItsText();
|
||||
void thePickersColourPreviewsOnOpenText();
|
||||
};
|
||||
|
||||
void TypeControlsTests::theBarShowsTheStyleItEdits()
|
||||
@@ -455,5 +456,19 @@ void TypeControlsTests::aTextRowShowsAndOpensItsText()
|
||||
QVERIFY(!session.renamingLayerID());
|
||||
}
|
||||
|
||||
void TypeControlsTests::thePickersColourPreviewsOnOpenText()
|
||||
{
|
||||
// Swift's onChange: each working colour reaches the draft.
|
||||
Bar shown;
|
||||
shown.session.beginText(QPointF(20, 30));
|
||||
shown.session.openTextColorPicker();
|
||||
PickerHSB hsb = shown.session.colorPicker().value().hsb;
|
||||
hsb.setRGB(PaletteColor{1, 0, 0});
|
||||
shown.session.setColorPickerHSB(hsb);
|
||||
QCOMPARE(shown.session.textDraft().value().style.red, 1.0);
|
||||
shown.session.closeColorPicker(false);
|
||||
QCOMPARE(shown.session.textDraft().value().style.red, 0.0);
|
||||
}
|
||||
|
||||
QTEST_MAIN(TypeControlsTests)
|
||||
#include "TypeControlsTests.moc"
|
||||
@@ -14,6 +14,14 @@ std::unique_ptr<EditorSession> makeSession()
|
||||
return session;
|
||||
}
|
||||
|
||||
// Swift writes `colorPicker.hsb` in place.
|
||||
void pick(EditorSession &session, const PaletteColor &color)
|
||||
{
|
||||
PickerHSB hsb = session.colorPicker().value().hsb;
|
||||
hsb.setRGB(color);
|
||||
session.setColorPickerHSB(hsb);
|
||||
}
|
||||
|
||||
// Swift writes `session.textDraft?.style.content` in place.
|
||||
void type(EditorSession &session, const QString &content)
|
||||
{
|
||||
@@ -50,6 +58,8 @@ private slots:
|
||||
void paragraphBoxAndToolSwitchCommitEditableText();
|
||||
void emptyNewParagraphIsDiscarded();
|
||||
void invalidAndStaleDraftsDoNotChangeDocument();
|
||||
void textColorPickerPreviewsAndRestoresDraft();
|
||||
void theForegroundPickerPreviewsOpenTextToo();
|
||||
};
|
||||
|
||||
void TypeToolTests::createEditCancelAndUndo()
|
||||
@@ -217,5 +227,95 @@ void TypeToolTests::invalidAndStaleDraftsDoNotChangeDocument()
|
||||
QCOMPARE(session->document().value().layers.size(), size_t(1));
|
||||
}
|
||||
|
||||
void TypeToolTests::textColorPickerPreviewsAndRestoresDraft()
|
||||
{
|
||||
const auto session = makeSession();
|
||||
session->beginText(QPointF(30, 40));
|
||||
const LayerTextStyle original = session->textDraft().value().style;
|
||||
session->openTextColorPicker();
|
||||
pick(*session, PaletteColor{1, 0, 0});
|
||||
session->previewTextColor();
|
||||
QCOMPARE(session->textDraft().value().style.red, 1.0);
|
||||
QCOMPARE(session->textDraft().value().style.green, 0.0);
|
||||
QCOMPARE(session->foregroundColor(), PaletteColor::black());
|
||||
session->closeColorPicker(false);
|
||||
QCOMPARE(session->textDraft().value().style, original);
|
||||
QCOMPARE(session->foregroundColor(), PaletteColor::black());
|
||||
session->openTextColorPicker();
|
||||
pick(*session, PaletteColor{0, 0, 1});
|
||||
session->previewTextColor();
|
||||
session->closeColorPicker(true);
|
||||
QCOMPARE(session->textDraft().value().style.blue, 1.0);
|
||||
QCOMPARE(session->foregroundColor(), (PaletteColor{0, 0, 1}));
|
||||
// With no text open, Cancel leaves the next text's colour.
|
||||
session->setTextDraft(std::nullopt);
|
||||
session->openTextColorPicker();
|
||||
pick(*session, PaletteColor{1, 0, 0});
|
||||
session->closeColorPicker(true);
|
||||
session->setForegroundColor(PaletteColor{0, 1, 0});
|
||||
session->openTextColorPicker();
|
||||
session->closeColorPicker(false);
|
||||
QCOMPARE(session->textDefaults().red, 1.0);
|
||||
QCOMPARE(session->textDefaults().green, 0.0);
|
||||
}
|
||||
|
||||
void TypeToolTests::theForegroundPickerPreviewsOpenTextToo()
|
||||
{
|
||||
// Swift 1.2.5 (4e5f4ec): open text follows the foreground picker.
|
||||
const auto session = makeSession();
|
||||
session->beginText(QPointF(30, 40));
|
||||
const auto paint = [&](const PaletteColor &color) {
|
||||
session->changeTextStyle([&](LayerTextStyle &style) {
|
||||
style.red = color.red;
|
||||
style.green = color.green;
|
||||
style.blue = color.blue;
|
||||
});
|
||||
};
|
||||
const auto colour = [&] {
|
||||
const LayerTextStyle style = session->textDraft().value().style;
|
||||
return PaletteColor{style.red, style.green, style.blue};
|
||||
};
|
||||
// Blue text, a black foreground: Cancel restores the text's own.
|
||||
paint(PaletteColor{0, 0, 1});
|
||||
session->openColorPicker(false);
|
||||
pick(*session, PaletteColor{0, 1, 0});
|
||||
session->previewTextColor();
|
||||
QCOMPARE(colour(), (PaletteColor{0, 1, 0}));
|
||||
session->closeColorPicker(false);
|
||||
QCOMPARE(colour(), (PaletteColor{0, 0, 1}));
|
||||
QCOMPARE(session->foregroundColor(), PaletteColor::black());
|
||||
// Another draft set meanwhile is neither previewed nor restored.
|
||||
session->openColorPicker(false);
|
||||
TextDraft other = session->textDraft().value();
|
||||
other.id = QUuid::createUuid();
|
||||
session->setTextDraft(other);
|
||||
paint(PaletteColor{1, 0, 0});
|
||||
pick(*session, PaletteColor{0, 1, 0});
|
||||
session->previewTextColor();
|
||||
QCOMPARE(colour(), (PaletteColor{1, 0, 0}));
|
||||
session->closeColorPicker(false);
|
||||
QCOMPARE(colour(), (PaletteColor{1, 0, 0}));
|
||||
// OK keeps the colour, and the foreground with it.
|
||||
session->openColorPicker(false);
|
||||
pick(*session, PaletteColor{0, 1, 0});
|
||||
session->previewTextColor();
|
||||
session->closeColorPicker(true);
|
||||
QCOMPARE(colour(), (PaletteColor{0, 1, 0}));
|
||||
QCOMPARE(session->foregroundColor(), (PaletteColor{0, 1, 0}));
|
||||
// The background picker leaves text alone.
|
||||
session->openColorPicker(true);
|
||||
pick(*session, PaletteColor{1, 0, 0});
|
||||
session->previewTextColor();
|
||||
QCOMPARE(colour(), (PaletteColor{0, 1, 0}));
|
||||
session->closeColorPicker(false);
|
||||
// The Type bar's picker: a later draft keeps its colour.
|
||||
session->openTextColorPicker();
|
||||
other.id = QUuid::createUuid();
|
||||
session->setTextDraft(other);
|
||||
paint(PaletteColor{1, 0, 0});
|
||||
session->closeColorPicker(false);
|
||||
QCOMPARE(colour(), (PaletteColor{1, 0, 0}));
|
||||
}
|
||||
|
||||
QTEST_MAIN(TypeToolTests)
|
||||
#include "TypeToolTests.moc"
|
||||
@@ -0,0 +1,190 @@
|
||||
#include "CanvasFixtures.h"
|
||||
#include "Document/Filters.h"
|
||||
#include "MenuFixtures.h"
|
||||
#include "SessionFixtures.h"
|
||||
#include <QPainter>
|
||||
|
||||
extern "C" {
|
||||
#include "AdjustPixels.h"
|
||||
}
|
||||
|
||||
// Swift 1.2.5: Vignette round by default, painting an empty layer.
|
||||
namespace {
|
||||
bool prepared(EditorSession &session)
|
||||
{
|
||||
return QTest::qWaitFor([&] { return session.filterEdit() && !session.filterEdit().value().preparing; }, 20'000);
|
||||
}
|
||||
|
||||
bool committed(EditorSession &session)
|
||||
{
|
||||
bool done = false;
|
||||
session.commitFilter([&done] { done = true; });
|
||||
return QTest::qWaitFor([&done] { return done; }, 20'000);
|
||||
}
|
||||
}
|
||||
|
||||
class VignetteCanvasTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void clearPixelsTakeTheColourTowardTheFramesEdge();
|
||||
void theCanvasFramesALayerThatSitsInsideIt();
|
||||
void onlyVignettePaintsAnEmptyLayer();
|
||||
void anEmptyLayerTakesTheVignetteAcrossTheCanvas();
|
||||
void theCanvasShowsThePreviewOnAnEmptyLayer();
|
||||
void aLayerWithPixelsKeepsItsOwnFrameAndClear();
|
||||
void aSmallEmptyLayerGrowsToTheCanvas();
|
||||
void theFrameIsReadTopDown();
|
||||
};
|
||||
|
||||
void VignetteCanvasTests::clearPixelsTakeTheColourTowardTheFramesEdge()
|
||||
{
|
||||
QImage clear = BrushRaster::context(41, 41, false);
|
||||
// Without fillsClear, clear pixels stay as they are.
|
||||
QImage kept = clear.copy();
|
||||
adjust_colored_vignette(kept.bits(), 41, 41, size_t(kept.bytesPerLine()), 0, 0, 41, 41, 0, 100, 50, 100, 60, 25, 1, 0, 0);
|
||||
QCOMPARE(kept, clear);
|
||||
adjust_colored_vignette(clear.bits(), 41, 41, size_t(clear.bytesPerLine()), 0, 0, 41, 41, 1, 100, 50, 100, 60, 25, 1, 0, 0);
|
||||
// The middle stays clear; the corner is red, near opaque.
|
||||
QCOMPARE(clear.pixel(20, 20), qRgba(0, 0, 0, 0));
|
||||
const QRgb corner = clear.pixel(0, 0);
|
||||
QVERIFY(qAlpha(corner) > 240 && qRed(corner) == qAlpha(corner) && qGreen(corner) == 0 && qBlue(corner) == 0);
|
||||
}
|
||||
|
||||
void VignetteCanvasTests::theCanvasFramesALayerThatSitsInsideIt()
|
||||
{
|
||||
// A 20-pixel layer at (10, 10) of a 40-pixel canvas.
|
||||
const QImage clear = BrushRaster::context(20, 20, false);
|
||||
FilterSettings settings;
|
||||
settings.vignetteAmount = 100;
|
||||
FilterJob job{FilterKind::vignette, clear, settings, 1, std::nullopt, QTransform::fromTranslate(10, 10)};
|
||||
job.canvas = QRectF(0, 0, 40, 40);
|
||||
QImage expected = clear.copy();
|
||||
adjust_colored_vignette(expected.bits(), 20, 20, size_t(expected.bytesPerLine()), -10, -10, 40, 40, 1, 100, 50, 100, 60, 25, 0, 0, 0);
|
||||
QCOMPARE(PixelFilter::run(job), expected);
|
||||
// Its own corner sits inside the frame: fainter.
|
||||
QImage framedAlone = clear.copy();
|
||||
adjust_colored_vignette(framedAlone.bits(), 20, 20, size_t(framedAlone.bytesPerLine()), 0, 0, 20, 20, 1, 100, 50, 100, 60, 25, 0, 0, 0);
|
||||
QVERIFY(qAlpha(expected.pixel(0, 0)) < qAlpha(framedAlone.pixel(0, 0)));
|
||||
}
|
||||
|
||||
void VignetteCanvasTests::onlyVignettePaintsAnEmptyLayer()
|
||||
{
|
||||
Bar bar;
|
||||
EditorSession &session = bar.session();
|
||||
session.createDocument(40, 30);
|
||||
session.addBlankLayer();
|
||||
QVERIFY(!session.canAdjustColors() && session.canVignette());
|
||||
QVERIFY(bar.action("vignette").isEnabled() && !bar.action("gaussianBlur").isEnabled());
|
||||
// Other filters refuse the empty layer.
|
||||
session.beginFilter(FilterKind::gaussianBlur);
|
||||
QVERIFY(!session.filterEdit());
|
||||
// Folders and adjustments hold no pixels to paint.
|
||||
session.addAdjustment(AdjustmentKind::curves);
|
||||
session.setAdjustmentEditingID(std::nullopt);
|
||||
QVERIFY(!session.canVignette() && !bar.action("vignette").isEnabled());
|
||||
session.addGroup();
|
||||
QVERIFY(!session.canVignette());
|
||||
}
|
||||
|
||||
void VignetteCanvasTests::anEmptyLayerTakesTheVignetteAcrossTheCanvas()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(40, 30);
|
||||
session.addBlankLayer();
|
||||
const QUuid id = session.activeLayerID().value();
|
||||
session.beginFilter(FilterKind::vignette);
|
||||
QVERIFY(prepared(session));
|
||||
QVERIFY(session.filterEdit().value().startedEmpty && session.filterEdit().value().canvas == QRectF(0, 0, 40, 30));
|
||||
// Round by default, as Swift 1.2.5 opens it.
|
||||
QCOMPARE(session.filterEdit().value().settings.vignetteRoundness, 100.0);
|
||||
QVERIFY(session.filterEdit().value().previewImage(id));
|
||||
QVERIFY(!session.activeLayer().value().asset);
|
||||
QVERIFY(committed(session));
|
||||
QCOMPARE(session.history.undoName(), QString("Vignette"));
|
||||
const ImageLayer layer = session.activeLayer().value();
|
||||
const QImage pixels = layer.asset.value().image();
|
||||
QCOMPARE(pixels.size(), QSize(40, 30));
|
||||
QCOMPARE(layer.transform.origin, QPointF(0, 0));
|
||||
// The kernel's canvas-wide fill: black edges, a clear middle.
|
||||
QImage expected = BrushRaster::context(40, 30, false);
|
||||
const FilterSettings defaults;
|
||||
adjust_colored_vignette(expected.bits(), 40, 30, size_t(expected.bytesPerLine()), 0, 0, 40, 30, 1, defaults.vignetteAmount, defaults.vignetteMidpoint,
|
||||
defaults.vignetteRoundness, defaults.vignetteFeather, defaults.vignetteHighlights, 0, 0, 0);
|
||||
QCOMPARE(pixels, expected);
|
||||
QVERIFY(qAlpha(pixels.pixel(20, 15)) == 0 && qAlpha(pixels.pixel(0, 0)) > 80);
|
||||
session.undo();
|
||||
QVERIFY(!session.activeLayer().value().asset);
|
||||
}
|
||||
|
||||
void VignetteCanvasTests::theCanvasShowsThePreviewOnAnEmptyLayer()
|
||||
{
|
||||
Shown shown(QSize(40, 30));
|
||||
shown.settle();
|
||||
shown.session.addBlankLayer();
|
||||
QCoreApplication::processEvents();
|
||||
const QImage before = shown.canvas->grab().toImage();
|
||||
shown.session.beginFilter(FilterKind::vignette);
|
||||
QVERIFY(prepared(shown.session));
|
||||
QVERIFY(shown.session.filterEdit().value().previewImage(shown.session.activeLayerID().value()));
|
||||
QTRY_VERIFY(shown.canvas->grab().toImage() != before);
|
||||
shown.session.cancelFilter();
|
||||
QTRY_COMPARE(shown.canvas->grab().toImage(), before);
|
||||
}
|
||||
|
||||
void VignetteCanvasTests::aLayerWithPixelsKeepsItsOwnFrameAndClear()
|
||||
{
|
||||
// A grey square in clear pixels, smaller than the canvas.
|
||||
EditorSession session;
|
||||
session.createDocument(48, 48);
|
||||
QImage image = BrushRaster::context(24, 24, false);
|
||||
QPainter(&image).fillRect(QRect(3, 3, 18, 18), QColor::fromRgbF(0.7, 0.7, 0.7));
|
||||
session.insert(ImportedImage(image, image, QStringLiteral("Square")));
|
||||
session.beginFilter(FilterKind::vignette);
|
||||
QVERIFY(prepared(session));
|
||||
QVERIFY(!session.filterEdit().value().startedEmpty && !session.filterEdit().value().canvas);
|
||||
QVERIFY(committed(session));
|
||||
const QImage pixels = session.activeLayer().value().asset.value().image();
|
||||
QCOMPARE(pixels.size(), QSize(24, 24));
|
||||
// Clear stays clear; the square darkens within its own frame.
|
||||
QCOMPARE(qAlpha(pixels.pixel(0, 0)), 0);
|
||||
QCOMPARE(qAlpha(pixels.pixel(3, 3)), 255);
|
||||
QVERIFY(qRed(pixels.pixel(3, 3)) < qRed(pixels.pixel(12, 12)));
|
||||
}
|
||||
|
||||
void VignetteCanvasTests::aSmallEmptyLayerGrowsToTheCanvas()
|
||||
{
|
||||
// An empty box inside the canvas, off its corner.
|
||||
EditorSession session;
|
||||
session.createDocument(40, 30);
|
||||
session.addBlankLayer();
|
||||
const QUuid id = session.activeLayerID().value();
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = LayerTransform{.origin = {3, 7}, .size = {20, 12}}; });
|
||||
session.selectLayer(id);
|
||||
session.beginFilter(FilterKind::vignette);
|
||||
QVERIFY(prepared(session));
|
||||
QCOMPARE(session.filterEdit().value().previewSource.size(), QSize(40, 30));
|
||||
QCOMPARE(session.filterEdit().value().grownTransform.value(), (LayerTransform{.origin = {0, 0}, .size = {40, 30}}));
|
||||
QVERIFY(committed(session));
|
||||
const ImageLayer layer = session.activeLayer().value();
|
||||
QCOMPARE(layer.transform, (LayerTransform{.origin = {0, 0}, .size = {40, 30}}));
|
||||
// The canvas's corner, past the old box, took the colour.
|
||||
QVERIFY(qAlpha(layer.asset.value().image().pixel(0, 0)) > 80);
|
||||
}
|
||||
|
||||
void VignetteCanvasTests::theFrameIsReadTopDown()
|
||||
{
|
||||
// Off-centre and not square, so a flip would show.
|
||||
const QImage clear = BrushRaster::context(20, 12, false);
|
||||
FilterSettings settings;
|
||||
settings.vignetteAmount = 100;
|
||||
FilterJob job{FilterKind::vignette, clear, settings, 1, std::nullopt, QTransform::fromTranslate(3, 7)};
|
||||
job.canvas = QRectF(0, 0, 40, 30);
|
||||
const QImage result = PixelFilter::run(job);
|
||||
QImage expected = clear.copy();
|
||||
adjust_colored_vignette(expected.bits(), 20, 12, size_t(expected.bytesPerLine()), -3, -7, 40, 30, 1, 100, 50, 100, 60, 25, 0, 0, 0);
|
||||
QCOMPARE(result, expected);
|
||||
QCOMPARE(qAlpha(result.pixel(0, 0)), 100);
|
||||
}
|
||||
|
||||
QTEST_MAIN(VignetteCanvasTests)
|
||||
#include "VignetteCanvasTests.moc"
|
||||
Reference in new issue
Block a user