Refine Quick Open liquid glass
This commit is contained in:
1 parent
e42fe7a548
commit
f1420e152f
3 files changed
+140
-37
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user