Mitchell Hashimoto 9bfe4544bf
macOS: Command palette has no selection by default, selection wraps
#7173

(1) The command palette no longer has any selection by default.
If and when we introduce most recently used commands, defaulting to that
would make sense. A selection only appears when the arrow keys are used
or the user starts typing.

(2) The selection with arrow keys now wraps, so if you press "down" on
the last option, it will wrap to the first option, and if you press "up"
on the first option, it will wrap to the last option. This matches both
VSCode and Zed.
2025-04-23 10:37:20 -07:00

258 lines
8.7 KiB
Swift

import SwiftUI
struct CommandOption: Identifiable, Hashable {
let id = UUID()
let title: String
let description: String?
let shortcut: String?
let action: () -> Void
static func == (lhs: CommandOption, rhs: CommandOption) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
struct CommandPaletteView: View {
@Binding var isPresented: Bool
var backgroundColor: Color = Color(nsColor: .windowBackgroundColor)
var options: [CommandOption]
@State private var query = ""
@State private var selectedIndex: UInt?
@State private var hoveredOptionID: UUID?
// The options that we should show, taking into account any filtering from
// the query.
var filteredOptions: [CommandOption] {
if query.isEmpty {
return options
} else {
return options.filter { $0.title.localizedCaseInsensitiveContains(query) }
}
}
var selectedOption: CommandOption? {
guard let selectedIndex else { return nil }
return if selectedIndex < filteredOptions.count {
filteredOptions[Int(selectedIndex)]
} else {
filteredOptions.last
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
CommandPaletteQuery(query: $query) { event in
switch (event) {
case .exit:
isPresented = false
case .submit:
isPresented = false
selectedOption?.action()
case .move(.up):
if filteredOptions.isEmpty { break }
let current = selectedIndex ?? UInt(filteredOptions.count)
selectedIndex = (current == 0)
? UInt(filteredOptions.count - 1)
: current - 1
case .move(.down):
if filteredOptions.isEmpty { break }
let current = selectedIndex ?? UInt.max
selectedIndex = (current >= UInt(filteredOptions.count - 1))
? 0
: current + 1
case .move(_):
// Unknown, ignore
break
}
}
.onChange(of: query) { newValue in
// If the user types a query then we want to make sure the first
// value is selected. If the user clears the query and we were selecting
// the first, we unset any selection.
if !newValue.isEmpty {
if selectedIndex == nil {
selectedIndex = 0
}
} else {
if let selectedIndex, selectedIndex == 0 {
self.selectedIndex = nil
}
}
}
Divider()
.padding(.bottom, 4)
CommandTable(
options: filteredOptions,
selectedIndex: $selectedIndex,
hoveredOptionID: $hoveredOptionID) { option in
isPresented = false
option.action()
}
}
.frame(maxWidth: 500)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(backgroundColor)
.shadow(color: .black.opacity(0.4), radius: 10, x: 0, y: 10)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.black.opacity(0.1), lineWidth: 1)
)
)
.padding()
}
}
/// The text field for building the query for the command palette.
fileprivate struct CommandPaletteQuery: View {
@Binding var query: String
var onEvent: ((KeyboardEvent) -> Void)? = nil
@FocusState private var isTextFieldFocused: Bool
enum KeyboardEvent {
case exit
case submit
case move(MoveCommandDirection)
}
var body: some View {
ZStack {
Group {
Button { onEvent?(.move(.up)) } label: { Color.clear }
.buttonStyle(PlainButtonStyle())
.keyboardShortcut(.upArrow, modifiers: [])
Button { onEvent?(.move(.down)) } label: { Color.clear }
.buttonStyle(PlainButtonStyle())
.keyboardShortcut(.downArrow, modifiers: [])
Button { onEvent?(.move(.up)) } label: { Color.clear }
.buttonStyle(PlainButtonStyle())
.keyboardShortcut(.init("p"), modifiers: [.control])
Button { onEvent?(.move(.down)) } label: { Color.clear }
.buttonStyle(PlainButtonStyle())
.keyboardShortcut(.init("n"), modifiers: [.control])
}
.frame(width: 0, height: 0)
.accessibilityHidden(true)
TextField("Execute a command…", text: $query)
.padding()
.font(.system(size: 14))
.textFieldStyle(PlainTextFieldStyle())
.focused($isTextFieldFocused)
.onAppear {
isTextFieldFocused = true
}
.onChange(of: isTextFieldFocused) { focused in
if !focused {
onEvent?(.exit)
}
}
.onExitCommand { onEvent?(.exit) }
.onMoveCommand { onEvent?(.move($0)) }
.onSubmit { onEvent?(.submit) }
}
}
}
fileprivate struct CommandTable: View {
var options: [CommandOption]
@Binding var selectedIndex: UInt?
@Binding var hoveredOptionID: UUID?
var action: (CommandOption) -> Void
var body: some View {
if options.isEmpty {
Text("No matches")
.foregroundStyle(.secondary)
.padding()
} else {
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(options.enumerated()), id: \.1.id) { index, option in
CommandRow(
option: option,
isSelected: {
if let selected = selectedIndex {
return selected == index ||
(selected >= options.count &&
index == options.count - 1)
} else {
return false
}
}(),
hoveredID: $hoveredOptionID
) {
action(option)
}
}
}
}
.frame(maxHeight: 200)
.onChange(of: selectedIndex) { _ in
guard let selectedIndex,
selectedIndex < options.count else { return }
proxy.scrollTo(
options[Int(selectedIndex)].id)
}
}
}
}
}
/// A single row in the command palette.
fileprivate struct CommandRow: View {
let option: CommandOption
var isSelected: Bool
@Binding var hoveredID: UUID?
var action: () -> Void
var body: some View {
Button(action: action) {
HStack {
Text(option.title)
Spacer()
if let shortcut = option.shortcut {
Text(shortcut)
.font(.system(.body, design: .monospaced))
.kerning(1.5)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.gray.opacity(0.2))
)
}
}
.padding(.horizontal, 6)
.padding(.vertical, 8)
.background(
isSelected
? Color.accentColor.opacity(0.2)
: (hoveredID == option.id
? Color.secondary.opacity(0.2)
: Color.clear)
)
.cornerRadius(6)
}
.help(option.description ?? "")
.buttonStyle(PlainButtonStyle())
.onHover { hovering in
hoveredID = hovering ? option.id : nil
}
.padding(.horizontal, 4)
.padding(.vertical, 1)
}
}