fix(ui): 优化公共控件反馈与文字可读性
This commit is contained in:
1 parent
04a7909dbf
commit
7b789b1ec2
4 files changed
+152
-14
No files matched your search
@@ -176,6 +176,13 @@ Panel height updates coalesce absolute content measurements within a layout cycl
|
||||
next cycle; they never synchronously accumulate viewport deltas or replace actual interaction state.
|
||||
Regression coverage includes `StatusPanelLayoutTests`.
|
||||
|
||||
Shared selection controls use `DS.Motion.select` to keep keyboard and eventless activation
|
||||
immediate; pointer selection retains each control's existing transition. `dsSelectionAnimation`
|
||||
removes self-drawn selection movement when Reduce Motion is enabled. Custom button press feedback
|
||||
is separate from hover and disabled state, while interactive system glass keeps native feedback.
|
||||
Supporting text colors are tested against window, card and elevated solid backgrounds in both
|
||||
appearances. These policies add no timers or retained per-interaction state.
|
||||
|
||||
## Rest timers
|
||||
|
||||
`RestSession` uses absolute `mach_continuous_time` deadlines and recalculates after wake instead
|
||||
|
||||
@@ -297,7 +297,7 @@ struct PillSwitch<Value: Hashable>: View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(options, id: \.value) { option in
|
||||
let selected = option.value == selection
|
||||
Button { selection = option.value } label: {
|
||||
Button { DS.Motion.select { selection = option.value } } label: {
|
||||
Text(option.title)
|
||||
.dsFont(.xs, weight: .semibold)
|
||||
.foregroundStyle(selected ? DS.Palette.onPrimary : DS.Palette.textSecondary)
|
||||
@@ -316,7 +316,7 @@ struct PillSwitch<Value: Hashable>: View {
|
||||
}
|
||||
.padding(DS.Space.s1 / 2)
|
||||
.background(DS.Palette.track, in: Capsule())
|
||||
.animation(DS.Motion.quick, value: selection)
|
||||
.dsSelectionAnimation(DS.Motion.quick, value: selection)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
@@ -392,9 +392,9 @@ private struct DSButtonBody: View {
|
||||
.frame(height: DS.Size.controlHeight)
|
||||
.contentShape(Capsule())
|
||||
.modifier(GlassButtonSurface(kind: kind, active: active, isInsideGlass: isInsideGlass))
|
||||
.opacity(isEnabled ? 1 : 0.5)
|
||||
.onHover { hovering = $0 }
|
||||
.animation(DS.Motion.quick, value: hovering)
|
||||
.opacity(buttonOpacity)
|
||||
}
|
||||
|
||||
private var solidBody: some View {
|
||||
@@ -409,13 +409,20 @@ private struct DSButtonBody: View {
|
||||
RoundedRectangle(cornerRadius: DS.Radius.md).strokeBorder(DS.Palette.border, lineWidth: DS.Size.stroke)
|
||||
}
|
||||
}
|
||||
.opacity(isEnabled ? 1 : 0.5)
|
||||
.contentShape(Rectangle())
|
||||
.onHover { hovering = $0 }
|
||||
.animation(DS.Motion.quick, value: hovering)
|
||||
.opacity(buttonOpacity)
|
||||
}
|
||||
|
||||
private var active: Bool { hovering || configuration.isPressed }
|
||||
private var active: Bool { isEnabled && (hovering || configuration.isPressed) }
|
||||
|
||||
private var buttonOpacity: Double {
|
||||
guard isEnabled else { return 0.5 }
|
||||
// 原生 interactive glass 自带按压效果;自绘按钮需区别悬停和按下,且立即响应。
|
||||
let systemFeedback = DS.Glass.isAvailable && (kind == .primary || (kind == .secondary && !isInsideGlass))
|
||||
return configuration.isPressed && !systemFeedback ? 0.65 : 1
|
||||
}
|
||||
|
||||
private var foreground: Color {
|
||||
switch kind {
|
||||
@@ -514,7 +521,7 @@ struct SidebarButton: View {
|
||||
@State private var hovering = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Button { DS.Motion.select(action) } label: {
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: DS.TextSize.sm.rawValue, weight: .medium))
|
||||
@@ -565,7 +572,7 @@ extension View {
|
||||
.fill(DS.Palette.sidebarSelected)
|
||||
.frame(width: selected.width, height: selected.height)
|
||||
.offset(x: selected.minX, y: selected.minY)
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: selected)
|
||||
.dsSelectionAnimation(.spring(response: 0.35, dampingFraction: 0.8), value: selected)
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
@@ -617,7 +624,7 @@ private struct SidebarGlider: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.6), value: selected)
|
||||
.dsSelectionAnimation(.spring(response: 0.45, dampingFraction: 0.6), value: selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,7 +645,7 @@ struct SegmentedControl<Value: Hashable>: View {
|
||||
ForEach(options, id: \.value) { option in
|
||||
let selected = option.value == selection
|
||||
Button {
|
||||
selection = option.value
|
||||
DS.Motion.select { selection = option.value }
|
||||
} label: {
|
||||
Text(option.title)
|
||||
.dsFont(.sm)
|
||||
@@ -661,7 +668,7 @@ struct SegmentedControl<Value: Hashable>: View {
|
||||
}
|
||||
}
|
||||
.background(DS.Palette.track, in: RoundedRectangle(cornerRadius: Self.radius, style: .continuous))
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: selection)
|
||||
.dsSelectionAnimation(.spring(response: 0.3, dampingFraction: 0.85), value: selection)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,7 +694,7 @@ struct DSToggle: View {
|
||||
|
||||
private var solidBody: some View {
|
||||
Button {
|
||||
isOn.toggle()
|
||||
DS.Motion.select { isOn.toggle() }
|
||||
} label: {
|
||||
ZStack(alignment: isOn ? .trailing : .leading) {
|
||||
Capsule().fill(isOn ? DS.Palette.primary : DS.Palette.neutral300)
|
||||
@@ -701,7 +708,7 @@ struct DSToggle: View {
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.animation(DS.Motion.quick, value: isOn)
|
||||
.dsSelectionAnimation(DS.Motion.quick, value: isOn)
|
||||
.accessibilityLabel(label)
|
||||
.accessibilityValue(isOn ? tr("开") : tr("关"))
|
||||
}
|
||||
|
||||
@@ -21,8 +21,9 @@ extension DS {
|
||||
|
||||
// 文字
|
||||
static let textPrimary = Color.dynamic(light: 0x111827, dark: 0xF3F4F6)
|
||||
static let textSecondary = Color.dynamic(light: 0x6B7280, dark: 0x9CA3AF)
|
||||
static let textTertiary = Color.dynamic(light: 0x9CA3AF, dark: 0x6B7280)
|
||||
static let textSecondary = Color.dynamic(light: 0x4B5563, dark: 0xB6BDC7)
|
||||
/// 小字号辅助说明在窗口与卡片底色上均保持可读对比度。
|
||||
static let textTertiary = Color.dynamic(light: 0x626B78, dark: 0x9CA3AF)
|
||||
static let onPrimary = Color.dynamic(light: 0xFFFFFF, dark: 0xFFFFFF)
|
||||
|
||||
// XStats 的统一配色:浅色为白底蓝色,深色为黑底蓝色。
|
||||
@@ -206,6 +207,14 @@ extension DS {
|
||||
/// 使用统计的来源/时间筛选仅淡入选中底色,不移动正在阅读的数据。
|
||||
static let usageSelection = Animation.timingCurve(0.23, 1, 0.32, 1, duration: 0.18)
|
||||
static let quick = Animation.easeOut(duration: 0.15)
|
||||
|
||||
/// 键盘及无指针事件的选择即时生效;指针选择沿用控件自身的短过渡。
|
||||
@MainActor
|
||||
static func select(event: NSEvent.EventType? = NSApp?.currentEvent?.type, _ action: () -> Void) {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = event != .leftMouseDown && event != .leftMouseUp
|
||||
withTransaction(transaction, action)
|
||||
}
|
||||
}
|
||||
|
||||
/// 温度阈值(°C),用于着色
|
||||
@@ -236,3 +245,20 @@ extension View {
|
||||
shadow(color: level.color, radius: level.radius, x: 0, y: level.y)
|
||||
}
|
||||
}
|
||||
|
||||
/// 自绘选中块的位移服从系统偏好;颜色、按下态等即时反馈保持可见。
|
||||
private struct DSSelectionMotion<Value: Equatable>: ViewModifier {
|
||||
let animation: Animation
|
||||
let value: Value
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.animation(reduceMotion ? nil : animation, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func dsSelectionAnimation<Value: Equatable>(_ animation: Animation, value: Value) -> some View {
|
||||
modifier(DSSelectionMotion(animation: animation, value: value))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import XStatsUI
|
||||
|
||||
@MainActor
|
||||
struct DesignAccessibilityTests {
|
||||
@Test(arguments: [false, true])
|
||||
func supportingTextRemainsReadableOnWindowAndCards(dark: Bool) throws {
|
||||
let appearance = try #require(NSAppearance(named: dark ? .darkAqua : .aqua))
|
||||
for text in [DS.Palette.textSecondary, DS.Palette.textTertiary] {
|
||||
for background in [DS.Palette.background, DS.Palette.surface, DS.Palette.elevated] {
|
||||
#expect(try contrast(text, on: background, appearance: appearance) >= 4.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func contrast(_ foreground: Color, on background: Color, appearance: NSAppearance) throws -> Double {
|
||||
let a = try luminance(foreground, appearance: appearance)
|
||||
let b = try luminance(background, appearance: appearance)
|
||||
return (max(a, b) + 0.05) / (min(a, b) + 0.05)
|
||||
}
|
||||
|
||||
private func luminance(_ color: Color, appearance: NSAppearance) throws -> Double {
|
||||
var resolved: NSColor?
|
||||
appearance.performAsCurrentDrawingAppearance {
|
||||
resolved = NSColor(color).usingColorSpace(.sRGB)
|
||||
}
|
||||
let rgb = try #require(resolved)
|
||||
let components: [Double] = [Double(rgb.redComponent), Double(rgb.greenComponent), Double(rgb.blueComponent)]
|
||||
let values: [Double] = components.map { value in
|
||||
if value <= 0.04045 { return value / 12.92 }
|
||||
return pow((value + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
return 0.2126 * values[0] + 0.7152 * values[1] + 0.0722 * values[2]
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
private final class SelectionFixture {
|
||||
var value = 0
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class SelectionTransactions {
|
||||
var values: [(value: Int, animated: Bool)] = []
|
||||
}
|
||||
|
||||
private struct SelectionTransactionProbe: NSViewRepresentable {
|
||||
let value: Int
|
||||
let transactions: SelectionTransactions
|
||||
|
||||
func makeNSView(context: Context) -> NSView { NSView() }
|
||||
|
||||
func updateNSView(_ view: NSView, context: Context) {
|
||||
transactions.values.append((value, context.transaction.animation != nil && !context.transaction.disablesAnimations))
|
||||
}
|
||||
}
|
||||
|
||||
private struct SelectionFixtureView: View {
|
||||
let model: SelectionFixture
|
||||
let transactions: SelectionTransactions
|
||||
|
||||
var body: some View {
|
||||
SelectionTransactionProbe(value: model.value, transactions: transactions)
|
||||
.frame(width: 160, height: 40)
|
||||
.dsSelectionAnimation(DS.Motion.quick, value: model.value)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct DesignSelectionMotionTests {
|
||||
@Test(arguments: [NSEvent.EventType?.some(.leftMouseUp), .some(.keyDown), .some(.keyUp), .none])
|
||||
func selectionTransactionsRespectInputAndAccessibility(event: NSEvent.EventType?) async throws {
|
||||
let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
||||
let model = SelectionFixture()
|
||||
let transactions = SelectionTransactions()
|
||||
let hosting = NSHostingView(rootView: SelectionFixtureView(model: model, transactions: transactions))
|
||||
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 160, height: 40),
|
||||
styleMask: .borderless, backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.contentView = hosting
|
||||
window.orderFront(nil)
|
||||
defer { window.orderOut(nil); window.contentView = nil; window.close() }
|
||||
hosting.layoutSubtreeIfNeeded()
|
||||
try await Task.sleep(for: .milliseconds(40))
|
||||
DS.Motion.select(event: event) { model.value = 1 }
|
||||
hosting.layoutSubtreeIfNeeded()
|
||||
try await Task.sleep(for: .milliseconds(40))
|
||||
let selected = try #require(transactions.values.last(where: { $0.value == 1 }))
|
||||
#expect(selected.animated == (!reduceMotion && event == .leftMouseUp))
|
||||
#expect(model.value == 1)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user