14.9: Filter › Last Filter on Ctrl+Meta+F

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 06:28:14 -04:00
1 parent 0136d9d41a
commit 886b18dd7f
14 files changed
+306 -9

No files matched your search

+1
View File
@@ -188,6 +188,7 @@ Platform adaptations, each forced by a missing Qt analog:
- The command palette (14.6, Swift 1.4.6–1.4.9: `cb139a1`, `384632d`, `07b8b1e`, `94b57f7`, `bba92dc`, `088a5c1`, `720c121`): `UI/CommandPalette.h/.cpp` twin Swift's `CommandPalette.swift`: `CommandPaletteEntry` (an aggregate, `title` given beside `id`), `CommandPaletteSearch` (`score` and `rank`, a stable sort), `CommandPaletteMenu` (`entries` of a `QMenuBar`, `plain` for a title without its mnemonic, `&&` read as one ampersand), `CommandPaletteCommands` (`layerCommands`, `tools`, Swift's statics on the entry) and `CommandPaletteModel`. Scores work on graphemes (`QTextBoundaryFinder`) of the NFC text, Swift's Characters, which compare canonically (a decomposed accent in a recent project's name matches a composed one); `isLetter` and `isNumber` read the first code point's general category (`QChar`), where Swift reads its Alphabetic and numeric-type properties (known difference: marks and numeric ideographs). `CommandPaletteTests::scoresAreSwiftsOwn` pins scores Swift 6.0.3 computed with v1.4.9's own code. An entry holds a `QPointer` to its `QAction` and triggers it, where Swift finds the item again by its path (SwiftUI rebuilds menus; the twin's actions live with the window, and Open Recent's rebuilt entries become a warning on `omaphoto.app`, Swift's beep); Swift's app-menu skip has no twin (no app menu on Linux), and the menus are current already, synchronized on every `changed()`. Shortcuts print as `QKeySequence` native text. `UI/CommandPaletteView.h/.cpp` twin the view and controller: View › Search Commands… (Ctrl+F, first in View, `Search Commands` in `ShortcutDefinition::all` after Fit Canvas) toggles a `Qt::Popup` of 460 by 290 centred on the window's frame, so a click elsewhere puts it away (Swift's `resignKey`) and Wayland places it relative to its window; the menu's action is added to the popup, so its key, remapped too, closes it from inside. The field (17 pixels, 16 and 13 of margin) takes Up and Down (wrapping, the row scrolled into view), Return and Enter; Escape is the popup's own. The rows (`CommandPaletteRows`, painted: 13 pixels, the chosen row in the accent at 35% rounded 6, a drawn checkmark twelve points with six after for a toggle that is on, the key in a 12-pixel fixed font in `PlaceholderText`, a disabled entry in the disabled `WindowText`) run on a left release; "No commands match" shows over an empty list. Running closes the popup, activates the window and posts the entry to the event loop; a disabled entry warns and keeps it open. A hidden popup clears the focus it held: Qt returns focus only to a window's own focus widget, so the menus would otherwise keep the hidden field as the one whose undo Undo drives. Equivalent mutants: `score`'s `words.size() > 1` (one word is the phrase, which the tier before tried) and `rank`'s `!query.isEmpty()` (an empty query scores every entry 0). Tests: `CommandPaletteTests` (Swift's four model tests and Swift's scores, the model, a hand-built bar's paths, mnemonics, sections, hidden entries, numbering and a gone action, the real bar's entries and a toggle run through them, the mask commands, every tool and each named mode reached from its sibling, a refused tool), `CommandPaletteWindowTests` (Ctrl+F in the window, place, focus, Ctrl+F inside, ranking and wrapping with scrolling, Return and Enter running the chosen row once the loop is free, a disabled row, Escape, clicks in and out, the checkmark's room, a key drawn at the row's end with an elided title six points before it, the empty words).
- Canvas Only (14.7, Swift 1.4.6–1.4.7: `ad4bddf`, `088a5c1`, `720c121`): the session keeps `canvasOnly` (a getter and a setter that notifies), `escapeHasNothingToCancel` and `canToggleCanvasOnly` (Swift's terms). `ProjectWorkspaceView::toggleCanvasOnly` is Swift's delegate's: it keeps the window's state, hides the menu bar (Swift hides macOS's menu bar and Dock; the twin's menu bar is in the window) and the toolbar, and makes the window fullscreen, which on Linux also covers the desktop's own bars; again, it shows both and puts the state back. A window already fullscreen keeps the screen and hides only its toolbar, as Swift's. Swift's black frame over the window's rounded corners and rim has no twin: a fullscreen Linux window has neither. View › Toggle Fullscreen (after Search Commands, checkable, a plain F, Swift's unconfigured key; its definition joins the canvas group first, as Swift's) rests while `canToggleCanvasOnly` does not hold; a `QLineEdit` takes a plain letter through `ShortcutOverride`, so a field types its F, Swift's monitor that hands an F to an `NSText` first, and open text holds the entry resting. A hidden `QMenuBar` disables its actions' keys (Qt's shortcut context reads the bar's visibility), so every menu action is also added to the window, which keeps them live. Escape leaves (an application event filter, Swift's local monitor) when it carries no Ctrl, Alt, Meta or Shift, the window is active, nothing is left to cancel and the focus is neither a `QLineEdit` nor inside a `QDialog`, so a docked panel keeps its Escape (Swift's docked panel is a window of its own). `ContentView` hides the tool bar, the rail, the rulers, the layers panel and its edge, the status bar and their dividers (`m_panels`); `showHeader` clears `m_header` after deleting it, since a new bar's constructor signals and `synchronize` then finds no bar. The canvas's surround is black, and `DisplayState` carries the flag so it repaints. Tests: `CanvasOnlySessionTests` (the flag's signal, each gate term and each Escape term), the definitions' place in `KeyboardShortcutsTests`, `CanvasOnlyWindowTests` (F in and out with every bar and the layers edge, the canvas filling the window, keys through the hidden menus, black, Escape after a cancel and with held keys, a field's F, a docked panel's and another window's Escape, an already fullscreen or maximised window, the entry resting under text, a picker and no document).
- Camera Raw's colour noise reduction (14.8, Swift 1.4.6: `a2718d9`, `42a559b`): `kernels/AdjustPixels.c` is 42a559b's, unchanged: Color smooths each pixel's red and blue differences from its Rec. 709 brightness toward the area's (each weighted by coverage, so clear pixels add no colour and pull none toward gray), green following from the brightness kept, over a reach that grows with the amount and Color Smoothness; Color Detail keeps edges where the smoothed colour itself changes across the blur's width. Tests: `CameraRawColorNoiseTests` (Swift's two: colour grain under a quarter with brightness within a level, and a run that reads nothing clear pixels left behind, made sharp by `mallopt`: the planes on the heap, `M_PERTURB` filling what is handed out, so a plane left unwritten differs from run to run; then half the amount, a red beside clear pixels kept, an edge kept by Color Detail down and across, a speck taking its surround's colour at its own brightness, translucent colour kept, and exact pixels of the upstream kernel for a faint neighbour's coverage, Color Smoothness at 0 and 100, a partial amount, a reduced preview, a lone pixel among clear ones, edges in the red or the blue difference alone, down and across, and half the Color Detail).
- Last Filter (14.9, Swift 1.4.7: `0415aa4`, `6b81d4a`): `repeatsAsLastFilter` (the Filter menu's own kinds), `FilterEdit::repeating`, and in the session `lastFilter` (a getter and a setter that notifies, Swift's public var, which its tests write), `canRepeatLastFilter` and `repeatLastFilter`, which takes a completion callback: a pending gradient lands first (`commitGradient`'s continuation), then `beginFilter(kind, true)` opens the edit without a preview unless the kind is automatic, `commitFilter` applies it with the settings it had, and its callback cancels an edit still open, so nothing is left without a panel. `commitFilter` records the kind. Swift's `edit.repeating` check after `beginFilter` is dead and left out: the repeat commits the gradient first, so `beginFilter` either opens a repeating edit or none. Filter › Last Filter is first, then a separator, titled `Last Filter: <kind>` once one is recorded, on Ctrl+Meta+F (Swift's Command-Control F), resting while `canRepeatLastFilter` does not hold; `ShortcutDefinition::all` gains it after Select Subject. `synchronizePanels` keeps the filter panel shut for a repeating edit. Equivalent mutant: `canRepeatLastFilter`'s `m_filterEdit` term (`canAdjust` refuses an open filter; Swift's too). Tests: `LastFilterTests` (Swift's two; an Image adjustment not recorded, the setter's signal, the gates, Vignette's on an empty layer, a pending gradient as its own step, Remove Background through its preview, the menu's title, key and panel, a preview only when automatic, a failed preview closed, a blur that cannot grow), the key and Swift's unique menu keys in `KeyboardShortcutsTests`, the Filter menu in `CompositorMenusTests`.
- Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to <title>"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
+1
View File
@@ -14,6 +14,7 @@ It is a Linux port of [Compositor](https://github.com/robbietilton/Compositor) b
- Camera Raw Filter, docked beside the canvas, with Photoshop's curves
- Layer effects: stroke, drop shadow, outer and inner glow, color overlay, inner shadow
- Filters: Gaussian and motion blur, noise, lens correction, Vignette, Bloom / Glow, Tonal Contrast, Dither, Content-Aware Fill and Remove Background (U²-Net on ONNX Runtime, offline)
- Last Filter (Ctrl+Meta+F) runs the last filter again with the same settings
- Crop, trim, canvas size, image size; PNG and JPEG export with a zoomable preview; New Canvas presets for screens and social formats
- Imports JPEG, PNG, TIFF, HEIC, SVG, Photoshop files (PSD and PSB) and camera RAW
- Open Recent, and a Keyboard Shortcuts window
+4 -2
View File
@@ -52,8 +52,10 @@ void ContentView::synchronizePanels()
m_colorRangePanel.close();
}
}
if (m_filterShown != m_session.filterEdit().has_value()) {
m_filterShown = m_session.filterEdit().has_value();
// Last Filter applies without the panel.
const bool filtering = m_session.filterEdit() && !m_session.filterEdit()->repeating;
if (m_filterShown != filtering) {
m_filterShown = filtering;
if (m_filterShown) {
m_filterPanel.onClose = [this] { m_session.cancelFilter(); };
const FilterKind kind = m_session.filterEdit()->kind;
+1
View File
@@ -441,6 +441,7 @@ private:
QFutureWatcher<RasterMade> m_rasterCommit;
std::optional<FilterEdit> m_filterEdit;
FilterSettings m_filterSettings;
std::optional<FilterKind> m_lastFilter;
QFutureWatcher<Filtered> m_filterPreview;
// Out until its finish runs: setFuture drops a pending signal.
bool m_filterRendering = false;
+49 -2
View File
@@ -16,7 +16,7 @@ bool EditorSession::canContentAwareFill() const
return canAdjustColors() && selection().has_value() && !m_hueSaturation;
}
void EditorSession::beginFilter(FilterKind kind)
void EditorSession::beginFilter(FilterKind kind, bool repeating)
{
if (kind == FilterKind::contentAwareFill && !canContentAwareFill())
return;
@@ -61,7 +61,9 @@ void EditorSession::beginFilter(FilterKind kind)
if (fillsCanvas)
m_filterEdit->canvas = canvas;
m_filterEdit->startedEmpty = startedEmpty;
updateFilter(m_filterEdit->settings, true);
m_filterEdit->repeating = repeating;
// An automatic filter commits its preview, so needs one.
updateFilter(m_filterEdit->settings, !repeating || isAutomatic(kind));
} catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
@@ -69,6 +71,49 @@ void EditorSession::beginFilter(FilterKind kind)
}
}
void EditorSession::setLastFilter(std::optional<FilterKind> kind)
{
m_lastFilter = kind;
notify();
}
bool EditorSession::canRepeatLastFilter() const
{
if (!m_lastFilter || m_filterEdit || m_hueSaturation)
return false;
return m_lastFilter == FilterKind::vignette ? canVignette() : canAdjustColors();
}
void EditorSession::repeatLastFilter(std::function<void()> done)
{
if (!canRepeatLastFilter()) {
qCWarning(lcApp) << "no last filter to run here";
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
return;
}
const FilterKind kind = m_lastFilter.value();
const auto run = [this, kind, done] {
beginFilter(kind, true);
if (!m_filterEdit) {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
return;
}
// Nothing applied: no panel is left to close it.
commitFilter([this, id = m_filterEdit->id, done] {
if (m_filterEdit && m_filterEdit->id == id)
cancelFilter();
if (done)
done();
});
};
if (m_gradientEdit)
commitGradient(run);
else
run();
}
void EditorSession::updateFilter(const FilterSettings &settings, bool preview)
{
if (!m_filterEdit || m_filterEdit->committing)
@@ -243,6 +288,8 @@ void EditorSession::commitFilter(std::function<void()> done)
m_filterPreviewFor = QUuid();
if (edit.kind != FilterKind::cameraRaw)
m_filterSettings = edit.settings;
if (repeatsAsLastFilter(edit.kind))
m_lastFilter = edit.kind;
setIsProjectBusy(true);
m_committingFilter = Committing{edit.id, std::move(done)};
// A mask hides the background, which can come back.
+8 -1
View File
@@ -1,7 +1,14 @@
// Swift's Filters extension: one filter at a time.
public:
bool canContentAwareFill() const;
void beginFilter(FilterKind kind);
// `repeating` is Last Filter: no preview unless automatic, no panel.
void beginFilter(FilterKind kind, bool repeating = false);
// What Filter › Last Filter runs again.
std::optional<FilterKind> lastFilter() const { return m_lastFilter; }
void setLastFilter(std::optional<FilterKind> kind);
bool canRepeatLastFilter() const;
// The last filter again, its settings kept, one step.
void repeatLastFilter(std::function<void()> done = {});
// The settings normalized; Preview off drops the queue and preview.
void updateFilter(const FilterSettings &settings, bool preview);
void cancelFilter();
+5
View File
@@ -53,6 +53,11 @@ QString rawValue(BackgroundQuality quality)
return quality == BackgroundQuality::basic ? QStringLiteral("Basic") : QStringLiteral("Advanced");
}
bool repeatsAsLastFilter(FilterKind kind)
{
return kind != FilterKind::contentAwareFill && !isImageAdjustment(kind);
}
bool isImageAdjustment(FilterKind kind)
{
return kind == FilterKind::curves || kind == FilterKind::exposure || kind == FilterKind::gradientMap || kind == FilterKind::grain
+4
View File
@@ -26,6 +26,8 @@ QString rawValue(FilterKind kind);
bool isAutomatic(FilterKind kind);
// The Image menu's colour adjustments, not under Filter.
bool isImageAdjustment(FilterKind kind);
// The Filter menu's own, which Last Filter runs again.
bool repeatsAsLastFilter(FilterKind kind);
// Swift's allCases, the menus' order.
inline constexpr std::array allFilterKinds{FilterKind::gaussianBlur, FilterKind::motionBlur, FilterKind::addNoise,
FilterKind::vignette, FilterKind::bloomGlow, FilterKind::dither,
@@ -183,6 +185,8 @@ public:
std::optional<QRectF> canvas;
// The layer had no pixels: the filter began clear.
bool startedEmpty = false;
// Last Filter: applied at once, without preview or panel.
bool repeating = false;
private:
void grow(const QRectF &extent);
+6
View File
@@ -225,6 +225,10 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, Pro
add(image, QStringLiteral("flipCanvasVertical"), QStringLiteral("Flip Canvas Vertical"), QKeySequence(), [this] { session().flipCanvas(false); });
QMenu *filter = bar.addMenu(QStringLiteral("Fil&ter"));
// Ctrl+Meta+F, Swift's Cmd+Control: Ctrl+F searches commands.
add(filter, QStringLiteral("lastFilter"), QStringLiteral("Last Filter"), QKeySequence(Qt::CTRL | Qt::META | Qt::Key_F),
[this] { session().repeatLastFilter(); });
filter->addSeparator();
for (const FilterKind kind : allFilterKinds) {
if (kind != FilterKind::contentAwareFill && !isImageAdjustment(kind))
add(filter, filterName(kind), rawValue(kind) + QStringLiteral("…"), QKeySequence(), [this, kind] { session().beginFilter(kind); });
@@ -387,6 +391,8 @@ void CompositorMenus::synchronize()
for (const char *name : {"expandSelection", "contractSelection", "featherSelection"})
action(QString::fromLatin1(name))->setEnabled(s.canModifySelection());
action(QStringLiteral("levels"))->setEnabled(s.canAdjustColors() && !s.hueSaturation());
action(QStringLiteral("lastFilter"))->setText(s.lastFilter() ? QStringLiteral("Last Filter: ") + rawValue(s.lastFilter().value()) : QStringLiteral("Last Filter"));
action(QStringLiteral("lastFilter"))->setEnabled(s.canRepeatLastFilter());
for (const FilterKind kind : allFilterKinds) {
if (kind != FilterKind::contentAwareFill)
action(filterName(kind))->setEnabled((kind == FilterKind::vignette ? s.canVignette() : s.canAdjustColors()) && !s.hueSaturation());
+1 -1
View File
@@ -130,7 +130,7 @@ const std::vector<ShortcutDefinition> &ShortcutDefinition::all()
entry("Copy", "c", 1, true), entry("Copy Merged", "c", 9, true), entry("Paste", "v", 1, true),
entry("Fill with Foreground", backspace, 2, true), entry("Fill with Background", backspace, 1, true),
entry("Content-Aware Fill", backspace, 8, true), entry("Select All", "a", 1, true), entry("Deselect", "d", 1, true),
entry("Inverse Selection", "i", 9, true), entry("Select Subject", "a", 3, true), entry("Curves", "m", 1, true),
entry("Inverse Selection", "i", 9, true), entry("Select Subject", "a", 3, true), entry("Last Filter", "f", 5, true), entry("Curves", "m", 1, true),
entry("Levels", "l", 1, true), entry("Hue/Saturation", "u", 1, true), entry("Invert Pixels / Mask", "i", 1, true),
entry("Canvas Size", "c", 3, true), entry("Image Size", "i", 3, true), entry("Transform Layer / Selection", "t", 1, true),
entry("Duplicate / Layer via Copy", "j", 1, true), entry("Toggle Clipping Mask", "g", 3, true),
+2 -2
View File
@@ -66,11 +66,11 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
for (int index = 3; index < 8; ++index)
adjustments << menus[4]->actions()[index]->objectName();
QCOMPARE(adjustments, (QStringList{"blackWhite", "colorBalance", "exposure", "gradientMap", "grain"}));
// Swift's Filter menu: Camera Raw after Lens Correction.
// Swift's Filter menu: Last Filter; Camera Raw after Lens Correction.
QStringList filters;
for (QAction *entry : menus[5]->actions())
filters << entry->text();
QCOMPARE(filters, (QStringList{"Gaussian Blur…", "Motion Blur…", "Add Noise…", "Vignette…", "Bloom / Glow…", "Dither…", "Tonal Contrast…", "Lens Correction…", "Camera Raw Filter…", "Remove Background…"}));
QCOMPARE(filters, (QStringList{"Last Filter", "", "Gaussian Blur…", "Motion Blur…", "Add Noise…", "Vignette…", "Bloom / Glow…", "Dither…", "Tonal Contrast…", "Lens Correction…", "Camera Raw Filter…", "Remove Background…"}));
QCOMPARE(menus[6]->actions().size(), 23);
}
+11 -1
View File
@@ -93,7 +93,7 @@ void KeyboardShortcutsTests::theListIsSwiftsWithoutHide()
{
const std::vector<ShortcutDefinition> &all = ShortcutDefinition::all();
// Swift's 113 with Ungroup Layers, less Hide Compositor.
QCOMPARE(int(all.size()), 114);
QCOMPARE(int(all.size()), 115);
QSet<QString> ids;
for (const ShortcutDefinition &definition : all)
ids.insert(definition.id());
@@ -119,6 +119,16 @@ void KeyboardShortcutsTests::theListIsSwiftsWithoutHide()
QCOMPARE(fullscreen.original, ShortcutChord("f"));
QCOMPARE(fullscreen.group, QString("Canvas & Layers"));
QCOMPARE(std::ranges::find_if(all, [](const ShortcutDefinition &d) { return !d.isMenu(); })->title, fullscreen.title);
QCOMPARE(named("Last Filter").original, ShortcutChord("f", 5));
// Swift's: no two menu keys are the same.
QSet<QString> chords;
int menus = 0;
for (const ShortcutDefinition &definition : all)
if (definition.isMenu()) {
chords.insert(definition.original.label());
++menus;
}
QCOMPARE(int(chords.size()), menus);
// Swift's defaults hold together.
QVERIFY(!ShortcutSettings::problem({}));
}
+212
View File
@@ -0,0 +1,212 @@
#include "ContentView.h"
#include "MenuFixtures.h"
#include "Document/DocumentLimits.h"
#include <QPainter>
// Filter › Last Filter: the last filter again, same settings.
namespace {
// Swift's fixture: 40 by 20, the left half white.
std::unique_ptr<EditorSession> halfWhite()
{
auto session = std::make_unique<EditorSession>();
session->createDocument(40, 20);
QImage image = BrushRaster::context(40, 20, false);
QPainter(&image).fillRect(QRect(0, 0, 20, 20), Qt::white);
session->insert(ImportedImage(image, image, QStringLiteral("Half")));
return session;
}
bool committed(EditorSession &session)
{
bool done = false;
session.commitFilter([&done] { done = true; });
return QTest::qWaitFor([&done] { return done; }, 20000);
}
bool repeated(EditorSession &session)
{
bool done = false;
session.repeatLastFilter([&done] { done = true; });
return QTest::qWaitFor([&done] { return done; }, 20000);
}
ImageLayer active(const EditorSession &session)
{
return session.activeLayer().value();
}
}
class LastFilterTests : public QObject {
Q_OBJECT
private slots:
void runsTheLastFilterAgainWithItsSettingsAsOneUndoStep();
void aCancelledFilterOrAnImageAdjustmentIsNotTheLastFilter();
void theGatesAndAPendingGradient();
void anAutomaticFilterRunsAgainThroughItsPreview();
void theMenuNamesItAndRunsWithoutThePanel();
void aRepeatThatCannotBeginOrLandLeavesNothingOpen();
};
void LastFilterTests::runsTheLastFilterAgainWithItsSettingsAsOneUndoStep()
{
const auto session = halfWhite();
QVERIFY(!session->canRepeatLastFilter() && !session->lastFilter());
session->beginFilter(FilterKind::gaussianBlur);
FilterSettings settings;
settings.radius = 3;
session->updateFilter(settings, true);
QVERIFY(committed(*session));
const ImageLayer once = active(*session);
QVERIFY(session->lastFilter() == FilterKind::gaussianBlur && session->canRepeatLastFilter());
const int count = session->history.undoCount();
QVERIFY(repeated(*session));
const ImageLayer twice = active(*session);
QVERIFY(!session->filterEdit() && session->history.undoCount() == count + 1 && session->history.undoName() == "Gaussian Blur");
QVERIFY(twice.asset->identity() != once.asset->identity());
// Blurred by 3 again, it spreads past the edge further.
QVERIFY(twice.transform.size.width() > once.transform.size.width());
QCOMPARE(session->filterSettings().radius, 3.0);
session->undo();
QVERIFY(active(*session).asset->identity() == once.asset->identity());
}
void LastFilterTests::aCancelledFilterOrAnImageAdjustmentIsNotTheLastFilter()
{
const auto session = halfWhite();
session->beginFilter(FilterKind::addNoise);
QVERIFY(committed(*session));
session->beginFilter(FilterKind::gaussianBlur);
session->cancelFilter();
session->beginFilter(FilterKind::curves);
session->cancelFilter();
QCOMPARE(session->lastFilter(), std::optional(FilterKind::addNoise));
// An Image menu adjustment committed is not recorded either.
session->beginFilter(FilterKind::exposure);
FilterSettings brighter = session->filterSettings();
brighter.exposure.exposure = 1;
session->updateFilter(brighter, true);
QVERIFY(committed(*session));
QCOMPARE(session->lastFilter(), std::optional(FilterKind::addNoise));
// Nothing to do: no step, and nothing left open.
QSignalSpy changes(session.get(), &EditorSession::changed);
session->setLastFilter(FilterKind::lensCorrection);
QCOMPARE(changes.count(), 1);
const int count = session->history.undoCount();
QVERIFY(repeated(*session));
QVERIFY(!session->filterEdit() && session->history.undoCount() == count && !session->isProjectBusy());
QVERIFY(!repeatsAsLastFilter(FilterKind::contentAwareFill) && !repeatsAsLastFilter(FilterKind::grain) && repeatsAsLastFilter(FilterKind::dither));
}
void LastFilterTests::theGatesAndAPendingGradient()
{
const auto session = halfWhite();
session->setLastFilter(FilterKind::addNoise);
QVERIFY(session->canRepeatLastFilter());
session->beginFilter(FilterKind::gaussianBlur);
QVERIFY(!session->canRepeatLastFilter());
// A refused run warns and calls back, changing nothing.
QTest::ignoreMessage(QtWarningMsg, "no last filter to run here");
QVERIFY(repeated(*session));
QCOMPARE(session->filterEdit()->kind, FilterKind::gaussianBlur);
session->cancelFilter();
session->beginHueSaturation();
QVERIFY(session->hueSaturation() && !session->canRepeatLastFilter());
session->cancelHueSaturation();
// Vignette asks Vignette's gate: an empty layer takes it.
session->addBlankLayer();
QVERIFY(!session->canRepeatLastFilter());
session->setLastFilter(FilterKind::vignette);
QVERIFY(session->canRepeatLastFilter());
// A pending gradient lands first, then the filter: two steps.
session->setLastFilter(FilterKind::addNoise);
session->selectLayer(session->document()->layers.front().id);
session->selectTool(NavigationTool::gradient);
session->beginGradient(QPointF(2, 2));
session->moveGradient(std::nullopt, QPointF(30, 2));
session->endGradientDrag();
QVERIFY(session->gradientEdit());
const int count = session->history.undoCount();
QVERIFY(repeated(*session));
QVERIFY(!session->gradientEdit() && session->history.undoCount() == count + 2 && session->history.undoName() == "Add Noise");
}
void LastFilterTests::anAutomaticFilterRunsAgainThroughItsPreview()
{
// Remove Background commits what its preview made.
auto session = std::make_unique<EditorSession>();
session->createDocument(400, 300);
QImage image = BrushRaster::context(400, 300, false);
image.fill(Qt::white);
QPainter painter(&image);
painter.setPen(Qt::NoPen);
painter.setBrush(QColor(217, 26, 26));
painter.drawEllipse(QPointF(200, 150), 80, 80);
painter.end();
session->insert(ImportedImage(image, image, QStringLiteral("Disc")));
session->setLastFilter(FilterKind::removeBackground);
const int count = session->history.undoCount();
QVERIFY(repeated(*session));
QVERIFY(session->history.undoCount() == count + 1 && session->history.undoName() == "Remove Background");
QVERIFY(active(*session).mask && !session->filterEdit());
}
void LastFilterTests::theMenuNamesItAndRunsWithoutThePanel()
{
Bar bar;
bar.window.show();
QVERIFY(QTest::qWaitForWindowActive(&bar.window));
QAction &entry = bar.action("lastFilter");
QVERIFY(entry.text() == "Last Filter" && !entry.isEnabled());
QCOMPARE(entry.shortcut(), QKeySequence(Qt::CTRL | Qt::META | Qt::Key_F));
bar.session().createDocument(40, 20);
bar.session().insert(white(), QPointF(20, 10));
bar.session().beginFilter(FilterKind::addNoise);
QTRY_VERIFY(bar.window.findChild<QWidget *>("filterPanel") && bar.window.findChild<QWidget *>("filterPanel")->isVisible());
QVERIFY(committed(bar.session()));
QTRY_VERIFY(!bar.window.findChild<QWidget *>("filterPanel")->isVisible());
QVERIFY(entry.text() == "Last Filter: Add Noise" && entry.isEnabled());
// Its key runs it at once; the panel never shows.
bool panelShown = false;
QObject::connect(&bar.session(), &EditorSession::changed, &bar.window, [&] {
panelShown = panelShown || bar.window.findChild<QWidget *>("filterPanel")->isVisible();
});
const int count = bar.session().history.undoCount();
QTest::keyClick(&bar.window, Qt::Key_F, Qt::ControlModifier | Qt::MetaModifier);
QTRY_COMPARE(bar.session().history.undoCount(), count + 1);
QTRY_VERIFY(!bar.session().isProjectBusy());
QVERIFY(!panelShown && !bar.session().filterEdit());
// While a filter is open it rests.
bar.session().beginFilter(FilterKind::gaussianBlur);
QVERIFY(!entry.isEnabled());
bar.session().cancelFilter();
QVERIFY(entry.isEnabled());
}
void LastFilterTests::aRepeatThatCannotBeginOrLandLeavesNothingOpen()
{
// A repeat begins without a preview, unless automatic.
const auto session = halfWhite();
session->beginFilter(FilterKind::gaussianBlur, true);
QVERIFY(session->filterEdit()->repeating && !session->filterEdit()->preview);
session->cancelFilter();
session->beginFilter(FilterKind::removeBackground, true);
QVERIFY(session->filterEdit()->repeating && session->filterEdit()->preview);
session->cancelFilter();
// No subject: the preview fails, and the repeat closes it.
session->setLastFilter(FilterKind::removeBackground);
const int count = session->history.undoCount();
QVERIFY(repeated(*session));
QVERIFY(!session->filterEdit() && session->history.undoCount() == count && !session->isProjectBusy());
// A blur that cannot grow begins nothing: an error.
EditorSession wide;
wide.createDocument(100, 10);
QImage line = BrushRaster::context(DocumentLimits::maxSide - 4, 1, false);
line.fill(Qt::white);
wide.insert(ImportedImage(line, QImage(), QStringLiteral("Line")));
wide.setLastFilter(FilterKind::gaussianBlur);
QVERIFY(repeated(wide));
QVERIFY(!wide.filterEdit() && wide.brushError() && !wide.isProjectBusy());
}
QTEST_MAIN(LastFilterTests)
#include "LastFilterTests.moc"
+1
View File
@@ -307,6 +307,7 @@ compositor_test(ToolDefaultsTests)
compositor_test(ColorAdjustmentTests)
compositor_test(CameraRawTests)
compositor_test(CameraRawColorNoiseTests)
compositor_test(LastFilterTests)
compositor_test(CameraRawSessionTests)
compositor_test(CameraRawToolsTests)
compositor_test(CameraRawRulesTests)