From e126762d5b9881e1050efd25ee40953ec27e840c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 11 Oct 2026 04:58:06 -0400 Subject: [PATCH] 14.6: the command palette on Ctrl+F --- AGENTS.md | 1 + CMakeLists.txt | 2 + README.md | 1 + src/UI/CommandPalette.cpp | 236 ++++++++++++++++++++++++++++ src/UI/CommandPalette.h | 64 ++++++++ src/UI/CommandPaletteView.cpp | 231 +++++++++++++++++++++++++++ src/UI/CommandPaletteView.h | 76 +++++++++ src/UI/CompositorMenus.cpp | 3 + src/UI/CompositorMenus.h | 2 + src/UI/KeyboardShortcuts.cpp | 2 +- tests/CommandPaletteTests.cpp | 228 +++++++++++++++++++++++++++ tests/CommandPaletteWindowTests.cpp | 181 +++++++++++++++++++++ tests/CompositorMenusTests.cpp | 2 +- tests/KeyboardShortcutsTests.cpp | 2 +- tests/Tests.cmake | 2 + 15 files changed, 1030 insertions(+), 3 deletions(-) create mode 100644 src/UI/CommandPalette.cpp create mode 100644 src/UI/CommandPalette.h create mode 100644 src/UI/CommandPaletteView.cpp create mode 100644 src/UI/CommandPaletteView.h create mode 100644 tests/CommandPaletteTests.cpp create mode 100644 tests/CommandPaletteWindowTests.cpp diff --git a/AGENTS.md b/AGENTS.md index 1f50b72..7e00031 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,6 +185,7 @@ Platform adaptations, each forced by a missing Qt analog: - RAW rounding and Importing… (14.2, Swift 1.4.6: `6345432`, `196342c`, `6e6ca38`, `0907f51`): `kernels/DitherPixels.c/.h` gain `196342c`'s `hash_noise` and `dither_quantize16`, its block body moved into `quantize_band` with a context struct, as 13.17's bands. `RawImporter::develop` works the look at 16 bits (`lround(65535 × …)`, opaque) into a buffer it allocates first (`ExportError(render)` without memory), then rounds it to 8 bits through `dither_quantize16`, where Swift renders Core Image at 16 bits and does the same; Swift's half-float working format names Core Image's own and has no twin. The session's `rawImporting` and `endRawDevelop` are Swift's: Import keeps the request and sets `rawImporting` while an answer waits, Cancel or an answer nobody waits for ends at once, and `finishDecode` ends it once the frame lands or fails (Swift's `defer`), after the layer is in. `RawDevelopSheet::setImporting` shows Swift's plate (a 32-point busy bar over "Importing…" in `PlaceholderText`, padded 16, on the window colour at 85% rounded 10), hides the preview's spinner, rests the sliders, Reset, Cancel and Import, and filters its dialog's Close and Escape while importing (Swift's `interactiveDismissDisabled`). Tests read RAW tones as a mean over the frame, rounded, since the noise moves single pixels a level; Qt's area average truncates, so a fitted preview reads a level low (`theQueueKeepsThePreviewsDecodeUntilReleased`). Holding the global pool freezes painting (Qt's raster engine fills on it), so the sheet's tests read its state before the event loop runs the develop, or use a frame large enough to take a while. Tests: `RawDitherTests` (Swift's two on the kernel; the triangular noise's three in four kept, alpha never passed, a develop dithered and repeatable), `RawDevelopTests` (importing, cancelling, failing, an answer nobody waits for, the plate, the locked dialog, the spinner and Reset while importing). - New Canvas in units, at a DPI, on a background (14.3, Swift 1.4.6: `b0b4f01`, `ab9779a`, `a00d8b6`): `NewCanvasUnit` and `NewCanvasBackground` (in `NewCanvasSheet.h`, Swift's file) are enums with free functions for Swift's members (`rawValue`, `name`, `next`, `pixels`, `text`; `title`, `next`, `color`). `pixels` trims Foundation's `.whitespaces` (`trimmedWhitespaces`), reads a comma as the point and parses as Swift's `Double(text)` (the whole text through `strtod_l` in the C locale, a leading white space refused), takes a positive number, turns a print size into pixels at the DPI rounded half away from zero, and wants a whole count from 1 to 30,000; Swift's `isFinite` term is dead (infinity fails the range, NaN the whole number) and left out. `text` prints whole pixels, or a print size to two decimals with none trailing, rounded as Foundation's format style rounds: the shortest decimal that reads back as the double (`QLocale::FloatingPointShortest`), then halves to even on its digits (1.905 cm prints 1.9, 3.175 prints 3.18, where rounding the binary value gives 1.91 and 3.17); checked once against Swift 6.0.3 for 1 to 4,000 pixels in every unit at 72 and 300 DPI, all 24,000 alike. The sheet starts in pixels at 72 DPI on a clear canvas each time (`a00d8b6`), and its three settings are Swift's `CyclePill`s, `QAbstractButton`s that paint their text in `PlaceholderText` at 12 pixels and, hovered, a capsule of the text colour at 10% (macOS's quaternary label): the background (Transparent, White, Black canvas), the unit (switching rewrites both fields in the new unit when both read, else leaves them) and the DPI (72 or 300; in a print unit its tooltip adds the pixels it makes). The fields' units follow the unit; the note shows only for a size that does not read, in the unit's words; presets and the clipboard's size are written in the unit. `createNewProject` and `createDocument` take the resolution (1 to 9,600, else 72) and the background; `firstLayer` makes a Background layer of that colour at full size with its thumbnail, or a blank Layer 1, and a background that cannot be allocated logs on `omaphoto.app` and falls back to the blank layer, Swift's `try?` (untested: only starvation reaches it). Tests: `NewCanvasUnitTests` (Swift's four; rounding, trimming, the DPI's ends, the thumbnail), `NewCanvasSheetTests` (the pills, the units, the DPI's tooltip, presets in units, Swift's number parsing). - Rotate Canvas 90° (14.4, Swift 1.4.6: `754f188`): `Document/CanvasRotation.cpp` twins Swift's file, `CanvasRotation+Session.h` its session half; `LayerTransform::quarterTurned` and `CanvasGuide::quarterTurned` are declared in their own headers and defined there, as `mirrored` is. `rotateCanvas` commits a transform edit and drops a crop frame first, asks `canEditLayers`, then turns every layer and placed mask about the canvas (the middle carried, the angle by ±90 through `fmod`, which keeps Swift's sign), swaps the sides in place, turns the guides and the selection's path (its feather and antialiasing kept), fits the view, one `Rotate Canvas 90° Clockwise` or `Counterclockwise` step; Swift's `finishOpacityEdit` is dead (`commitTransform` runs it, as `flipCanvas` notes) and left out. `restore` fits the view when the canvas changes size too. The Image menu lists both entries before the flips, enabled by `canEditLayers`. A fix the item's exact test found: QPainter turned a layer through `rotateRadians`, whose cos(π/2) is 6e-17, so a turned edge missed its pixel boundary and antialiasing shaved it, and its fixed-point bilinear fetch strayed a level or two (40 read 39) where CoreGraphics' low quality copies a pixel-aligned turn exactly. Every layer placement now turns through `LayerTransform::degrees()` and `QTransform::rotate`, which Qt builds exactly at ±90, ±270 and 180 and as `rotateRadians` elsewhere, and `LayerRenderer::upright` keeps Swift's upright (no turn) and adds a draw that takes image pixels onto device pixels one to one: the whole map from the image's pixels through its target rect, the placement and the painter's device transform is a signed permutation (no stretch on either axis, no shear, no perspective, the painter's own turn counted), within a thousandth of a pixel across the image, with the image's corner on a whole device pixel. Such a draw takes no filter; a turn half a pixel off, a stretched side or a turned painter still filters, as CoreGraphics' low quality blends them. Equivalent mutants: `degrees()` in `BrushStroke`'s grid map, `Distort` and `LayerMask` (the two matrices differ by 1e-16 there; the change keeps every placement one turn). Tests: `CanvasRotationTests` (Swift's four, a turned layer and a placed mask, the crop frame, the refit, refusals and steps), `SharpCanvasTests::aQuarterTurnOnWholePixelsCopiesAcross` (the rule's edges: every quarter, a nudge on either axis, a stretched side, a 30,000-pixel side leaning 0.0001°, painters turned, sheared and in perspective; plain and painted layers exact on whole pixels and filtered half off or stretched), `everyLayerAndEveryPlacedMaskTurns`, `anOpenEditClosesBeforeTheTurn` (an opacity drag and a pending transform each their own step), the menu in `ImageMenuTests`. +- 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`), Swift's Characters; `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's mode and a refused tool), `CommandPaletteWindowTests` (Ctrl+F in the window, place, focus, Ctrl+F inside, ranking and wrapping with scrolling, Return and Enter running once the loop is free, a disabled row, Escape, clicks in and out, the checkmark's room, the empty words). - 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 "), 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. diff --git a/CMakeLists.txt b/CMakeLists.txt index 48dba20..c50bb16 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -115,6 +115,8 @@ add_library(compositor_core STATIC src/UI/ColorPaletteControls.cpp src/UI/ColorPickerSheet.cpp src/UI/ColorPickerSheet+Dialog.cpp + src/UI/CommandPalette.cpp + src/UI/CommandPaletteView.cpp src/UI/CompositorMenus.cpp src/UI/LayerAppearanceControls.cpp src/UI/LayerIcons.cpp diff --git a/README.md b/README.md index 718c97f..49b7e00 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ It is a Linux port of [Compositor](https://github.com/robbietilton/Compositor) b - 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 +- Search Commands (Ctrl+F): find every menu command and tool by name and run it with Return - Drag a number's label to scrub its value - Keep working while a project saves - Project tabs that drag to reorder; those that do not fit gather in a menu diff --git a/src/UI/CommandPalette.cpp b/src/UI/CommandPalette.cpp new file mode 100644 index 0000000..bead13d --- /dev/null +++ b/src/UI/CommandPalette.cpp @@ -0,0 +1,236 @@ +#include "UI/CommandPalette.h" +#include "Logging.h" +#include <QPointer> +#include <QTextBoundaryFinder> +#include <algorithm> +#include <map> + +namespace { +// Swift's Characters: one string a grapheme. +QStringList characters(const QString &text) +{ + QStringList result; + QTextBoundaryFinder finder(QTextBoundaryFinder::Grapheme, text); + qsizetype start = 0; + while (finder.toNextBoundary() != -1) { + result << text.mid(start, finder.position() - start); + start = finder.position(); + } + return result; +} + +char32_t first(const QString &character) +{ + return character.toUcs4().value(0); +} + +bool isLetter(const QString &character) +{ + return QChar::isLetter(first(character)); +} + +// Each word starts a word of `title`, in order. +bool wordsStartWords(const QStringList &words, const QStringList &title) +{ + qsizetype from = 0; + for (const QString &word : words) { + const QStringList wanted = characters(word); + std::optional<qsizetype> found; + for (qsizetype at = from; at + wanted.size() <= title.size() && !found; ++at) + if (std::equal(wanted.begin(), wanted.end(), title.begin() + at) && (at == 0 || !isLetter(title[at - 1]))) + found = at + wanted.size(); + if (!found) + return false; + from = found.value(); + } + return true; +} + +void collect(const QList<QAction *> &items, const QStringList &path, const std::set<QString> &skipping, std::map<QString, int> &seen, + std::vector<CommandPaletteEntry> &result) +{ + for (QAction *item : items) { + const QString name = CommandPaletteMenu::plain(item->text()); + if (item->isSeparator() || !item->isVisible() || skipping.contains(name) || name.isEmpty()) + continue; + if (item->menu()) { + collect(item->menu()->actions(), path + QStringList{name}, skipping, seen, result); + continue; + } + const QString title = (path + QStringList{name}).join(QStringLiteral(" › ")); + const int count = seen[title]++; + const QKeySequence key = item->shortcut(); + result.push_back({.id = count == 0 ? title : QStringLiteral("%1 (%2)").arg(title).arg(count + 1), + .title = title, + .shortcut = key.isEmpty() ? std::nullopt : std::optional(key.toString(QKeySequence::NativeText)), + .isEnabled = item->isEnabled(), + .isOn = item->isChecked(), + .perform = [item = QPointer<QAction>(item), title] { + if (!item) { + qCWarning(lcApp) << "palette: menu entry gone:" << title; + return; + } + item->trigger(); + }}); + } +} +} + +std::optional<int> CommandPaletteSearch::score(const QString &query, const QString &text) +{ + QStringList wanted; + for (const QString &character : characters(query.toLower())) + if (!QChar::isSpace(first(character))) + wanted << character; + if (wanted.isEmpty()) + return 0; + const QStringList letters = characters(text.toLower()); + int total = 0; + qsizetype from = 0; + qsizetype previous = -2; + for (const QString &character : wanted) { + const qsizetype found = letters.indexOf(character, from); + if (found < 0) + return std::nullopt; + const bool wordStart = found == 0 || !(isLetter(letters[found - 1]) || QChar::isNumber(first(letters[found - 1]))); + total += 1 + (wordStart ? 8 : 0) + (found == previous + 1 ? 5 : 0); + previous = found; + from = found + 1; + } + // How the text was found, best first; scattered letters last. + const QString title = letters.join(QString()); + const QString phrase = trimmedWhitespaces(query.toLower()); + const QStringList words = phrase.split(QLatin1Char(' '), Qt::SkipEmptyParts); + int tier = 1; + if (wordsStartWords({phrase}, letters)) + tier = 5; + else if (words.size() > 1 && wordsStartWords(words, letters)) + tier = 4; + else if (title.contains(phrase)) + tier = 3; + else if (QString(title).remove(QLatin1Char(' ')).contains(wanted.join(QString()))) + tier = 2; + // Same tier: shorter title wins; scattered letters by fall. + return tier * 1'000'000 + (tier == 1 ? total * 100 : 0) - int(letters.size() / 4); +} + +std::vector<CommandPaletteEntry> CommandPaletteSearch::rank(const std::vector<CommandPaletteEntry> &entries, const QString &query) +{ + struct Scored { + const CommandPaletteEntry *entry; + int score; + }; + std::vector<Scored> scored; + for (const CommandPaletteEntry &entry : entries) + if (const std::optional<int> found = score(query, entry.title)) + scored.push_back({&entry, found.value()}); + std::stable_sort(scored.begin(), scored.end(), [&](const Scored &a, const Scored &b) { + if (a.entry->isEnabled != b.entry->isEnabled) + return a.entry->isEnabled; + return !query.isEmpty() && a.score > b.score; + }); + std::vector<CommandPaletteEntry> result; + for (const Scored &each : scored) + result.push_back(*each.entry); + return result; +} + +std::vector<CommandPaletteEntry> CommandPaletteMenu::entries(const QMenuBar &bar, const std::set<QString> &skipping) +{ + std::map<QString, int> seen; + std::vector<CommandPaletteEntry> result; + collect(bar.actions(), {}, skipping, seen, result); + return result; +} + +QString CommandPaletteMenu::plain(const QString &text) +{ + QString result; + for (qsizetype at = 0; at < text.size(); ++at) { + if (text[at] != QLatin1Char('&')) + result += text[at]; + else if (at + 1 < text.size() && text[at + 1] == QLatin1Char('&')) + result += text[++at]; + } + return result; +} + +std::vector<CommandPaletteEntry> CommandPaletteCommands::layerCommands(EditorSession &session) +{ + const std::optional<ImageLayer> active = session.activeLayer(); + const bool canAdd = session.canEditMask() && !(active && active->mask); + const bool selected = session.selection().has_value(); + const auto add = [&session](bool revealing) { return [session = QPointer(&session), revealing] { if (session) session->addMask(revealing); }; }; + const QString reveal = selected ? QStringLiteral("Layer › Add Layer Mask from Selection") : QStringLiteral("Layer › Add Layer Mask"); + const QString hide = selected ? QStringLiteral("Layer › Add Layer Mask Hiding Selection") : QStringLiteral("Layer › Add Layer Mask (Hide All)"); + return {{.id = reveal, .title = reveal, .shortcut = std::nullopt, .isEnabled = canAdd, .isOn = false, .perform = add(true)}, + {.id = hide, .title = hide, .shortcut = std::nullopt, .isEnabled = canAdd, .isOn = false, .perform = add(false)}}; +} + +std::vector<CommandPaletteEntry> CommandPaletteCommands::tools(EditorSession &session) +{ + using Setup = std::function<void(EditorSession &)>; + const std::vector<std::tuple<QString, QString, NavigationTool, Setup>> tools{ + {"Move / Transform", "V", NavigationTool::move, nullptr}, + {"Rectangular Marquee", "M", NavigationTool::marquee, [](EditorSession &s) { s.setMarqueeKind(LassoKind::rectangle); }}, + {"Elliptical Marquee", "M", NavigationTool::marquee, [](EditorSession &s) { s.setMarqueeKind(LassoKind::ellipse); }}, + {"Lasso", "L", NavigationTool::lasso, [](EditorSession &s) { s.setLassoKind(LassoKind::freehand); }}, + {"Polygonal Lasso", "L", NavigationTool::lasso, [](EditorSession &s) { s.setLassoKind(LassoKind::polygonal); }}, + {"Magic Wand", "W", NavigationTool::wand, [](EditorSession &s) { s.setWandMode(WandMode::wand); }}, + {"Object Selection", "W", NavigationTool::wand, [](EditorSession &s) { s.setWandMode(WandMode::object); }}, + {"Crop", "C", NavigationTool::crop, nullptr}, + {"Brush", "B", NavigationTool::brush, [](EditorSession &s) { s.setBrushMode(BrushToolMode::paint); }}, + {"Eraser", "E", NavigationTool::brush, [](EditorSession &s) { s.setBrushMode(BrushToolMode::erase); }}, + {"Spot Healing Brush", "J", NavigationTool::spotHealing, nullptr}, + {"Clone Stamp", "S", NavigationTool::cloneStamp, nullptr}, + {"Liquify", "R", NavigationTool::blur, [](EditorSession &s) { s.setBlurMode(BlurToolMode::liquify); }}, + {"Blur", "R", NavigationTool::blur, [](EditorSession &s) { s.setBlurMode(BlurToolMode::blur); }}, + {"Smudge", "R", NavigationTool::blur, [](EditorSession &s) { s.setBlurMode(BlurToolMode::smudge); }}, + {"Gradient", "G", NavigationTool::gradient, nullptr}, + {"Rectangle", "U", NavigationTool::shape, [](EditorSession &s) { s.setShapeKind(ShapeKind::rectangle); }}, + {"Ellipse", "U", NavigationTool::shape, [](EditorSession &s) { s.setShapeKind(ShapeKind::ellipse); }}, + {"Line", "U", NavigationTool::shape, [](EditorSession &s) { s.setShapeKind(ShapeKind::line); }}, + {"Type", "T", NavigationTool::type, nullptr}, + {"Eyedropper", "I", NavigationTool::eyedropper, nullptr}, + {"Hand", "H", NavigationTool::hand, nullptr}, + {"Zoom", "Z", NavigationTool::zoom, nullptr}}; + std::vector<CommandPaletteEntry> result; + for (const auto &[name, key, tool, setup] : tools) { + const QString id = QStringLiteral("Tool › %1").arg(name); + result.push_back({.id = id, .title = id, .shortcut = key, .isEnabled = session.document().has_value(), .isOn = false, + .perform = [session = QPointer(&session), tool, setup] { + if (!session) + return; + session->selectTool(tool); + // Only once the tool is in hand. + if (session->tool() == tool && setup) + setup(*session); + }}); + } + return result; +} + +void CommandPaletteModel::setQuery(const QString &query) +{ + if (query != m_query) + selection = 0; + m_query = query; +} + +std::optional<CommandPaletteEntry> CommandPaletteModel::selected() const +{ + const std::vector<CommandPaletteEntry> found = results(); + if (selection < 0 || selection >= int(found.size())) + return std::nullopt; + return found[size_t(selection)]; +} + +void CommandPaletteModel::move(int step) +{ + const int count = int(results().size()); + if (count == 0) { + selection = 0; + return; + } + selection = ((selection + step) % count + count) % count; +} diff --git a/src/UI/CommandPalette.h b/src/UI/CommandPalette.h new file mode 100644 index 0000000..145bd43 --- /dev/null +++ b/src/UI/CommandPalette.h @@ -0,0 +1,64 @@ +#pragma once +#include "Document/EditorSession.h" +#include <QMenuBar> +#include <functional> +#include <optional> +#include <set> +#include <vector> + +// One thing the palette runs: a menu command or tool. +struct CommandPaletteEntry { + // The title, numbered when an earlier entry shares it. + QString id; + // Its full path, shown and searched: "Filter › Gaussian Blur…". + QString title; + // Its current key, "Ctrl+Alt+P", or none. + std::optional<QString> shortcut; + // Disabled now: listed, greyed, never run. + bool isEnabled = true; + // A toggle on now, checked as in its menu. + bool isOn = false; + std::function<void()> perform; +}; + +// Fuzzy matching: letters in order, word starts and runs count. +namespace CommandPaletteSearch { +std::optional<int> score(const QString &query, const QString &text); +// Matches best first, enabled ahead; no query keeps menu order. +std::vector<CommandPaletteEntry> rank(const std::vector<CommandPaletteEntry> &entries, const QString &query); +} + +// The menu bar as entries, read each time it opens. +namespace CommandPaletteMenu { +// `skipping` holds titles left out: whole menus or single entries. +std::vector<CommandPaletteEntry> entries(const QMenuBar &bar, const std::set<QString> &skipping); +// A menu title without its mnemonic: "Fil&ter" is Filter. +QString plain(const QString &text); +} + +namespace CommandPaletteCommands { +// The Layers panel's mask button, so the palette finds it. +std::vector<CommandPaletteEntry> layerCommands(EditorSession &session); +// Every tool and each of its modes, by name. +std::vector<CommandPaletteEntry> tools(EditorSession &session); +} + +// The query, the ranked results and which one is chosen. +class CommandPaletteModel { +public: + explicit CommandPaletteModel(std::vector<CommandPaletteEntry> entries) : m_entries(std::move(entries)) {} + + const std::vector<CommandPaletteEntry> &entries() const { return m_entries; } + const QString &query() const { return m_query; } + // Only a real change goes back to the top. + void setQuery(const QString &query); + int selection = 0; + std::vector<CommandPaletteEntry> results() const { return CommandPaletteSearch::rank(m_entries, m_query); } + std::optional<CommandPaletteEntry> selected() const; + // Up and Down, wrapping at either end. + void move(int step); + +private: + std::vector<CommandPaletteEntry> m_entries; + QString m_query; +}; diff --git a/src/UI/CommandPaletteView.cpp b/src/UI/CommandPaletteView.cpp new file mode 100644 index 0000000..4f85903 --- /dev/null +++ b/src/UI/CommandPaletteView.cpp @@ -0,0 +1,231 @@ +#include "UI/CommandPaletteView.h" +#include "Logging.h" +#include <QKeyEvent> +#include <QLabel> +#include <QPainter> +#include <QApplication> +#include <QFontDatabase> +#include <QPainterPath> +#include <QVBoxLayout> + +namespace { +constexpr int panelWidth = 460; +constexpr int panelHeight = 290; +constexpr int outer = 6; + +QFont sized(QFont font, int pixels) +{ + font.setPixelSize(pixels); + return font; +} +} + +const std::set<QString> CommandPaletteController::skipped{QStringLiteral("Search Commands…")}; + +CommandPaletteRows::CommandPaletteRows(CommandPaletteModel &model, std::function<void(const CommandPaletteEntry &)> run, QWidget *parent) + : QWidget(parent), m_model(model), m_run(std::move(run)) +{ + setFont(sized(font(), 13)); +} + +int CommandPaletteRows::rowHeight() const +{ + return fontMetrics().height() + 12; +} + +QRect CommandPaletteRows::rowRect(int index) const +{ + return QRect(outer, outer + index * rowHeight(), width() - 2 * outer, rowHeight()); +} + +void CommandPaletteRows::refresh() +{ + m_shown = m_model.results(); + setFixedHeight(outer + int(m_shown.size()) * rowHeight()); + update(); +} + +void CommandPaletteRows::paintEvent(QPaintEvent *event) +{ + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const QFont shortcutFont = sized(QFontDatabase::systemFont(QFontDatabase::FixedFont), 12); + for (int index = 0; index < int(m_shown.size()); ++index) { + const QRect row = rowRect(index); + if (!row.intersects(event->rect())) + continue; + const CommandPaletteEntry &entry = m_shown[size_t(index)]; + if (index == m_model.selection) { + QColor chosen = palette().color(QPalette::Highlight); + chosen.setAlphaF(0.35); + QPainterPath round; + round.addRoundedRect(row, 6, 6); + painter.fillPath(round, chosen); + } + const QRect inside = row.adjusted(10, 0, -10, 0); + const QColor ink = palette().color(entry.isEnabled ? QPalette::Active : QPalette::Disabled, QPalette::WindowText); + int left = inside.left(); + if (entry.isOn) { + // Swift's checkmark, twelve points with six after. + QPainterPath check; + const QPointF middle(left + 6, row.center().y() + 0.5); + check.moveTo(middle + QPointF(-4, 0)); + check.lineTo(middle + QPointF(-1.5, 3)); + check.lineTo(middle + QPointF(4, -3.5)); + painter.setPen(QPen(ink, 1.6, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawPath(check); + left += 12 + 6; + } + int right = inside.right(); + if (entry.shortcut) { + painter.setFont(shortcutFont); + painter.setPen(palette().color(QPalette::PlaceholderText)); + const int width = painter.fontMetrics().horizontalAdvance(entry.shortcut.value()); + painter.drawText(QRect(right - width, row.top(), width + 1, row.height()), Qt::AlignVCenter | Qt::AlignRight, entry.shortcut.value()); + right -= width + 6; + } + painter.setFont(font()); + painter.setPen(ink); + const QRect text(left, row.top(), right - left, row.height()); + painter.drawText(text, Qt::AlignVCenter | Qt::AlignLeft, fontMetrics().elidedText(entry.title, Qt::ElideRight, text.width())); + } +} + +void CommandPaletteRows::mouseReleaseEvent(QMouseEvent *event) +{ + if (event->button() != Qt::LeftButton) + return; + for (int index = 0; index < int(m_shown.size()); ++index) + if (rowRect(index).contains(event->position().toPoint())) { + m_run(m_shown[size_t(index)]); + return; + } +} + +CommandPaletteView::CommandPaletteView(std::vector<CommandPaletteEntry> entries, std::function<void(const CommandPaletteEntry &)> run, QWidget *parent) + : QWidget(parent), m_model(std::move(entries)), m_run(std::move(run)), m_field(new QLineEdit(this)), + m_scroller(new QScrollArea(this)), m_rows(new CommandPaletteRows(m_model, m_run, m_scroller)), m_empty(new QLabel(QStringLiteral("No commands match"), this)) +{ + auto *column = new QVBoxLayout(this); + column->setContentsMargins(0, 0, 0, 0); + column->setSpacing(0); + m_field->setObjectName(QStringLiteral("commandSearch")); + m_field->setPlaceholderText(QStringLiteral("Search commands and tools")); + m_field->setFrame(false); + m_field->setFont(sized(m_field->font(), 17)); + m_field->setTextMargins(16, 13, 16, 13); + m_field->installEventFilter(this); + column->addWidget(m_field); + auto *divider = new QWidget(this); + divider->setFixedHeight(1); + divider->setAutoFillBackground(true); + divider->setBackgroundRole(QPalette::Mid); + column->addWidget(divider); + // The list runs on under the panel's bottom edge. + m_scroller->setFrameShape(QFrame::NoFrame); + m_scroller->setWidgetResizable(true); + m_scroller->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_scroller->setWidget(m_rows); + column->addWidget(m_scroller, 1); + m_empty->setObjectName(QStringLiteral("noCommands")); + m_empty->setAlignment(Qt::AlignCenter); + m_empty->setForegroundRole(QPalette::PlaceholderText); + connect(m_field, &QLineEdit::textChanged, this, [this](const QString &text) { + m_model.setQuery(text); + refresh(); + }); + refresh(); +} + +void CommandPaletteView::refresh() +{ + m_rows->refresh(); + m_empty->setGeometry(m_scroller->geometry()); + m_empty->setVisible(m_model.results().empty()); + m_scroller->ensureVisible(0, m_rows->rowRect(m_model.selection).center().y(), 0, m_rows->rowHeight() / 2 + outer); +} + +void CommandPaletteView::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + m_empty->setGeometry(m_scroller->geometry()); +} + +bool CommandPaletteView::eventFilter(QObject *watched, QEvent *event) +{ + if (watched != m_field || event->type() != QEvent::KeyPress) + return QWidget::eventFilter(watched, event); + switch (static_cast<QKeyEvent *>(event)->key()) { + case Qt::Key_Up: + case Qt::Key_Down: + m_model.move(static_cast<QKeyEvent *>(event)->key() == Qt::Key_Up ? -1 : 1); + refresh(); + return true; + case Qt::Key_Return: + case Qt::Key_Enter: + if (const std::optional<CommandPaletteEntry> entry = m_model.selected()) + m_run(entry.value()); + return true; + default: + return QWidget::eventFilter(watched, event); + } +} + +void CommandPaletteController::toggle(EditorSession &session, const QMenuBar &bar, QAction *search) +{ + if (isOpen()) { + close(); + return; + } + std::vector<CommandPaletteEntry> entries = CommandPaletteMenu::entries(bar, skipped); + for (std::vector<CommandPaletteEntry> more : {CommandPaletteCommands::layerCommands(session), CommandPaletteCommands::tools(session)}) + entries.insert(entries.end(), more.begin(), more.end()); + if (!m_panel) { + // A popup: a click elsewhere or Escape puts it away. + m_panel = new QWidget(&m_window, Qt::Popup); + m_panel->setObjectName(QStringLiteral("commandPalette")); + m_panel->setFixedSize(panelWidth, panelHeight); + m_panel->setAutoFillBackground(true); + new QVBoxLayout(m_panel); + m_panel->layout()->setContentsMargins(0, 0, 0, 0); + m_panel->addAction(search); + m_panel->installEventFilter(this); + } + for (CommandPaletteView *old : m_panel->findChildren<CommandPaletteView *>(Qt::FindDirectChildrenOnly)) { + old->hide(); + old->deleteLater(); + } + auto *view = new CommandPaletteView(std::move(entries), [this](const CommandPaletteEntry &entry) { run(entry); }, m_panel); + m_panel->layout()->addWidget(view); + // A popup has no frame: its geometry is its place. + m_panel->setGeometry(QRect(m_window.frameGeometry().center() - QPoint(panelWidth / 2, panelHeight / 2), QSize(panelWidth, panelHeight))); + m_panel->show(); + view->field().setFocus(Qt::PopupFocusReason); + qCInfo(lcApp) << "palette opened with" << view->model().entries().size() << "entries"; +} + +bool CommandPaletteController::eventFilter(QObject *watched, QEvent *event) +{ + // Qt hands focus back only to the window's focus widget. + if (watched == m_panel && event->type() == QEvent::Hide && m_panel->isAncestorOf(QApplication::focusWidget())) + QApplication::focusWidget()->clearFocus(); + return QObject::eventFilter(watched, event); +} + +void CommandPaletteController::close() +{ + if (m_panel) + m_panel->hide(); +} + +void CommandPaletteController::run(const CommandPaletteEntry &entry) +{ + if (!entry.isEnabled) { + qCWarning(lcApp) << "palette: disabled:" << entry.title; + return; + } + close(); + m_window.activateWindow(); + qCInfo(lcApp) << "palette runs" << entry.title; + QMetaObject::invokeMethod(this, entry.perform, Qt::QueuedConnection); +} diff --git a/src/UI/CommandPaletteView.h b/src/UI/CommandPaletteView.h new file mode 100644 index 0000000..ddf13b3 --- /dev/null +++ b/src/UI/CommandPaletteView.h @@ -0,0 +1,76 @@ +#pragma once +#include "UI/CommandPalette.h" +#include <QLineEdit> +#include <QPointer> +#include <QScrollArea> + +class QLabel; + +// The ranked rows under the search field. +class CommandPaletteRows : public QWidget { + Q_OBJECT +public: + CommandPaletteRows(CommandPaletteModel &model, std::function<void(const CommandPaletteEntry &)> run, QWidget *parent); + + int rowHeight() const; + // Where row `index` sits, inside the rows. + QRect rowRect(int index) const; + void refresh(); + +protected: + void paintEvent(QPaintEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + +private: + CommandPaletteModel &m_model; + std::function<void(const CommandPaletteEntry &)> m_run; + std::vector<CommandPaletteEntry> m_shown; +}; + +// The palette: a search field over the ranked commands. +class CommandPaletteView : public QWidget { + Q_OBJECT +public: + CommandPaletteView(std::vector<CommandPaletteEntry> entries, std::function<void(const CommandPaletteEntry &)> run, QWidget *parent); + + CommandPaletteModel &model() { return m_model; } + QLineEdit &field() { return *m_field; } + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + +private: + void refresh(); + + CommandPaletteModel m_model; + std::function<void(const CommandPaletteEntry &)> m_run; + QLineEdit *m_field; + QScrollArea *m_scroller; + CommandPaletteRows *m_rows; + QLabel *m_empty; +}; + +// The palette over the window; a click elsewhere hides it. +class CommandPaletteController : public QObject { + Q_OBJECT +public: + // Left out of the palette: the palette itself. + static const std::set<QString> skipped; + + explicit CommandPaletteController(QWidget &window) : m_window(window) {} + // A hidden palette lets go of the focus it held. + bool eventFilter(QObject *watched, QEvent *event) override; + + bool isOpen() const { return m_panel && m_panel->isVisible(); } + QWidget *panel() const { return m_panel; } + // Opens over the window, or closes; `search` works inside. + void toggle(EditorSession &session, const QMenuBar &bar, QAction *search); + void close(); + // Closes, gives the window back its keys, then runs it. + void run(const CommandPaletteEntry &entry); + +private: + QWidget &m_window; + QPointer<QWidget> m_panel; +}; diff --git a/src/UI/CompositorMenus.cpp b/src/UI/CompositorMenus.cpp index eb9d44f..7fb4baa 100644 --- a/src/UI/CompositorMenus.cpp +++ b/src/UI/CompositorMenus.cpp @@ -124,6 +124,9 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi [this] { session().beginFilter(FilterKind::contentAwareFill); }); QMenu *view = bar.addMenu(QStringLiteral("&View")); + add(view, QStringLiteral("searchCommands"), QStringLiteral("Search Commands…"), QKeySequence(Qt::CTRL | Qt::Key_F), + [this, &bar] { m_palette.toggle(session(), bar, action(QStringLiteral("searchCommands"))); }); + view->addSeparator(); add(view, QStringLiteral("fit"), QStringLiteral("Fit Canvas"), QKeySequence(Qt::CTRL | Qt::Key_0), [this] { session().fit(); }); add(view, QStringLiteral("actualPixels"), QStringLiteral("Actual Pixels"), QKeySequence(Qt::CTRL | Qt::Key_1), [this] { session().zoom(1); }); // Swift's stops; open text keeps the keys, as NSText does. diff --git a/src/UI/CompositorMenus.h b/src/UI/CompositorMenus.h index d0f8dfa..5115c5c 100644 --- a/src/UI/CompositorMenus.h +++ b/src/UI/CompositorMenus.h @@ -1,6 +1,7 @@ #pragma once #include "Document/ProjectWorkspace.h" #include "Rendering/EditorCanvas.h" +#include "UI/CommandPaletteView.h" #include "UI/FloatingPanel.h" #include <QAction> #include <QApplication> @@ -35,6 +36,7 @@ private: ProjectWorkspace &m_workspace; QWidget &m_window; FloatingPanel m_shortcutsPanel{QStringLiteral("keyboardShortcuts"), m_window}; + CommandPaletteController m_palette{m_window}; QMetaObject::Connection m_sessionWatch; std::optional<QUuid> m_watchedTab; // The field whose undo the Edit entries drive. diff --git a/src/UI/KeyboardShortcuts.cpp b/src/UI/KeyboardShortcuts.cpp index 12121e4..85973fb 100644 --- a/src/UI/KeyboardShortcuts.cpp +++ b/src/UI/KeyboardShortcuts.cpp @@ -125,7 +125,7 @@ const std::vector<ShortcutDefinition> &ShortcutDefinition::all() entry("Undo", "z", 1, true), entry("Redo", "z", 9, true), entry("New Canvas", "n", 1, true), entry("Open Project", "o", 1, true), entry("Save", "s", 1, true), entry("Save As", "s", 9, true), entry("Export PNG", "e", 9, true), entry("Export JPEG", "s", 11, true), entry("Close Project", "w", 1, true), - entry("Fit Canvas", "0", 1, true), entry("Actual Pixels", "1", 1, true), entry("Zoom In", "=", 1, true), + entry("Fit Canvas", "0", 1, true), entry("Search Commands", "f", 1, true), entry("Actual Pixels", "1", 1, true), entry("Zoom In", "=", 1, true), entry("Zoom Out", "-", 1, true), entry("Show Transform Controls", "h", 1, true), entry("Cut", "x", 1, true), 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), diff --git a/tests/CommandPaletteTests.cpp b/tests/CommandPaletteTests.cpp new file mode 100644 index 0000000..b4e4e63 --- /dev/null +++ b/tests/CommandPaletteTests.cpp @@ -0,0 +1,228 @@ +#include "MenuFixtures.h" +#include "UI/CommandPalette.h" + +// The palette's search, model, menu reading and commands. +namespace { +CommandPaletteEntry entry(const QString &id, bool enabled = true) +{ + return {.id = id, .title = id, .shortcut = std::nullopt, .isEnabled = enabled, .isOn = false, .perform = [] {}}; +} + +QStringList ids(const std::vector<CommandPaletteEntry> &entries) +{ + QStringList result; + for (const CommandPaletteEntry &each : entries) + result << each.id; + return result; +} + +const CommandPaletteEntry &titled(const std::vector<CommandPaletteEntry> &entries, const QString &title) +{ + for (const CommandPaletteEntry &each : entries) + if (each.title == title) + return each; + throw std::runtime_error(title.toStdString() + " not listed"); +} +} + +class CommandPaletteTests : public QObject { + Q_OBJECT +private slots: + void lettersInOrderMatchAndWordStartsCount(); + void scoresAreSwiftsOwn(); + void rankingPutsTheBestAndEnabledFirst(); + void wholeWordsBeatScatteredLetters(); + void typedWordsStartingWords(); + void theModelMovesAndWraps(); + void aMenuBarReadsAsPaths(); + void realMenuBarRunsItsCommands(); + void layerCommandsAddAMask(); + void toolsTakeTheirModes(); +}; + +void CommandPaletteTests::lettersInOrderMatchAndWordStartsCount() +{ + QVERIFY(CommandPaletteSearch::score("gb", "Filter › Gaussian Blur…")); + QVERIFY(CommandPaletteSearch::score("BLUR", "Filter › Gaussian Blur…")); + QVERIFY(!CommandPaletteSearch::score("bg", "Filter › Gaussian Blur…")); + QCOMPARE(CommandPaletteSearch::score("", "Anything").value(), 0); + QVERIFY(CommandPaletteSearch::score("gb", "Filter › Gaussian Blur…").value() > CommandPaletteSearch::score("gb", "Edit › Debug Tab").value()); +} + +void CommandPaletteTests::scoresAreSwiftsOwn() +{ + // Swift 6.0.3 running v1.4.9's CommandPaletteSearch on each pair. + const std::vector<std::tuple<QString, QString, int>> cases{ + {"gb", "Filter › Gaussian Blur…", 1001795}, {"gb", "Edit › Debug Tab", 1000196}, {"BLUR", "Filter › Gaussian Blur…", 4999995}, + {"lasso", "Tool › Lasso", 4999997}, {"lasso", "Tool › Polygonal Lasso", 4999995}, + {"lasso", "Layer › New Adjustment Layer › Hue/Saturation…", 1002589}, {"new layer", "Layer › New Blank Layer", 3999995}, + {"new layer", "Layer › New Adjustment Layer › Curves…", 3999991}, {"ussian", "Filter › Gaussian Blur…", 2999995}, + {"gaussianbl", "Filter › Gaussian Blur…", 1999995}, {" blur ", "Filter › Gaussian Blur…", 4999995}, {"2", "View › Zoom 2x", 4999997}, + {"x2", "Layer › Box2", 2999997}, {"e u", "Edit › Undo", 3999998}, {"fl", "Layer › Flip Layer Horizontal", 4999993}, + {"é", "Café › Crème", 2999997}, {"ab", "a1b c", 1000999}, {"b", "1b", 5000000}, {"mask", "Layer › Add Layer Mask (Hide All)", 4999992}, + {"hide all", "Layer › Add Layer Mask (Hide All)", 4999992}, {"\tblur", "Filter › Gaussian Blur…", 4999995}, {"ab", "x-ab", 4999999}, + {"lay mask", "Layer › Add Layer Mask", 3999995}, {"b a", "a bab", 1999999}, {"b a", "ab a", 2999999}}; + for (const auto &[query, title, score] : cases) + QVERIFY2(CommandPaletteSearch::score(query, title).value() == score, qPrintable(query + " in " + title)); + QVERIFY(!CommandPaletteSearch::score("new layer", "Layer › Duplicate Layer")); +} + +void CommandPaletteTests::rankingPutsTheBestAndEnabledFirst() +{ + const std::vector<CommandPaletteEntry> entries{entry("Edit › Paste"), entry("Layer › Flip Layer Horizontal"), entry("Filter › Gaussian Blur…"), + entry("Filter › Motion Blur…"), entry("Edit › Undo", false), entry("Image › Levels…")}; + QCOMPARE(ids(CommandPaletteSearch::rank(entries, "gau")).value(0), QString("Filter › Gaussian Blur…")); + // Equal scores keep menu order. + QCOMPARE(ids(CommandPaletteSearch::rank(entries, "blur")), (QStringList{"Filter › Gaussian Blur…", "Filter › Motion Blur…"})); + QCOMPARE(ids(CommandPaletteSearch::rank(entries, "fl")).value(0), QString("Layer › Flip Layer Horizontal")); + QCOMPARE(ids(CommandPaletteSearch::rank(entries, "")), + (QStringList{"Edit › Paste", "Layer › Flip Layer Horizontal", "Filter › Gaussian Blur…", "Filter › Motion Blur…", "Image › Levels…", "Edit › Undo"})); + // Disabled after enabled, whatever it scores. + QCOMPARE(ids(CommandPaletteSearch::rank(entries, "e u")).last(), QString("Edit › Undo")); + QCOMPARE(ids(CommandPaletteSearch::rank({entry("Edit › Undo", false), entry("Layer › Merge Layers")}, "e")), + (QStringList{"Layer › Merge Layers", "Edit › Undo"})); +} + +void CommandPaletteTests::wholeWordsBeatScatteredLetters() +{ + const std::vector<CommandPaletteEntry> entries{entry("Layer › New Adjustment Layer › Hue/Saturation…"), entry("Tool › Lasso"), entry("Tool › Polygonal Lasso")}; + QCOMPARE(ids(CommandPaletteSearch::rank(entries, "lasso")), (QStringList{"Tool › Lasso", "Tool › Polygonal Lasso", "Layer › New Adjustment Layer › Hue/Saturation…"})); + CommandPaletteModel model(entries); + model.setQuery("lasso"); + model.move(1); + // Return writes the same text back: the choice stays. + model.setQuery("lasso"); + QCOMPARE(model.selected().value().id, QString("Tool › Polygonal Lasso")); + model.setQuery("lass"); + QCOMPARE(model.selection, 0); +} + +void CommandPaletteTests::typedWordsStartingWords() +{ + const std::vector<CommandPaletteEntry> entries{entry("Layer › New Adjustment Layer › Curves…"), entry("Layer › New Adjustment Layer › Exposure…"), + entry("Layer › Duplicate Layer"), entry("Layer › New Blank Layer")}; + QCOMPARE(ids(CommandPaletteSearch::rank(entries, "new layer")).value(0), QString("Layer › New Blank Layer")); +} + +void CommandPaletteTests::theModelMovesAndWraps() +{ + CommandPaletteModel model({entry("A"), entry("B"), entry("C")}); + model.move(-1); + QCOMPARE(model.selection, 2); + model.move(1); + QCOMPARE(model.selection, 0); + model.move(4); + QCOMPARE(model.selected().value().id, QString("B")); + model.setQuery("zzz"); + QVERIFY(!model.selected()); + model.move(1); + QCOMPARE(model.selection, 0); +} + +void CommandPaletteTests::aMenuBarReadsAsPaths() +{ + QMenuBar bar; + QMenu *filter = bar.addMenu("Fil&ter"); + QAction *blur = filter->addAction("&Gaussian Blur…"); + blur->setShortcut(QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_P)); + filter->addSeparator(); + filter->addSection("Section"); + filter->addAction("Black && White…")->setEnabled(false); + filter->addAction("Hidden")->setVisible(false); + QAction *grid = filter->addMenu("Show")->addAction("Grid"); + grid->setCheckable(true); + grid->setChecked(true); + filter->addAction("Twin"); + filter->addAction("Twin"); + filter->addAction("Search Commands…"); + bar.addMenu("Skipped")->addAction("Inside"); + const std::vector<CommandPaletteEntry> entries = CommandPaletteMenu::entries(bar, {"Search Commands…", "Skipped"}); + QCOMPARE(ids(entries), (QStringList{"Filter › Gaussian Blur…", "Filter › Black & White…", "Filter › Show › Grid", "Filter › Twin", "Filter › Twin (2)"})); + QCOMPARE(entries[0].shortcut.value(), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_P).toString(QKeySequence::NativeText)); + QVERIFY(!entries[1].shortcut && !entries[1].isEnabled && entries[0].isEnabled); + QVERIFY(entries[2].isOn && !entries[0].isOn); + QCOMPARE(entries[4].title, QString("Filter › Twin")); + // An entry triggers its own action; a gone one warns. + QSignalSpy triggered(blur, &QAction::triggered); + entries[0].perform(); + QCOMPARE(triggered.count(), 1); + delete blur; + QTest::ignoreMessage(QtWarningMsg, "palette: menu entry gone: \"Filter › Gaussian Blur…\""); + entries[0].perform(); + QCOMPARE(CommandPaletteMenu::plain("A&&&B&"), QString("A&B")); +} + +void CommandPaletteTests::realMenuBarRunsItsCommands() +{ + Bar bar; + const auto read = [&] { return CommandPaletteMenu::entries(*bar.window.menuBar(), {"Search Commands…"}); }; + const std::vector<CommandPaletteEntry> listed = read(); + QVERIFY(std::ranges::none_of(listed, [](const CommandPaletteEntry &e) { return e.title == "View › Search Commands…"; })); + QVERIFY(titled(listed, "Filter › Gaussian Blur…").title == "Filter › Gaussian Blur…"); + QVERIFY(!titled(listed, "View › Zoom In").isEnabled); + QCOMPARE(titled(listed, "View › Fit Canvas").shortcut.value(), QKeySequence(Qt::CTRL | Qt::Key_0).toString(QKeySequence::NativeText)); + QCOMPARE(titled(listed, "Image › Black & White…").title, QString("Image › Black & White…")); + const CommandPaletteEntry grid = titled(listed, "View › Pixel Grid (800% and above)"); + QVERIFY(grid.isOn && bar.session().showsPixelGrid()); + grid.perform(); + QVERIFY(!bar.session().showsPixelGrid() && !titled(read(), grid.title).isOn); + grid.perform(); + QVERIFY(bar.session().showsPixelGrid()); +} + +void CommandPaletteTests::layerCommandsAddAMask() +{ + EditorSession session; + std::vector<CommandPaletteEntry> commands = CommandPaletteCommands::layerCommands(session); + QCOMPARE(ids(commands), (QStringList{"Layer › Add Layer Mask", "Layer › Add Layer Mask (Hide All)"})); + QVERIFY(!commands[0].isEnabled && !commands[1].isEnabled); + session.createDocument(4, 4); + session.insert(white(), QPointF(2, 2)); + session.selectAll(); + commands = CommandPaletteCommands::layerCommands(session); + QCOMPARE(ids(commands), (QStringList{"Layer › Add Layer Mask from Selection", "Layer › Add Layer Mask Hiding Selection"})); + QVERIFY(commands[0].isEnabled && commands[1].isEnabled); + commands[1].perform(); + QCOMPARE(session.history.undoName(), QString("Hide Selection")); + QVERIFY(!CommandPaletteCommands::layerCommands(session)[0].isEnabled); + session.undo(); + session.deselect(); + CommandPaletteCommands::layerCommands(session)[0].perform(); + QCOMPARE(session.history.undoName(), QString("Add Reveal-All Mask")); +} + +void CommandPaletteTests::toolsTakeTheirModes() +{ + EditorSession session; + std::vector<CommandPaletteEntry> tools = CommandPaletteCommands::tools(session); + QCOMPARE(tools.size(), size_t(23)); + QCOMPARE(tools[0].id, QString("Tool › Move / Transform")); + QCOMPARE(tools[4].shortcut.value(), QString("L")); + QVERIFY(std::ranges::none_of(tools, [](const CommandPaletteEntry &e) { return e.isEnabled; })); + session.createDocument(4, 4); + tools = CommandPaletteCommands::tools(session); + QVERIFY(std::ranges::all_of(tools, [](const CommandPaletteEntry &e) { return e.isEnabled; })); + titled(tools, "Tool › Polygonal Lasso").perform(); + QVERIFY(session.tool() == NavigationTool::lasso && session.lassoKind() == LassoKind::polygonal); + titled(tools, "Tool › Elliptical Marquee").perform(); + QVERIFY(session.tool() == NavigationTool::marquee && session.marqueeKind() == LassoKind::ellipse); + titled(tools, "Tool › Object Selection").perform(); + QVERIFY(session.tool() == NavigationTool::wand && session.wandMode() == WandMode::object); + titled(tools, "Tool › Eraser").perform(); + QVERIFY(session.tool() == NavigationTool::brush && session.brushMode() == BrushToolMode::erase); + titled(tools, "Tool › Smudge").perform(); + QVERIFY(session.tool() == NavigationTool::blur && session.blurMode() == BlurToolMode::smudge); + titled(tools, "Tool › Line").perform(); + QVERIFY(session.tool() == NavigationTool::shape && session.shapeKind() == ShapeKind::line); + titled(tools, "Tool › Zoom").perform(); + QVERIFY(session.tool() == NavigationTool::zoom); + // A refused tool change leaves the mode alone. + session.insert(white(), QPointF(2, 2)); + session.beginLevels(); + QVERIFY(session.levels()); + titled(tools, "Tool › Rectangle").perform(); + QVERIFY(session.tool() == NavigationTool::zoom && session.shapeKind() == ShapeKind::line); +} + +QTEST_MAIN(CommandPaletteTests) +#include "CommandPaletteTests.moc" diff --git a/tests/CommandPaletteWindowTests.cpp b/tests/CommandPaletteWindowTests.cpp new file mode 100644 index 0000000..bcf1062 --- /dev/null +++ b/tests/CommandPaletteWindowTests.cpp @@ -0,0 +1,181 @@ +#include "MenuFixtures.h" +#include "UI/CommandPaletteView.h" +#include <QLabel> +#include <QScrollBar> + +// The palette in the app's window, on Ctrl+F. +namespace { +struct Shown : Bar { + Shown() + { + window.resize(1000, 700); + window.show(); + if (!QTest::qWaitForWindowActive(&window)) + throw std::runtime_error("window never active"); + } + QWidget *panel() const { return window.findChild<QWidget *>("commandPalette"); } + CommandPaletteView &view() const + { + CommandPaletteView *found = nullptr; + for (CommandPaletteView *each : window.findChildren<CommandPaletteView *>()) + if (each->isVisible()) + found = each; + if (!found) + throw std::runtime_error("no palette shown"); + return *found; + } + void open() + { + QTest::keyClick(&window, Qt::Key_F, Qt::ControlModifier); + QTRY_VERIFY(panel() && panel()->isVisible()); + } +}; +} + +class CommandPaletteWindowTests : public QObject { + Q_OBJECT +private slots: + void ctrlFOpensItOverTheWindowAndAgainCloses(); + void typingRanksAndReturnRunsTheChosenRow(); + void aDisabledRowStaysOpenAndEscapeCloses(); + void aClickRunsARowAndAClickOutsideCloses(); + void rowsDrawChecksKeysAndTheEmptyWords(); +}; + +void CommandPaletteWindowTests::ctrlFOpensItOverTheWindowAndAgainCloses() +{ + Shown shown; + QAction &search = shown.action("searchCommands"); + QVERIFY(search.text() == "Search Commands…" && search.isEnabled()); + // First in View, a separator after it. + const QList<QAction *> view = qobject_cast<QMenu *>(search.parent())->actions(); + QVERIFY(view[0] == &search && view[1]->isSeparator()); + const QPoint centred = shown.window.frameGeometry().center() - QPoint(230, 145); + shown.open(); + QCOMPARE(shown.panel()->size(), QSize(460, 290)); + QCOMPARE(shown.panel()->pos(), centred); + QTRY_VERIFY(shown.view().field().hasFocus()); + QCOMPARE(shown.view().field().placeholderText(), QString("Search commands and tools")); + // Every menu entry, the mask commands and the tools. + const std::vector<CommandPaletteEntry> &entries = shown.view().model().entries(); + QCOMPARE(entries.back().id, QString("Tool › Zoom")); + QVERIFY(std::ranges::any_of(entries, [](const CommandPaletteEntry &e) { return e.id == "Layer › Add Layer Mask"; })); + QVERIFY(std::ranges::none_of(entries, [](const CommandPaletteEntry &e) { return e.title.contains("Search Commands"); })); + // Ctrl+F inside the palette puts it away. + QTest::keyClick(&shown.view().field(), Qt::Key_F, Qt::ControlModifier); + QTRY_VERIFY(!shown.panel()->isVisible()); + shown.open(); + QVERIFY(shown.view().field().text().isEmpty()); + // The last palette goes once the event loop runs. + QTRY_COMPARE(shown.window.findChildren<CommandPaletteView *>().size(), qsizetype(1)); +} + +void CommandPaletteWindowTests::typingRanksAndReturnRunsTheChosenRow() +{ + Shown shown; + shown.session().createDocument(400, 300); + shown.session().zoom(1); + shown.open(); + // Up from the top wraps to the last row, shown. + auto *scroller = shown.view().findChild<QScrollArea *>(); + QTest::keyClick(&shown.view().field(), Qt::Key_Up); + QVERIFY(shown.view().model().results().size() > 100); + QCOMPARE(shown.view().model().selection, int(shown.view().model().results().size()) - 1); + QCOMPARE(scroller->verticalScrollBar()->value(), scroller->verticalScrollBar()->maximum()); + QTest::keyClick(&shown.view().field(), Qt::Key_Up); + QCOMPARE(shown.view().model().selection, int(shown.view().model().results().size()) - 2); + QTest::keyClick(&shown.view().field(), Qt::Key_Down); + QTest::keyClick(&shown.view().field(), Qt::Key_Down); + QCOMPARE(shown.view().model().selection, 0); + QCOMPARE(scroller->verticalScrollBar()->value(), 0); + QTest::keyClicks(&shown.view().field(), "zoom in"); + QCOMPARE(shown.view().model().selected().value().id, QString("View › Zoom In")); + QTest::keyClick(&shown.view().field(), Qt::Key_Return); + QVERIFY(!shown.panel()->isVisible()); + // The hidden palette keeps no focus: Undo is no field’s. + QVERIFY(!QApplication::focusWidget()); + // It runs once the event loop is next free. + QCOMPARE(shown.session().viewport.zoom(), 1.0); + QTRY_COMPARE(shown.session().viewport.zoom(), 1.25); + // A tool's mode by its name. + shown.open(); + QTest::keyClicks(&shown.view().field(), "polygonal"); + QTest::keyClick(&shown.view().field(), Qt::Key_Enter); + QTRY_VERIFY(shown.session().tool() == NavigationTool::lasso && shown.session().lassoKind() == LassoKind::polygonal); +} + +void CommandPaletteWindowTests::aDisabledRowStaysOpenAndEscapeCloses() +{ + Shown shown; + shown.open(); + QTest::keyClicks(&shown.view().field(), "zoom in"); + QVERIFY(!shown.view().model().selected().value().isEnabled); + QTest::ignoreMessage(QtWarningMsg, "palette: disabled: \"View › Zoom In\""); + QTest::keyClick(&shown.view().field(), Qt::Key_Return); + QVERIFY(shown.panel()->isVisible()); + QTest::keyClick(&shown.view().field(), Qt::Key_Escape); + QVERIFY(!shown.panel()->isVisible()); + QVERIFY(!shown.session().document()); +} + +void CommandPaletteWindowTests::aClickRunsARowAndAClickOutsideCloses() +{ + Shown shown; + shown.session().createDocument(40, 30); + shown.open(); + QTest::keyClicks(&shown.view().field(), "tool eraser"); + auto *rows = shown.view().findChild<CommandPaletteRows *>(); + QCOMPARE(shown.view().model().results()[0].id, QString("Tool › Eraser")); + QTest::mouseClick(rows, Qt::RightButton, {}, rows->rowRect(0).center()); + QVERIFY(shown.panel()->isVisible()); + QTest::mouseClick(rows, Qt::LeftButton, {}, rows->rowRect(0).center()); + QTRY_VERIFY(shown.session().tool() == NavigationTool::brush && shown.session().brushMode() == BrushToolMode::erase); + shown.open(); + QTest::mouseClick(shown.panel(), Qt::LeftButton, {}, QPoint(-20, -20)); + QTRY_VERIFY(!shown.panel()->isVisible()); +} + +void CommandPaletteWindowTests::rowsDrawChecksKeysAndTheEmptyWords() +{ + Shown shown; + shown.open(); + CommandPaletteView &view = shown.view(); + auto *rows = view.findChild<CommandPaletteRows *>(); + QTest::keyClicks(&view.field(), "pixel grid"); + QCOMPARE(view.model().results()[0].id, QString("View › Pixel Grid (800% and above)")); + QVERIFY(view.model().results()[0].isOn); + const QImage drawn = rows->grab().toImage(); + const QRect row = rows->rowRect(0); + // The chosen row is tinted; its checkmark draws first. + const QColor tint = drawn.pixelColor(row.right() - 3, row.center().y()); + QVERIFY(tint != rows->palette().color(QPalette::Window)); + int inked = 0; + for (int x = row.left() + 10; x < row.left() + 22; ++x) + for (int y = row.top(); y < row.bottom(); ++y) + inked += drawn.pixelColor(x, y) != drawn.pixelColor(row.right() - 3, y); + QVERIFY(inked > 6); + // Text starts past the check; off, the title is there. + const auto gap = [&](const QImage &image) { + int ink = 0; + for (int x = row.left() + 23; x < row.left() + 27; ++x) + for (int y = row.top() + 2; y < row.bottom() - 2; ++y) + ink += image.pixelColor(x, y) != image.pixelColor(row.right() - 3, y); + return ink; + }; + QCOMPARE(gap(drawn), 0); + QTest::keyClick(&view.field(), Qt::Key_Escape); + shown.session().setShowsPixelGrid(false); + shown.open(); + QTest::keyClicks(&shown.view().field(), "pixel grid"); + QVERIFY(!shown.view().model().results()[0].isOn); + QVERIFY(gap(shown.view().findChild<CommandPaletteRows *>()->grab().toImage()) > 0); + QVERIFY(!shown.view().findChild<QLabel *>("noCommands")->isVisible()); + QTest::keyClicks(&shown.view().field(), "qqqq"); + QVERIFY(shown.view().model().results().empty()); + QVERIFY(shown.view().findChild<QLabel *>("noCommands")->isVisible()); + QTest::keyClick(&shown.view().field(), Qt::Key_Return); + QVERIFY(shown.panel()->isVisible()); +} + +QTEST_MAIN(CommandPaletteWindowTests) +#include "CommandPaletteWindowTests.moc" diff --git a/tests/CompositorMenusTests.cpp b/tests/CompositorMenusTests.cpp index 64cc674..6e9fad5 100644 --- a/tests/CompositorMenusTests.cpp +++ b/tests/CompositorMenusTests.cpp @@ -51,7 +51,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand() QCOMPARE(titles, (QStringList{"&File", "&Edit", "&View", "&Select", "&Image", "Fil&ter", "&Layer"})); QCOMPARE(menus[0]->actions().size(), 12); QCOMPARE(menus[1]->actions().size(), 13); - QCOMPARE(menus[2]->actions().size(), 16); + QCOMPARE(menus[2]->actions().size(), 18); QCOMPARE(menus[3]->actions().size(), 11); // Swift 1.3.4's Color Range… follows Subject. const QList<QAction *> select = menus[3]->actions(); diff --git a/tests/KeyboardShortcutsTests.cpp b/tests/KeyboardShortcutsTests.cpp index 228825c..11a4405 100644 --- a/tests/KeyboardShortcutsTests.cpp +++ b/tests/KeyboardShortcutsTests.cpp @@ -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()), 112); + QCOMPARE(int(all.size()), 113); QSet<QString> ids; for (const ShortcutDefinition &definition : all) ids.insert(definition.id()); diff --git a/tests/Tests.cmake b/tests/Tests.cmake index f6d6091..12f390f 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -102,6 +102,8 @@ compositor_test(CompositorTests) compositor_test(CompositorMenusTests) compositor_test(ImageMenuTests) compositor_test(ViewMenuTests) +compositor_test(CommandPaletteTests) +compositor_test(CommandPaletteWindowTests) compositor_test(KeyboardShortcutsTests) compositor_test(KeyboardShortcutsSheetTests) compositor_test(RemappedKeysTests)