diff --git a/TFM/Design/DesignTokens.swift b/TFM/Design/DesignTokens.swift index aa3dfbf5..e448dad0 100644 --- a/TFM/Design/DesignTokens.swift +++ b/TFM/Design/DesignTokens.swift @@ -130,12 +130,13 @@ nonisolated enum Metrics { enum QuickOpen { static let width: CGFloat = 640 static let height: CGFloat = 480 - static let cornerRadius: CGFloat = 18 - static let searchHeight: CGFloat = 60 - static let sectionHeight: CGFloat = 28 - static let rowHeight: CGFloat = 48 - static let footerHeight: CGFloat = 36 - static let iconSize: CGFloat = 28 + static let cornerRadius: CGFloat = 24 + static let searchHeight: CGFloat = 58 + static let sectionHeight: CGFloat = 30 + static let rowHeight: CGFloat = 52 + static let footerHeight: CGFloat = 34 + static let iconSize: CGFloat = 30 + static let selectionCornerRadius: CGFloat = 12 } enum Welcome { diff --git a/TFM/Features/QuickOpen/QuickOpenView.swift b/TFM/Features/QuickOpen/QuickOpenView.swift index 2534072c..69b0d46c 100644 --- a/TFM/Features/QuickOpen/QuickOpenView.swift +++ b/TFM/Features/QuickOpen/QuickOpenView.swift @@ -16,23 +16,16 @@ struct QuickOpenOverlay: View { var body: some View { ZStack { - TermindColor.shadow.opacity(colorScheme == .dark ? 0.18 : 0.10) + TermindColor.shadow.opacity(colorScheme == .dark ? 0.14 : 0.07) .ignoresSafeArea() .contentShape(.rect) .onTapGesture(perform: close) QuickOpenView(dismiss: close) - .clipShape(.rect(cornerRadius: Metrics.QuickOpen.cornerRadius)) - .termindGlassSurface(cornerRadius: Metrics.QuickOpen.cornerRadius) - .overlay { - RoundedRectangle(cornerRadius: Metrics.QuickOpen.cornerRadius) - .strokeBorder(Color.primary.opacity(0.10), lineWidth: 1) - .allowsHitTesting(false) - } .shadow( - color: TermindColor.shadow.opacity(colorScheme == .dark ? 0.34 : 0.20), - radius: 28, - y: 12 + color: TermindColor.shadow.opacity(colorScheme == .dark ? 0.30 : 0.18), + radius: 32, + y: 14 ) .contentShape(.rect) .padding(Metrics.Space.xl) @@ -56,10 +49,12 @@ struct QuickOpenView: View { @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( @@ -69,18 +64,15 @@ struct QuickOpenView: View { } var body: some View { - VStack(spacing: 0) { - searchHeader - - Divider().opacity(0.72) - - resultContent - - Divider().opacity(0.72) - - shortcutFooter - } - .frame(maxWidth: Metrics.QuickOpen.width, maxHeight: Metrics.QuickOpen.height) + 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 } @@ -92,6 +84,35 @@ struct QuickOpenView: View { } } + 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: 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") @@ -242,9 +263,17 @@ struct QuickOpenView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .background( Highlight.fill(isSelected: isSelected), - in: .rect(cornerRadius: Metrics.Space.m) + in: .rect(cornerRadius: Metrics.QuickOpen.selectionCornerRadius) ) - .contentShape(.rect) + .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) @@ -254,6 +283,7 @@ struct QuickOpenView: View { .accessibilityElement(children: .combine) .accessibilityLabel(commandAccessibilityLabel(command)) .accessibilityAddTraits(isSelected ? .isSelected : []) + .animation(reduceMotion ? nil : Motion.workspaceSwitch, value: isSelected) } private var shortcutFooter: some View { @@ -297,6 +327,10 @@ struct QuickOpenView: View { } } +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 { @@ -336,15 +370,29 @@ private struct QuickOpenFocusMount: NSViewRepresentable { private func scheduleFocus() { generation &+= 1 - guard isPresented, let window else { return } + guard isPresented else { return } let request = generation - DispatchQueue.main.async { [weak self, weak window] in - guard let self, let window, self.window === window, - isPresented, generation == request, window.isKeyWindow - else { return } - focus?() + 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?() + } } } diff --git a/TFMTests/ChooserInteractionTests.swift b/TFMTests/ChooserInteractionTests.swift index 824a3db5..e361c3cb 100644 --- a/TFMTests/ChooserInteractionTests.swift +++ b/TFMTests/ChooserInteractionTests.swift @@ -11,6 +11,37 @@ import struct TermindDomain.Host @Suite(.requiresVisibleWindow, .serialized) @MainActor struct ChooserInteractionTests { + @Test(arguments: ["light", "dark", "empty", "reduced-transparency"]) + func quickOpenIntegratedGlassRendersAcrossAppearances(_ variant: String) async throws { + let fixture = ChooserFixture() + defer { fixture.close() } + fixture.model.openPage(.hosts) + fixture.mount( + QuickOpenHarness( + workspace: nil, + reduceTransparency: variant == "reduced-transparency" + ) + ) + try await fixture.settle() + fixture.window.appearance = NSAppearance( + named: variant == "dark" ? .darkAqua : .aqua + ) + fixture.window.makeKey() + fixture.model.isQuickOpenPresented = true + let field = try await fixture.quickOpenEditableField() + if variant == "empty" { + field.stringValue = "no matching command exists" + field.delegate?.controlTextDidChange?( + Notification(name: NSControl.textDidChangeNotification, object: field) + ) + try await fixture.settle() + } + + let image = try fixture.capture() + Attachment.record(image, named: "quick-open-integrated-glass-\(variant).png") + #expect(image.count > 20_000) + } + @Test(arguments: ["toolbar", "ssh", "serial"]) func quickOpenTakesFocusAndHandlesTypingNavigationAndReopening(_ source: String) async throws { let fixture = ChooserFixture() @@ -159,6 +190,7 @@ struct ChooserInteractionTests { private struct QuickOpenHarness: View { let workspace: Workspace? + var reduceTransparency = false @Environment(WindowModel.self) private var model var body: some View { @@ -178,6 +210,7 @@ private struct QuickOpenHarness: View { QuickOpenOverlay { model.isQuickOpenPresented = false } } } + .environment(\._accessibilityReduceTransparency, reduceTransparency) } } @@ -263,11 +296,31 @@ private final class ChooserFixture { throw ChooserTestError.searchDidNotTakeFocus } + func quickOpenEditableField() async throws -> NSTextField { + for _ in 0..<100 { + if let field = views.compactMap({ $0 as? NSTextField }).first(where: { + $0.isEditable && !($0 is NSSearchField) + }) { + return field + } + try await Task.sleep(for: .milliseconds(10)) + } + throw ChooserTestError.searchDidNotMount + } + func type(_ text: String, into field: NSTextField) throws { let editor = try #require(field.currentEditor() as? NSTextView) editor.insertText(text, replacementRange: NSRange(location: 0, length: editor.string.utf16.count)) } + func capture() throws -> Data { + let view = try #require(window.contentView) + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + return try #require(bitmap.representation(using: .png, properties: [:])) + } + func key(_ characters: String, code: UInt16) throws { let event = try #require( NSEvent.keyEvent( @@ -292,5 +345,6 @@ private final class ChooserFixture { private enum ChooserTestError: Error { case searchDidNotTakeFocus + case searchDidNotMount case searchDidNotClose }