Friedrich Stoltzfus b4b2b10328 macOS: command pallete scroll improvements
Removes the withAnimation closure which caused flashing when scrolling
up or down with arrow keys. Also removes the center anchor to behave
more like other command palletes (e.g., Zed, Raycast).
2025-04-22 16:12:09 -04:00

232 lines
7.4 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 = 0
@State private var hoveredOptionID: UUID? = nil
// 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? {
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 selectedIndex > 0 {
selectedIndex -= 1
}
case .move(.down):
if selectedIndex < filteredOptions.count - 1 {
selectedIndex += 1
}
case .move(_):
// Unknown, ignore
break
}
}
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: selectedIndex == index ||
(selectedIndex >= options.count &&
index == options.count - 1),
hoveredID: $hoveredOptionID
) {
action(option)
}
}
}
}
.frame(maxHeight: 200)
.onChange(of: selectedIndex) { _ in
guard 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)
}
}