505 lines
17 KiB
Swift
505 lines
17 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AppKit
|
|
import SwiftUI
|
|
import TermindPresentation
|
|
|
|
/// The window-level presentation for Command-P.
|
|
///
|
|
/// The dimming layer owns dismissal while the glass surface intercepts clicks
|
|
/// inside its bounds. Keeping this out of `RootView` leaves the window shell in
|
|
/// charge of *when* the palette is visible and this view in charge of how it
|
|
/// behaves while it is visible.
|
|
struct QuickOpenOverlay: View {
|
|
let dismiss: () -> Void
|
|
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
TermindColor.shadow.opacity(colorScheme == .dark ? 0.14 : 0.07)
|
|
.ignoresSafeArea()
|
|
.contentShape(.rect)
|
|
.onTapGesture(perform: close)
|
|
|
|
QuickOpenView(dismiss: close)
|
|
.shadow(
|
|
color: TermindColor.shadow.opacity(colorScheme == .dark ? 0.30 : 0.18),
|
|
radius: 32,
|
|
y: 14
|
|
)
|
|
.contentShape(.rect)
|
|
.padding(Metrics.Space.xl)
|
|
}
|
|
.onExitCommand(perform: close)
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityLabel(Text(AppStrings.Navigation.quickOpen))
|
|
}
|
|
|
|
private func close() {
|
|
if reduceMotion {
|
|
dismiss()
|
|
} else {
|
|
withAnimation(Motion.panel) { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
|
|
struct QuickOpenView: View {
|
|
let dismiss: () -> Void
|
|
|
|
@Environment(AppModel.self) private var app
|
|
@Environment(WindowModel.self) private var window
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
@State private var query = ""
|
|
@State private var selectedID: AppCommandDescriptor.ID?
|
|
@FocusState private var searchFocused: Bool
|
|
@Namespace private var glassNamespace
|
|
|
|
private var results: QuickOpenResults {
|
|
QuickOpenResults(
|
|
commands: app.commands.commands(app: app, window: window),
|
|
query: query
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
glassIdentity(
|
|
integratedContent
|
|
.clipShape(.rect(cornerRadius: Metrics.QuickOpen.cornerRadius))
|
|
.termindGlassSurface(
|
|
cornerRadius: Metrics.QuickOpen.cornerRadius,
|
|
isInteractive: true
|
|
)
|
|
)
|
|
.frame(width: Metrics.QuickOpen.width, height: Metrics.QuickOpen.height)
|
|
.onAppear {
|
|
selectedID = results.commands.first?.id
|
|
}
|
|
.onChange(of: window.isQuickOpenPresented) {
|
|
if !window.isQuickOpenPresented { searchFocused = false }
|
|
}
|
|
.onChange(of: query) {
|
|
selectedID = results.commands.first?.id
|
|
}
|
|
}
|
|
|
|
private var integratedContent: some View {
|
|
VStack(spacing: 0) {
|
|
searchHeader
|
|
|
|
Divider()
|
|
.opacity(0.48)
|
|
.padding(.horizontal, Metrics.Space.xl)
|
|
|
|
resultContent
|
|
|
|
Divider()
|
|
.opacity(0.36)
|
|
.padding(.horizontal, Metrics.Space.xl)
|
|
|
|
shortcutFooter
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func glassIdentity<Content: View>(_ content: Content) -> some View {
|
|
if #available(macOS 26.0, *) {
|
|
content
|
|
.glassEffectID(QuickOpenGlassSurface.palette, in: glassNamespace)
|
|
.glassEffectTransition(reduceMotion ? .identity : .materialize)
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
|
|
private var searchHeader: some View {
|
|
HStack(spacing: Metrics.Space.l) {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.system(size: 17, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityHidden(true)
|
|
|
|
TextField(
|
|
AppStrings.Navigation.searchPagesChatsWorkspacesHostsAndActions.swiftUIKey,
|
|
text: $query.singleLineText,
|
|
axis: .horizontal
|
|
)
|
|
.singleLinePaste()
|
|
.lineLimit(1)
|
|
.textFieldStyle(.plain)
|
|
.font(.system(size: 17, weight: .medium))
|
|
.focused($searchFocused)
|
|
.background {
|
|
QuickOpenFocusMount(isPresented: window.isQuickOpenPresented) {
|
|
guard window.isQuickOpenPresented else { return }
|
|
searchFocused = true
|
|
}
|
|
}
|
|
.onSubmit(performSelection)
|
|
.onKeyPress(.upArrow) { moveSelection(by: -1) }
|
|
.onKeyPress(.downArrow) { moveSelection(by: 1) }
|
|
.onExitCommand(perform: dismiss)
|
|
.accessibilityLabel(Text(AppStrings.Navigation.searchQuickOpen))
|
|
|
|
if !query.isEmpty {
|
|
Button {
|
|
query = ""
|
|
searchFocused = true
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(.tertiary)
|
|
.contentShape(.circle)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.focusable(false)
|
|
.help(Text(AppStrings.Common.clearSearch))
|
|
.accessibilityLabel(Text(AppStrings.Common.clearSearch))
|
|
}
|
|
|
|
QuickOpenKeycap(keys: "esc")
|
|
.accessibilityHidden(true)
|
|
}
|
|
.padding(.horizontal, Metrics.Space.xl)
|
|
.frame(height: Metrics.QuickOpen.searchHeight)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var resultContent: some View {
|
|
if results.commands.isEmpty {
|
|
VStack(spacing: Metrics.Space.m) {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.system(size: 25, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
.accessibilityHidden(true)
|
|
Text(AppStrings.Common.noResults)
|
|
.font(.headline)
|
|
Text(AppStrings.Common.noResultsMatch(query))
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.accessibilityElement(children: .combine)
|
|
} else {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(listItems) { item in
|
|
Group {
|
|
switch item {
|
|
case .section(let section):
|
|
quickOpenSection(section)
|
|
case .command(let command, let isSelected):
|
|
quickOpenCommand(command, isSelected: isSelected)
|
|
}
|
|
}
|
|
.frame(height: item.height)
|
|
.id(item.id)
|
|
}
|
|
}
|
|
.padding(.vertical, Metrics.Space.xs)
|
|
}
|
|
.onAppear { scrollSelectionIntoView(using: proxy) }
|
|
.onChange(of: selectedID) { _, _ in scrollSelectionIntoView(using: proxy) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private var listItems: [QuickOpenListItem] {
|
|
results.listItems(selectedID: selectedID)
|
|
}
|
|
|
|
private func scrollSelectionIntoView(using proxy: ScrollViewProxy) {
|
|
guard let selectedID else { return }
|
|
proxy.scrollTo(QuickOpenListItem.ID.command(selectedID))
|
|
}
|
|
|
|
private func quickOpenSection(_ section: AppCommandSection) -> some View {
|
|
Text(section.title)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
|
|
.padding(.horizontal, Metrics.Space.xl + Metrics.Space.xs)
|
|
.padding(.bottom, Metrics.Space.xs)
|
|
.accessibilityAddTraits(.isHeader)
|
|
}
|
|
|
|
private func quickOpenCommand(
|
|
_ command: AppCommandDescriptor,
|
|
isSelected: Bool
|
|
) -> some View {
|
|
Button {
|
|
perform(command)
|
|
} label: {
|
|
HStack(spacing: Metrics.Space.l) {
|
|
Image(systemName: command.symbol)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(
|
|
isSelected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)
|
|
)
|
|
.frame(width: Metrics.QuickOpen.iconSize, height: Metrics.QuickOpen.iconSize)
|
|
.background(
|
|
isSelected
|
|
? AnyShapeStyle(Color.accentColor.opacity(0.12))
|
|
: AnyShapeStyle(Color.primary.opacity(0.055)),
|
|
in: .rect(cornerRadius: Metrics.Radius.medium)
|
|
)
|
|
.accessibilityHidden(true)
|
|
|
|
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
|
|
Text(command.title)
|
|
.font(.callout.weight(isSelected ? .medium : .regular))
|
|
.lineLimit(1)
|
|
if let subtitle = command.subtitle {
|
|
Text(subtitle)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, Metrics.Space.l)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
.background(
|
|
Highlight.fill(isSelected: isSelected),
|
|
in: .rect(cornerRadius: Metrics.QuickOpen.selectionCornerRadius)
|
|
)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: Metrics.QuickOpen.selectionCornerRadius)
|
|
.strokeBorder(
|
|
isSelected ? Color.accentColor.opacity(0.24) : .clear,
|
|
lineWidth: 1
|
|
)
|
|
.allowsHitTesting(false)
|
|
}
|
|
.contentShape(.rect(cornerRadius: Metrics.QuickOpen.selectionCornerRadius))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.horizontal, Metrics.Space.s)
|
|
.onHover { hovering in
|
|
if hovering { selectedID = command.id }
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityLabel(commandAccessibilityLabel(command))
|
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
|
.animation(reduceMotion ? nil : Motion.workspaceSwitch, value: isSelected)
|
|
}
|
|
|
|
private var shortcutFooter: some View {
|
|
HStack(spacing: Metrics.Space.xl) {
|
|
QuickOpenShortcutHint(keys: "↑↓", label: String(localized: AppStrings.Navigation.navigate))
|
|
QuickOpenShortcutHint(keys: "↩", label: String(localized: AppStrings.Common.open))
|
|
QuickOpenShortcutHint(keys: "esc", label: String(localized: AppStrings.Common.close))
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, Metrics.Space.xl)
|
|
.frame(height: Metrics.QuickOpen.footerHeight)
|
|
.accessibilityHidden(true)
|
|
}
|
|
|
|
private func commandAccessibilityLabel(_ command: AppCommandDescriptor) -> Text {
|
|
if let subtitle = command.subtitle {
|
|
Text("\(command.title), \(subtitle)")
|
|
} else {
|
|
Text(command.title)
|
|
}
|
|
}
|
|
|
|
private func moveSelection(by offset: Int) -> KeyPress.Result {
|
|
guard let next = results.selection(movingFrom: selectedID, by: offset) else {
|
|
return .ignored
|
|
}
|
|
selectedID = next
|
|
return .handled
|
|
}
|
|
|
|
private func performSelection() {
|
|
let command =
|
|
selectedID.flatMap { id in results.commands.first { $0.id == id } }
|
|
?? results.commands.first
|
|
if let command { perform(command) }
|
|
}
|
|
|
|
private func perform(_ command: AppCommandDescriptor) {
|
|
app.commands.perform(command.target, app: app, window: window)
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
private nonisolated enum QuickOpenGlassSurface: Hashable, Sendable {
|
|
case palette
|
|
}
|
|
|
|
/// SwiftUI can run `onAppear` before the search field joins its window. Request
|
|
/// focus after AppKit mounts it, and retire that request even during a closing transition.
|
|
private struct QuickOpenFocusMount: NSViewRepresentable {
|
|
let isPresented: Bool
|
|
let focus: () -> Void
|
|
|
|
func makeNSView(context: Context) -> MountView {
|
|
let view = MountView()
|
|
view.focus = focus
|
|
view.isPresented = isPresented
|
|
return view
|
|
}
|
|
|
|
func updateNSView(_ view: MountView, context: Context) {
|
|
view.focus = focus
|
|
view.isPresented = isPresented
|
|
}
|
|
|
|
static func dismantleNSView(_ view: MountView, coordinator: ()) {
|
|
view.isPresented = false
|
|
view.focus = nil
|
|
}
|
|
|
|
final class MountView: NSView {
|
|
var focus: (() -> Void)?
|
|
var isPresented = false {
|
|
didSet {
|
|
if isPresented != oldValue { scheduleFocus() }
|
|
}
|
|
}
|
|
private var generation: UInt64 = 0
|
|
|
|
override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
scheduleFocus()
|
|
}
|
|
|
|
private func scheduleFocus() {
|
|
generation &+= 1
|
|
guard isPresented else { return }
|
|
let request = generation
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.deliverFocus(request: request, remainingAttempts: 20)
|
|
}
|
|
}
|
|
|
|
private func deliverFocus(request: UInt64, remainingAttempts: Int) {
|
|
guard isPresented, generation == request, let window, self.window === window else {
|
|
return
|
|
}
|
|
guard window.isKeyWindow else {
|
|
guard remainingAttempts > 0 else { return }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
|
self?.deliverFocus(
|
|
request: request,
|
|
remainingAttempts: remainingAttempts - 1
|
|
)
|
|
}
|
|
return
|
|
}
|
|
focus?()
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct QuickOpenShortcutHint: View {
|
|
let keys: String
|
|
let label: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: Metrics.Space.s) {
|
|
QuickOpenKeycap(keys: keys)
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct QuickOpenKeycap: View {
|
|
let keys: String
|
|
|
|
var body: some View {
|
|
Text(verbatim: keys)
|
|
.font(.system(size: 10, weight: .semibold, design: .rounded))
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, Metrics.Space.s)
|
|
.frame(minWidth: 22, minHeight: 18)
|
|
.background(
|
|
Color.primary.opacity(0.055),
|
|
in: .rect(cornerRadius: Metrics.Radius.small)
|
|
)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: Metrics.Radius.small)
|
|
.strokeBorder(Color.primary.opacity(0.10), lineWidth: 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated struct QuickOpenResults: Equatable, Sendable {
|
|
let commands: [AppCommandDescriptor]
|
|
|
|
init(commands: [AppCommandDescriptor], query: String) {
|
|
let matches = commands.filter { $0.matches(query) }
|
|
self.commands = AppCommandSection.allCases.flatMap { section in
|
|
matches.filter { $0.section == section }
|
|
}
|
|
}
|
|
|
|
var sections: [AppCommandSection] {
|
|
AppCommandSection.allCases.filter { section in
|
|
commands.contains { $0.section == section }
|
|
}
|
|
}
|
|
|
|
func listItems(selectedID: AppCommandDescriptor.ID?) -> [QuickOpenListItem] {
|
|
sections.flatMap { section in
|
|
[.section(section)]
|
|
+ commands.compactMap { command in
|
|
guard command.section == section else { return nil }
|
|
return .command(command, isSelected: command.id == selectedID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func selection(
|
|
movingFrom selectedID: AppCommandDescriptor.ID?,
|
|
by offset: Int
|
|
) -> AppCommandDescriptor.ID? {
|
|
guard !commands.isEmpty else { return nil }
|
|
let current =
|
|
selectedID.flatMap { id in commands.firstIndex { $0.id == id } }
|
|
?? (offset > 0 ? -1 : commands.count)
|
|
let next = min(max(current + offset, 0), commands.count - 1)
|
|
return commands[next].id
|
|
}
|
|
}
|
|
|
|
nonisolated enum QuickOpenListItem: Identifiable, Hashable, Sendable {
|
|
nonisolated enum ID: Hashable, Sendable {
|
|
case section(AppCommandSection)
|
|
case command(AppCommandDescriptor.ID)
|
|
}
|
|
|
|
case section(AppCommandSection)
|
|
case command(AppCommandDescriptor, isSelected: Bool)
|
|
|
|
var id: ID {
|
|
switch self {
|
|
case .section(let section): .section(section)
|
|
case .command(let command, _): .command(command.id)
|
|
}
|
|
}
|
|
|
|
var height: CGFloat {
|
|
switch self {
|
|
case .section: Metrics.QuickOpen.sectionHeight
|
|
case .command: Metrics.QuickOpen.rowHeight
|
|
}
|
|
}
|
|
|
|
var isSelected: Bool {
|
|
guard case .command(_, let isSelected) = self else { return false }
|
|
return isSelected
|
|
}
|
|
}
|