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