Refine Quick Open liquid glass

This commit is contained in:
Kaede Akino committed 2026-09-22 22:21:13 +08:00
1 parent e42fe7a548
commit f1420e152f
3 files changed
+140 -37

No files matched your search

+7 -6
View File
@@ -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 {
+79 -31
View File
@@ -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: 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")
@@ -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?()
}
}
}
+54
View File
@@ -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
}