From cb139a1dd6555b5bddd7934a336a004617e53434 Mon Sep 17 00:00:00 2001 From: Fvzion Date: Tue, 29 Sep 2026 17:33:53 -0600 Subject: [PATCH] =?UTF-8?q?Command=20palette=20(=E2=8C=A5=E2=8C=98P):=20se?= =?UTF-8?q?arch=20every=20menu=20command=20and=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View › Command Palette… opens a small search window, as in Raycast or Obsidian. It lists every command in the menu bar, read live from the main menu (so new commands appear by themselves) with its menu path and shortcut, greyed when disabled, and every tool. Typing ranks them fuzzily (letters in order, word starts and runs first); arrows move, Return runs, Escape closes. The shortcut is listed in Keyboard Shortcuts. Co-Authored-By: Claude Opus 5.5 --- Compositor/CompositorApp.swift | 5 + Compositor/UI/CommandPalette.swift | 159 +++++++++++++++++ Compositor/UI/CommandPaletteView.swift | 130 ++++++++++++++ Compositor/UI/KeyboardShortcuts.swift | 2 +- CompositorTests/CommandPaletteTests.swift | 200 ++++++++++++++++++++++ README.md | 1 + 6 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 Compositor/UI/CommandPalette.swift create mode 100644 Compositor/UI/CommandPaletteView.swift create mode 100644 CompositorTests/CommandPaletteTests.swift diff --git a/Compositor/CompositorApp.swift b/Compositor/CompositorApp.swift index 0c171105a..51fa60aab 100644 --- a/Compositor/CompositorApp.swift +++ b/Compositor/CompositorApp.swift @@ -96,6 +96,11 @@ struct CompositorApp: App { Button("Check for Updates…") { applicationDelegate.updater.checkForUpdates(nil) } } CommandGroup(after: .toolbar) { + Button("Command Palette…") { + CommandPaletteController.shared.toggle(session: session, over: applicationDelegate.projects.window) + } + .configuredKeyboardShortcut("p", modifiers: [.command, .option]) + Divider() // With a dialog's preview open (Export JPEG), these zoom that preview rather than the canvas. Button("Fit Canvas") { if let preview = session.previewZoom { preview(.fit) } else { session.fit() } diff --git a/Compositor/UI/CommandPalette.swift b/Compositor/UI/CommandPalette.swift new file mode 100644 index 000000000..a78f14659 --- /dev/null +++ b/Compositor/UI/CommandPalette.swift @@ -0,0 +1,159 @@ +import AppKit + +/// One thing the command palette can run: a menu command or a tool. +struct CommandPaletteEntry: Identifiable { + /// Unique among the entries: the title, with its position added when an earlier entry has the same title (the + /// View menu has two items called "Snap"). + let id: String + /// Its full path, as the palette shows it and searches it: "Filter › Gaussian Blur…". + let title: String + /// Its current key, "⌥⌘P", or nil. + let shortcut: String? + /// False when its menu item is disabled right now: listed, greyed, and never run. + let isEnabled: Bool + let perform: @MainActor () -> Void + + init(id: String, title: String? = nil, shortcut: String?, isEnabled: Bool, perform: @escaping @MainActor () -> Void) { + self.id = id + self.title = title ?? id + self.shortcut = shortcut + self.isEnabled = isEnabled + self.perform = perform + } +} + +/// Fuzzy matching as launchers do it: the query's letters must appear in order; each one scores, more at the start +/// of a word and more again right after the previous match, so "gb" finds Gaussian Blur before Debug. +enum CommandPaletteSearch { + static func score(_ query: String, in text: String) -> Int? { + let wanted = Array(query.lowercased().filter { !$0.isWhitespace }) + guard !wanted.isEmpty else { return 0 } + let letters = Array(text.lowercased()) + var total = 0, from = 0, previous = -2 + for character in wanted { + guard let found = letters[from...].firstIndex(of: character) else { return nil } + let wordStart = found == 0 || !(letters[found - 1].isLetter || letters[found - 1].isNumber) + total += 1 + (wordStart ? 8 : 0) + (found == previous + 1 ? 5 : 0) + previous = found + from = found + 1 + } + // Between equal matches, the shorter title is the likelier one. + return total * 100 - letters.count + } + + /// The entries that match, best first, with enabled ones ahead of disabled; with no query, all of them in menu + /// order, enabled first. + static func rank(_ entries: [CommandPaletteEntry], query: String) -> [CommandPaletteEntry] { + let scored = entries.enumerated().compactMap { index, entry in + score(query, in: entry.title).map { (entry, $0, index) } + } + return scored.sorted { a, b in + if a.0.isEnabled != b.0.isEnabled { return a.0.isEnabled } + if query.isEmpty || a.1 == b.1 { return a.2 < b.2 } + return a.1 > b.1 + }.map(\.0) + } +} + +/// The menu bar as palette entries: every item that does something, with its path ("Layer › Rename Layer…"), its +/// key and whether it's enabled now. Read afresh each time the palette opens, so titles that change with state +/// ("Undo Brush Stroke") are current. +enum CommandPaletteMenu { + /// `skipping` holds titles left out: top-level menus (Window, Help) and single items (the palette's own). + /// The app menu, the first in the bar, is always left out. + static func entries(in menu: NSMenu, skipping: Set) -> [CommandPaletteEntry] { + var seen: [String: Int] = [:] + return collect(menu, root: menu, path: [], skipping: skipping, seen: &seen) + } + + private static func collect(_ menu: NSMenu, root: NSMenu, path: [String], skipping: Set, + seen: inout [String: Int]) -> [CommandPaletteEntry] { + // SwiftUI brings a menu's titles, checkmarks and enabled states up to date only when it's about to open, through + // its delegate; ask for that, then let AppKit validate, so what's read is what the menu would show now. + menu.delegate?.menuNeedsUpdate?(menu) + menu.update() + var result: [CommandPaletteEntry] = [] + var sameTitle: [String: Int] = [:] + for (index, item) in menu.items.enumerated() { + guard !item.isSeparatorItem, !item.isHidden, !skipping.contains(item.title), !item.title.isEmpty else { continue } + if let submenu = item.submenu { + if path.isEmpty, index == 0 { continue } // The app menu: About, Hide, Quit. + result += collect(submenu, root: root, path: path + [item.title], skipping: skipping, seen: &seen) + continue + } + // SwiftUI takes the action off a disabled item, so an item without one is listed greyed rather than dropped. + let titles = path + [item.title] + let title = titles.joined(separator: " › ") + let occurrence = sameTitle[item.title, default: 0] + sameTitle[item.title] = occurrence + 1 + let count = seen[title, default: 0] + seen[title] = count + 1 + result.append(CommandPaletteEntry( + id: count == 0 ? title : "\(title) (\(count + 1))", title: title, + shortcut: shortcut(of: item), isEnabled: item.isEnabled, + // Found again by its path when run, so a menu SwiftUI has rebuilt since the palette opened still works. + perform: { [weak root] in + guard let root, let (menu, position) = locate(titles, occurrence: occurrence, in: root) else { NSSound.beep(); return } + menu.performActionForItem(at: position) + })) + } + return result + } + + /// The menu holding the item at `titles` (menu titles, then the item's), and its position there; `occurrence` + /// picks among items with the same title. + private static func locate(_ titles: [String], occurrence: Int, in root: NSMenu) -> (NSMenu, Int)? { + var menu = root + for title in titles.dropLast() { + guard let submenu = menu.items.first(where: { $0.title == title && $0.submenu != nil })?.submenu else { return nil } + menu = submenu + } + let matches = menu.items.indices.filter { menu.items[$0].title == titles.last && menu.items[$0].submenu == nil } + return matches.indices.contains(occurrence) ? (menu, matches[occurrence]) : nil + } + + /// The key as the menu shows it, "⌥⌘P". An uppercase key equivalent implies Shift, as it does in AppKit. + static func shortcut(of item: NSMenuItem) -> String? { + guard let character = item.keyEquivalent.first else { return nil } + let flags = item.keyEquivalentModifierMask + let shifted = flags.contains(.shift) || (character.isLetter && character.isUppercase) + let bits = (flags.contains(.command) ? 1 : 0) | (flags.contains(.option) ? 2 : 0) + | (flags.contains(.control) ? 4 : 0) | (shifted ? 8 : 0) + // AppKit's Delete key equivalent is U+0008; Keyboard Shortcuts records and labels Delete as U+007F. + let key = character == "\u{8}" ? "\u{7f}" : String(character).lowercased() + return ShortcutChord(key, bits).label + } +} + +extension CommandPaletteEntry { + /// The tools, run by choosing them. With no document open there is nothing to use them on, so they're disabled. + static func tools(for session: EditorSession) -> [CommandPaletteEntry] { + NavigationTool.allCases.filter { $0 != .idle }.map { tool in + CommandPaletteEntry(id: "Tool › \(tool.label)", shortcut: nil, isEnabled: session.document != nil, + perform: { [weak session] in session?.selectTool(tool) }) + } + } +} + +/// What the palette shows: the query, the ranked results and which one is chosen. +@MainActor @Observable +final class CommandPaletteModel { + let entries: [CommandPaletteEntry] + var query = "" { didSet { selection = 0 } } + var selection = 0 + + init(entries: [CommandPaletteEntry]) { self.entries = entries } + + var results: [CommandPaletteEntry] { CommandPaletteSearch.rank(entries, query: query) } + var selected: CommandPaletteEntry? { + let results = results + return results.indices.contains(selection) ? results[selection] : nil + } + + /// ↑ and ↓, wrapping at either end. + func move(by step: Int) { + let count = results.count + guard count > 0 else { selection = 0; return } + selection = ((selection + step) % count + count) % count + } +} diff --git a/Compositor/UI/CommandPaletteView.swift b/Compositor/UI/CommandPaletteView.swift new file mode 100644 index 000000000..5b06781a3 --- /dev/null +++ b/Compositor/UI/CommandPaletteView.swift @@ -0,0 +1,130 @@ +import AppKit +import SwiftUI + +/// The palette: a search field over the ranked commands. ↑/↓ choose, Return runs, Esc closes. +struct CommandPaletteView: View { + @Bindable var model: CommandPaletteModel + let run: (CommandPaletteEntry) -> Void + let close: () -> Void + @FocusState private var searching: Bool + + var body: some View { + VStack(spacing: 0) { + TextField("Search commands and tools", text: $model.query) + .textFieldStyle(.plain).font(.system(size: 17)) + .padding(.horizontal, 16).padding(.vertical, 13) + .focused($searching) + .onKeyPress(.upArrow) { model.move(by: -1); return .handled } + .onKeyPress(.downArrow) { model.move(by: 1); return .handled } + .onSubmit { if let entry = model.selected { run(entry) } } + .onExitCommand(perform: close) + Divider() + ScrollViewReader { scroller in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(model.results.enumerated()), id: \.element.id) { index, entry in + row(entry, chosen: index == model.selection) + .id(entry.id) + .onTapGesture { run(entry) } + } + } + .padding(6) + } + .onChange(of: model.selection) { _, _ in + if let id = model.selected?.id { scroller.scrollTo(id) } + } + } + if model.results.isEmpty { + Text("No commands match").foregroundStyle(.secondary).padding(20) + } + } + .frame(width: 560, height: 380) + // The panel's title bar is transparent and hidden; its height isn't a margin to keep. + .ignoresSafeArea() + .onAppear { searching = true } + } + + private func row(_ entry: CommandPaletteEntry, chosen: Bool) -> some View { + HStack { + Text(entry.title).lineLimit(1) + Spacer() + if let shortcut = entry.shortcut { Text(shortcut).font(.callout.monospaced()).foregroundStyle(.secondary) } + } + .padding(.horizontal, 10).padding(.vertical, 6) + .background(chosen ? Color.accentColor.opacity(0.35) : .clear, in: RoundedRectangle(cornerRadius: 6)) + .foregroundStyle(entry.isEnabled ? .primary : .tertiary) + .contentShape(Rectangle()) + } +} + +/// Shows the palette over the editor window and runs what's chosen. It closes when it loses focus, so a click in +/// the editor puts it away. +@MainActor +final class CommandPaletteController { + static let shared = CommandPaletteController() + /// Left out of the palette: the palette itself and the system menus. + static let skipped: Set = ["Command Palette…", "Window", "Help", "Services"] + + private(set) var panel: PalettePanel? + private weak var window: NSWindow? + var isOpen: Bool { panel?.isVisible == true } + + /// ⌥⌘P: opens the palette over `window`, or closes it when it's already open. `menu` is the menu bar to list, + /// the app's own unless a test passes one. + func toggle(session: EditorSession, over window: NSWindow?, menu: NSMenu? = nil) { + if isOpen { close(); return } + self.window = window + let bar = menu ?? NSApp.mainMenu + let entries = (bar.map { CommandPaletteMenu.entries(in: $0, skipping: Self.skipped) } ?? []) + CommandPaletteEntry.tools(for: session) + let model = CommandPaletteModel(entries: entries) + let panel = self.panel ?? makePanel() + panel.contentView = NSHostingView(rootView: CommandPaletteView(model: model, run: { [weak self] in self?.run($0) }, + close: { [weak self] in self?.close() })) + panel.setContentSize(NSSize(width: 560, height: 380)) + if let frame = window?.frame { + panel.setFrameTopLeftPoint(NSPoint(x: frame.midX - 280, y: frame.maxY - 110)) + } else { + panel.center() + } + panel.makeKeyAndOrderFront(nil) + } + + func close() { + panel?.orderOut(nil) + } + + /// Closes the palette, gives the editor back its focus, then runs the entry once the main actor is next free, + /// so a command that looks at the key window finds the editor. + func run(_ entry: CommandPaletteEntry) { + guard entry.isEnabled else { NSSound.beep(); return } + close() + window?.makeKeyAndOrderFront(nil) + Task { @MainActor in entry.perform() } + } + + private func makePanel() -> PalettePanel { + let panel = PalettePanel(contentRect: .zero, styleMask: [.titled, .fullSizeContentView], backing: .buffered, defer: false) + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + // A launcher, not a window: no close, minimise or zoom buttons. + for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { + panel.standardWindowButton(button)?.isHidden = true + } + panel.isFloatingPanel = true + panel.hidesOnDeactivate = true + panel.isReleasedWhenClosed = false + panel.onResignKey = { [weak self] in self?.close() } + self.panel = panel + return panel + } +} + +/// A panel that can take the keyboard and says when it loses it. +final class PalettePanel: NSPanel { + var onResignKey: (() -> Void)? + override var canBecomeKey: Bool { true } + override func resignKey() { + super.resignKey() + onResignKey?() + } +} diff --git a/Compositor/UI/KeyboardShortcuts.swift b/Compositor/UI/KeyboardShortcuts.swift index 8a844a5a1..335fae91e 100644 --- a/Compositor/UI/KeyboardShortcuts.swift +++ b/Compositor/UI/KeyboardShortcuts.swift @@ -76,7 +76,7 @@ struct ShortcutDefinition: Identifiable { entry("New Canvas", "n", 1, menu: true), entry("Open Project", "o", 1, menu: true), entry("Save", "s", 1, menu: true), entry("Save As", "s", 9, menu: true), entry("Export PNG", "e", 9, menu: true), entry("Export JPEG", "s", 11, menu: true), - entry("Close Project", "w", 1, menu: true), entry("Fit Canvas", "0", 1, menu: true), + entry("Close Project", "w", 1, menu: true), entry("Fit Canvas", "0", 1, menu: true), entry("Command Palette", "p", 3, menu: true), entry("Actual Pixels", "1", 1, menu: true), entry("Zoom In", "=", 1, menu: true), entry("Zoom Out", "-", 1, menu: true), entry("Show Transform Controls", "h", 1, menu: true), entry("Hide Compositor", "h", 3, menu: true), entry("Cut", "x", 1, menu: true), diff --git a/CompositorTests/CommandPaletteTests.swift b/CompositorTests/CommandPaletteTests.swift new file mode 100644 index 000000000..70c66a172 --- /dev/null +++ b/CompositorTests/CommandPaletteTests.swift @@ -0,0 +1,200 @@ +import AppKit +import Testing +@testable import Compositor + +@MainActor +struct CommandPaletteTests { + private func entry(_ id: String, enabled: Bool = true) -> CommandPaletteEntry { + CommandPaletteEntry(id: id, shortcut: nil, isEnabled: enabled, perform: {}) + } + + @Test func lettersInOrderMatchAndWordStartsCount() { + #expect(CommandPaletteSearch.score("gb", in: "Filter › Gaussian Blur…") != nil) + #expect(CommandPaletteSearch.score("BLUR", in: "Filter › Gaussian Blur…") != nil, "case doesn't matter") + #expect(CommandPaletteSearch.score("bg", in: "Filter › Gaussian Blur…") == nil, "letters must come in order") + #expect(CommandPaletteSearch.score("", in: "Anything") == 0) + let wordStarts = CommandPaletteSearch.score("gb", in: "Filter › Gaussian Blur…")! + let midWord = CommandPaletteSearch.score("gb", in: "Edit › Debug Tab")! + #expect(wordStarts > midWord) + } + + @Test func rankingPutsTheBestAndEnabledFirst() { + let entries = [entry("Edit › Paste"), entry("Layer › Flip Layer Horizontal"), + entry("Filter › Gaussian Blur…"), entry("Filter › Motion Blur…"), + entry("Edit › Undo", enabled: false), entry("Image › Levels…")] + #expect(CommandPaletteSearch.rank(entries, query: "gau").first?.id == "Filter › Gaussian Blur…") + #expect(CommandPaletteSearch.rank(entries, query: "blur").map(\.id) == ["Filter › Gaussian Blur…", "Filter › Motion Blur…"] + || CommandPaletteSearch.rank(entries, query: "blur").map(\.id) == ["Filter › Motion Blur…", "Filter › Gaussian Blur…"]) + #expect(CommandPaletteSearch.rank(entries, query: "fl").first?.id == "Layer › Flip Layer Horizontal") + // No query: everything, enabled first, in menu order. + let all = CommandPaletteSearch.rank(entries, query: "") + #expect(all.count == entries.count && all.last?.id == "Edit › Undo" && all.first?.id == "Edit › Paste") + #expect(CommandPaletteSearch.rank(entries, query: "e u").map(\.id).last == "Edit › Undo", "disabled after enabled") + } + + final class Hits: NSObject { + var count = 0 + @objc func hit(_ sender: Any?) { count += 1 } + } + + /// A small main menu: the app menu, File (with a separator, a hidden item, a disabled item and a submenu), + /// Window, and View holding the palette's own item. + private func menuBar(_ hits: Hits) -> NSMenu { + func item(_ title: String, key: String = "", mask: NSEvent.ModifierFlags = []) -> NSMenuItem { + let item = NSMenuItem(title: title, action: #selector(Hits.hit(_:)), keyEquivalent: key) + item.keyEquivalentModifierMask = mask + item.target = hits + return item + } + func menu(_ title: String, _ items: [NSMenuItem]) -> NSMenuItem { + let holder = NSMenuItem(title: title, action: nil, keyEquivalent: "") + let submenu = NSMenu(title: title) + submenu.autoenablesItems = false + items.forEach(submenu.addItem) + holder.submenu = submenu + return holder + } + let hidden = item("Hidden"); hidden.isHidden = true + let disabled = item("Disabled"); disabled.isEnabled = false + let main = NSMenu(title: "Main") + main.addItem(menu("Compositor", [item("About Compositor")])) + main.addItem(menu("File", [item("Save", key: "s", mask: .command), .separator(), hidden, disabled, + menu("Open Recent", [item("Clear Menu")])])) + main.addItem(menu("Window", [item("Minimize", key: "m", mask: .command)])) + main.addItem(menu("View", [item("Command Palette…", key: "p", mask: [.command, .option])])) + return main + } + + @Test func menuBarBecomesEntries() throws { + let hits = Hits() + let bar = menuBar(hits) // Kept alive, as NSApp keeps its main menu: entries hold their menus weakly. + let entries = CommandPaletteMenu.entries(in: bar, skipping: ["Window", "Command Palette…"]) + #expect(entries.map(\.id) == ["File › Save", "File › Disabled", "File › Open Recent › Clear Menu"]) + let save = try #require(entries.first) + #expect(save.shortcut == "⌘S" && save.isEnabled) + #expect(entries[1].isEnabled == false) + save.perform() + #expect(hits.count == 1, "running an entry sends its menu item's action") + } + + @Test func shortcutsReadAsTheMenuShowsThem() { + func label(_ key: String, _ mask: NSEvent.ModifierFlags) -> String? { + let item = NSMenuItem(title: "x", action: nil, keyEquivalent: key) + item.keyEquivalentModifierMask = mask + return CommandPaletteMenu.shortcut(of: item) + } + #expect(label("p", [.command, .option]) == "⌥⌘P") + #expect(label("Z", [.command]) == "⇧⌘Z", "an uppercase key implies Shift") + #expect(label("\u{8}", [.option]) == "⌥Delete") + #expect(label("", [.command]) == nil) + } + + @Test func toolsAreEntriesThatSelectTheTool() throws { + let session = EditorSession() + session.createDocument(width: 10, height: 10) + let tools = CommandPaletteEntry.tools(for: session) + #expect(tools.count == NavigationTool.allCases.count - 1) + let brush = try #require(tools.first { $0.id == "Tool › \(NavigationTool.brush.label)" }) + brush.perform() + #expect(session.tool == .brush) + } + + @Test func modelMovesWithinResultsAndResetsOnTyping() { + let model = CommandPaletteModel(entries: [entry("Edit › Paste"), entry("Filter › Gaussian Blur…"), entry("Image › Levels…")]) + model.move(by: -1) + #expect(model.selected?.id == "Image › Levels…", "up from the top wraps to the bottom") + model.move(by: 1) + #expect(model.selected?.id == "Edit › Paste") + model.move(by: 1) + model.query = "lev" + #expect(model.selection == 0 && model.selected?.id == "Image › Levels…") + model.query = "zzz" + #expect(model.selected == nil) + model.move(by: 1) + #expect(model.selection == 0) + } + + @Test func paletteShortcutIsListedAndFree() throws { + let definition = try #require(ShortcutDefinition.all.first { $0.title == "Command Palette" }) + #expect(definition.isMenu && definition.original == ShortcutChord("p", 3)) + #expect(ShortcutSettings.problem(in: [:]) == nil) + #expect(CommandPaletteController.skipped.isSuperset(of: ["Command Palette…", "Window", "Help", "Services"])) + } + + @Test func paletteOpensClosesAndRunsAfterClosing() async throws { + let session = EditorSession() + session.createDocument(width: 10, height: 10) + let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 800, height: 600), styleMask: [.titled], backing: .buffered, defer: false) + let controller = CommandPaletteController() + controller.toggle(session: session, over: window, menu: NSMenu(title: "Empty")) + #expect(controller.isOpen) + var openWhenRun: Bool? + controller.run(CommandPaletteEntry(id: "Test › Run", shortcut: nil, isEnabled: true, perform: { openWhenRun = controller.isOpen })) + try await Task.sleep(for: .milliseconds(100)) + #expect(openWhenRun == false, "the palette is closed before the command runs") + controller.toggle(session: session, over: window, menu: NSMenu(title: "Empty")) + controller.toggle(session: session, over: window, menu: NSMenu(title: "Empty")) + #expect(!controller.isOpen, "⌥⌘P again closes it") + var ran = false + controller.run(CommandPaletteEntry(id: "Test › Off", shortcut: nil, isEnabled: false, perform: { ran = true })) + try await Task.sleep(for: .milliseconds(100)) + #expect(!ran, "a disabled entry never runs") + } + + /// The View menu really has two items called "Snap": each must stay its own row, and run its own action. + @Test func sameTitledItemsStayApart() throws { + let first = Hits(), second = Hits() + let view = NSMenu(title: "View") + view.autoenablesItems = false + for hits in [first, second] { + let item = NSMenuItem(title: "Snap", action: #selector(Hits.hit(_:)), keyEquivalent: "") + item.target = hits + view.addItem(item) + } + let bar = NSMenu(title: "Main") + bar.addItem(NSMenuItem(title: "Compositor", action: nil, keyEquivalent: "")) + bar.items[0].submenu = NSMenu(title: "Compositor") + let holder = NSMenuItem(title: "View", action: nil, keyEquivalent: "") + holder.submenu = view + bar.addItem(holder) + let entries = CommandPaletteMenu.entries(in: bar, skipping: []) + #expect(entries.map(\.title) == ["View › Snap", "View › Snap"]) + #expect(Set(entries.map(\.id)).count == 2, "distinct identities for the list") + entries[1].perform() + #expect(first.count == 0 && second.count == 1) + } + + /// Against the app's own SwiftUI menu bar, not a hand-built one: its commands are listed, disabled ones greyed + /// (SwiftUI takes their action away), and running one runs its SwiftUI action. + @Test func realMenuBarRunsItsCommands() async throws { + let bar = try #require(NSApp.mainMenu) + func entries() -> [CommandPaletteEntry] { CommandPaletteMenu.entries(in: bar, skipping: CommandPaletteController.skipped) } + func gridState() -> NSControl.StateValue? { + bar.items.first { $0.title == "View" }?.submenu?.items.first { $0.title == "Pixel Grid (800% and above)" }?.state + } + let listed = entries() + let titles = Set(listed.map(\.title)) + #expect(titles.contains("Filter › Gaussian Blur…") && !titles.contains("View › Command Palette…")) + // The test host has no document open, so Zoom In is disabled: listed, greyed. + let zoom = try #require(listed.first { $0.title == "View › Zoom In" }) + #expect(!zoom.isEnabled) + let grid = try #require(listed.first { $0.title == "View › Pixel Grid (800% and above)" }) + let before = try #require(gridState()) + grid.perform() + try await Task.sleep(for: .milliseconds(300)) + _ = entries() // Reading the menu again refreshes it, as opening the palette does. + #expect(gridState() != before, "the toggle's SwiftUI binding flipped") + grid.perform() // Put it back. + try await Task.sleep(for: .milliseconds(300)) + } + + @Test func paletteHasNoWindowButtons() throws { + let controller = CommandPaletteController() + controller.toggle(session: EditorSession(), over: nil, menu: NSMenu(title: "Empty")) + defer { controller.close() } + let panel = try #require(controller.panel) + for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { + #expect(panel.standardWindowButton(button)?.isHidden ?? true) + } + } +} diff --git a/README.md b/README.md index 9540063cf..ceee537f1 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ brew install --cask robbietilton-compositor ### Canvas and files - Multiple projects in tabs +- A command palette (⌥⌘P): search every menu command and tool by name, as in Raycast or Obsidian, and run it with Return - Rulers (⌘R), guides dragged from them, a layout grid with adjustable spacing and subdivisions, and Snap To for guides, grid, layers and document bounds - Crop with snapping, ratios including 3:4 and 9:16, and Option for symmetric cropping; with a selection, the crop starts at it - Canvas Size, Image Size and Trim