macOS: fix the animation of showing&hiding command palette (#9698)

https://github.com/user-attachments/assets/0069d634-4156-486f-9b59-141fb4565a19

> [!NOTE]
> AI proofread my comments.
pull/9123/merge
Mitchell Hashimoto 2025-11-25 09:47:12 -08:00 committed by GitHub
commit 9511b237f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 35 additions and 14 deletions

View File

@ -44,6 +44,7 @@ struct CommandPaletteView: View {
@State private var query = ""
@State private var selectedIndex: UInt?
@State private var hoveredOptionID: UUID?
@FocusState private var isTextFieldFocused: Bool
// The options that we should show, taking into account any filtering from
// the query.
@ -72,7 +73,7 @@ struct CommandPaletteView: View {
}
VStack(alignment: .leading, spacing: 0) {
CommandPaletteQuery(query: $query) { event in
CommandPaletteQuery(query: $query, isTextFieldFocused: _isTextFieldFocused) { event in
switch (event) {
case .exit:
isPresented = false
@ -144,6 +145,28 @@ struct CommandPaletteView: View {
.shadow(radius: 32, x: 0, y: 12)
.padding()
.environment(\.colorScheme, scheme)
.onChange(of: isPresented) { newValue in
// Reset focus when quickly showing and hiding.
// macOS will destroy this view after a while,
// so task/onAppear will not be called again.
// If you toggle it rather quickly, we reset
// it here when dismissing.
isTextFieldFocused = newValue
if !isPresented {
// This is optional, since most of the time
// there will be a delay before the next use.
// To keep behavior the same as before, we reset it.
query = ""
}
}
.task {
// Grab focus on the first appearance.
// This happens right after onAppear,
// so we dont need to dispatch it again.
// Fixes: https://github.com/ghostty-org/ghostty/issues/8497
// Also fixes initial focus while animating.
isTextFieldFocused = isPresented
}
}
}
@ -153,6 +176,12 @@ fileprivate struct CommandPaletteQuery: View {
var onEvent: ((KeyboardEvent) -> Void)? = nil
@FocusState private var isTextFieldFocused: Bool
init(query: Binding<String>, isTextFieldFocused: FocusState<Bool>, onEvent: ((KeyboardEvent) -> Void)? = nil) {
_query = query
self.onEvent = onEvent
_isTextFieldFocused = isTextFieldFocused
}
enum KeyboardEvent {
case exit
case submit
@ -185,14 +214,6 @@ fileprivate struct CommandPaletteQuery: View {
.frame(height: 48)
.textFieldStyle(.plain)
.focused($isTextFieldFocused)
.onAppear {
// We want to grab focus on appearance. We have to do this after a tick
// on macOS Tahoe otherwise this doesn't work. See:
// https://github.com/ghostty-org/ghostty/issues/8497
DispatchQueue.main.async {
isTextFieldFocused = true
}
}
.onChange(of: isTextFieldFocused) { focused in
if !focused {
onEvent?(.exit)

View File

@ -90,19 +90,19 @@ struct TerminalCommandPaletteView: View {
backgroundColor: ghosttyConfig.backgroundColor,
options: commandOptions
)
.transition(
.move(edge: .top)
.combined(with: .opacity)
.animation(.spring(response: 0.4, dampingFraction: 0.8))
) // Spring animation
.zIndex(1) // Ensure it's on top
Spacer()
}
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
}
.transition(
.move(edge: .top)
.combined(with: .opacity)
)
}
}
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: isPresented)
.onChange(of: isPresented) { newValue in
// When the command palette disappears we need to send focus back to the
// surface view we were overlaid on top of. There's probably a better way