Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Compositor/CompositorApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
159 changes: 159 additions & 0 deletions Compositor/UI/CommandPalette.swift
Original file line number Diff line number Diff line change
@@ -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<String>) -> [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<String>,
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
}
}
130 changes: 130 additions & 0 deletions Compositor/UI/CommandPaletteView.swift
Original file line number Diff line number Diff line change
@@ -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<String> = ["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?()
}
}
2 changes: 1 addition & 1 deletion Compositor/UI/KeyboardShortcuts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading