diff --git a/Sources/CodeIsland/L10n.swift b/Sources/CodeIsland/L10n.swift index 749b3809..741e7c36 100644 --- a/Sources/CodeIsland/L10n.swift +++ b/Sources/CodeIsland/L10n.swift @@ -170,6 +170,8 @@ final class L10n: ObservableObject { // Appearance "preview": "Preview", "panel": "Panel", + "notch_animation_speed": "Open / Close Speed", + "notch_animation_speed_desc": "How fast the island expands and collapses", "max_visible_sessions": "Max Visible Sessions", "max_visible_sessions_desc": "Sessions beyond this limit will be scrollable", "collapsed_width_scale": "Island Width", @@ -519,6 +521,8 @@ final class L10n: ObservableObject { // Appearance "preview": "Vorschau", "panel": "Panel", + "notch_animation_speed": "Öffnungs-/Schließgeschwindigkeit", + "notch_animation_speed_desc": "Wie schnell die Insel auf- und zuklappt", "max_visible_sessions": "Maximal sichtbare Sitzungen", "max_visible_sessions_desc": "Sitzungen über diesem Limit werden scrollbar", "collapsed_width_scale": "Island-Breite", @@ -872,6 +876,8 @@ final class L10n: ObservableObject { // Appearance "preview": "预览", "panel": "面板", + "notch_animation_speed": "展开 / 收起速度", + "notch_animation_speed_desc": "灵动岛展开和收起动画的快慢", "max_visible_sessions": "最大显示会话数", "max_visible_sessions_desc": "超出数量的会话将通过滚动查看", "collapsed_width_scale": "灵动岛宽度", @@ -1225,6 +1231,8 @@ final class L10n: ObservableObject { // Appearance "preview": "預覽", "panel": "面板", + "notch_animation_speed": "展開 / 收合速度", + "notch_animation_speed_desc": "靈動島展開和收合動畫的快慢", "max_visible_sessions": "最大顯示會話數", "max_visible_sessions_desc": "超出數量的會話將透過捲動檢視", "collapsed_width_scale": "靈動島寬度", @@ -1578,6 +1586,8 @@ final class L10n: ObservableObject { // Appearance "preview": "プレビュー", "panel": "パネル", + "notch_animation_speed": "開閉スピード", + "notch_animation_speed_desc": "アイランドが展開・収縮する速さ", "max_visible_sessions": "最大表示セッション数", "max_visible_sessions_desc": "この数を超えるセッションはスクロールして表示します", "collapsed_width_scale": "アイランドの幅", @@ -1931,6 +1941,8 @@ final class L10n: ObservableObject { // Appearance "preview": "미리보기", "panel": "패널", + "notch_animation_speed": "열기 / 닫기 속도", + "notch_animation_speed_desc": "아일랜드가 펼쳐지고 접히는 속도", "max_visible_sessions": "최대 표시 세션 수", "max_visible_sessions_desc": "이 수를 넘는 세션은 스크롤해서 볼 수 있습니다", "collapsed_width_scale": "아일랜드 너비", @@ -2284,6 +2296,8 @@ final class L10n: ObservableObject { // Appearance "preview": "Önizleme", "panel": "Panel", + "notch_animation_speed": "Açılma / Kapanma Hızı", + "notch_animation_speed_desc": "Adanın ne kadar hızlı açılıp kapandığı", "max_visible_sessions": "Maksimum Görünür Oturum", "max_visible_sessions_desc": "Bu sınırdan fazla oturumlar kaydırılabilir olacak", "collapsed_width_scale": "Ada Genişliği", diff --git a/Sources/CodeIsland/NotchAnimation.swift b/Sources/CodeIsland/NotchAnimation.swift index abde6fff..4eed7a81 100644 --- a/Sources/CodeIsland/NotchAnimation.swift +++ b/Sources/CodeIsland/NotchAnimation.swift @@ -2,9 +2,13 @@ import SwiftUI enum NotchAnimation { /// 展开面板:微弹,有少许回弹感 - static let open = Animation.spring(response: 0.42, dampingFraction: 0.82) + static var open: Animation { + .spring(response: NotchAnimationMetrics.openResponse, dampingFraction: 0.82) + } /// 收起面板:临界阻尼,无过冲(防止 NotchPanelShape 底边露出刘海) - static let close = Animation.spring(response: 0.38, dampingFraction: 1.0) + static var close: Animation { + .spring(response: NotchAnimationMetrics.closeResponse, dampingFraction: 1.0) + } /// 通知弹出:快速弹跳,用于 completion/approval 自动展开 static let pop = Animation.spring(response: 0.3, dampingFraction: 0.65) /// 微交互:hover 状态变化、按钮高亮等 diff --git a/Sources/CodeIsland/NotchAnimationMetrics.swift b/Sources/CodeIsland/NotchAnimationMetrics.swift new file mode 100644 index 00000000..e02455cb --- /dev/null +++ b/Sources/CodeIsland/NotchAnimationMetrics.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Speed multiplier for the island's open and close animations. +/// Higher values shorten the spring response while preserving its damping. +enum NotchAnimationSpeed { + static let minimum = 0.5 + static let maximum = 2.0 + static let step = 0.1 + static let normal = 1.0 + + static func clamped(_ value: Double) -> Double { + guard value.isFinite else { return normal } + return min(max(value, minimum), maximum) + } + + static func response(base: TimeInterval, speed: Double) -> TimeInterval { + base / clamped(speed) + } +} + +enum NotchAnimationMetrics { + /// Unscaled spring responses at 1.0x speed. + static let baseOpenResponse: TimeInterval = 0.42 + static let baseCloseResponse: TimeInterval = 0.38 + + static var openResponse: TimeInterval { + NotchAnimationSpeed.response(base: baseOpenResponse, speed: storedSpeed) + } + + static var closeResponse: TimeInterval { + NotchAnimationSpeed.response(base: baseCloseResponse, speed: storedSpeed) + } + + private static var storedSpeed: Double { + let defaults = UserDefaults.standard + guard defaults.object(forKey: SettingsKey.notchAnimationSpeed) != nil else { + return NotchAnimationSpeed.normal + } + return NotchAnimationSpeed.clamped(defaults.double(forKey: SettingsKey.notchAnimationSpeed)) + } +} diff --git a/Sources/CodeIsland/Settings.swift b/Sources/CodeIsland/Settings.swift index 4b3e2007..8ee93f33 100644 --- a/Sources/CodeIsland/Settings.swift +++ b/Sources/CodeIsland/Settings.swift @@ -33,6 +33,7 @@ enum SettingsKey { static let hideWhenNoSession = "hideWhenNoSession" static let smartSuppress = "smartSuppress" static let collapseOnMouseLeave = "collapseOnMouseLeave" + static let notchAnimationSpeed = "notchAnimationSpeed" static let autoCollapseAfterSessionJump = "autoCollapseAfterSessionJump" static let autoExpandOnPermission = "autoExpandOnPermission" static let autoExpandOnCompletion = "autoExpandOnCompletion" @@ -145,6 +146,7 @@ struct SettingsDefaults { static let hideWhenNoSession = false static let smartSuppress = true static let collapseOnMouseLeave = true + static let notchAnimationSpeed = NotchAnimationSpeed.normal static let autoCollapseAfterSessionJump = false static let autoExpandOnPermission = true static let autoExpandOnCompletion = true @@ -232,6 +234,7 @@ class SettingsManager { SettingsKey.hideWhenNoSession: SettingsDefaults.hideWhenNoSession, SettingsKey.smartSuppress: SettingsDefaults.smartSuppress, SettingsKey.collapseOnMouseLeave: SettingsDefaults.collapseOnMouseLeave, + SettingsKey.notchAnimationSpeed: SettingsDefaults.notchAnimationSpeed, SettingsKey.autoCollapseAfterSessionJump: SettingsDefaults.autoCollapseAfterSessionJump, SettingsKey.autoExpandOnPermission: SettingsDefaults.autoExpandOnPermission, SettingsKey.autoExpandOnCompletion: SettingsDefaults.autoExpandOnCompletion, @@ -336,6 +339,11 @@ class SettingsManager { set { defaults.set(newValue, forKey: SettingsKey.collapseOnMouseLeave) } } + var notchAnimationSpeed: Double { + get { NotchAnimationSpeed.clamped(defaults.double(forKey: SettingsKey.notchAnimationSpeed)) } + set { defaults.set(NotchAnimationSpeed.clamped(newValue), forKey: SettingsKey.notchAnimationSpeed) } + } + var hapticOnHover: Bool { get { defaults.bool(forKey: SettingsKey.hapticOnHover) } set { defaults.set(newValue, forKey: SettingsKey.hapticOnHover) } diff --git a/Sources/CodeIsland/SettingsView.swift b/Sources/CodeIsland/SettingsView.swift index 11c14459..ff1461ea 100644 --- a/Sources/CodeIsland/SettingsView.swift +++ b/Sources/CodeIsland/SettingsView.swift @@ -869,6 +869,7 @@ private struct AppearancePage: View { @AppStorage(SettingsKey.collapsedWidthScale) private var collapsedWidthScale = SettingsDefaults.collapsedWidthScale @AppStorage(SettingsKey.notchHeightMode) private var notchHeightModeRaw = SettingsDefaults.notchHeightMode @AppStorage(SettingsKey.customNotchHeight) private var customNotchHeight = SettingsDefaults.customNotchHeight + @AppStorage(SettingsKey.notchAnimationSpeed) private var notchAnimationSpeed = SettingsDefaults.notchAnimationSpeed private var notchHeightMode: Binding { Binding( @@ -877,6 +878,13 @@ private struct AppearancePage: View { ) } + private var notchAnimationSpeedBinding: Binding { + Binding( + get: { NotchAnimationSpeed.clamped(notchAnimationSpeed) }, + set: { notchAnimationSpeed = NotchAnimationSpeed.clamped($0) } + ) + } + var body: some View { Form { Section(l10n["preview"]) { @@ -888,6 +896,25 @@ private struct AppearancePage: View { } Section(l10n["panel"]) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(l10n["notch_animation_speed"]) + Spacer() + Text("\(notchAnimationSpeedBinding.wrappedValue, specifier: "%.1f")×") + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider( + value: notchAnimationSpeedBinding, + in: NotchAnimationSpeed.minimum...NotchAnimationSpeed.maximum, + step: NotchAnimationSpeed.step + ) + .accessibilityLabel(Text(l10n["notch_animation_speed"])) + .accessibilityValue(Text("\(notchAnimationSpeedBinding.wrappedValue, specifier: "%.1f")×")) + Text(l10n["notch_animation_speed_desc"]) + .font(.caption) + .foregroundStyle(.secondary) + } Picker(selection: $maxVisibleSessions) { Text("3").tag(3) Text("5").tag(5) diff --git a/Tests/CodeIslandTests/L10nTests.swift b/Tests/CodeIslandTests/L10nTests.swift index d844ff09..1c42b539 100644 --- a/Tests/CodeIslandTests/L10nTests.swift +++ b/Tests/CodeIslandTests/L10nTests.swift @@ -86,6 +86,24 @@ final class L10nTests: XCTestCase { XCTAssertEqual(L10n.shared["quit"], "Beenden") } + func testNotchAnimationSpeedLabelsAreLocalized() { + let expected: [String: (title: String, description: String)] = [ + "en": ("Open / Close Speed", "How fast the island expands and collapses"), + "de": ("Öffnungs-/Schließgeschwindigkeit", "Wie schnell die Insel auf- und zuklappt"), + "zh": ("展开 / 收起速度", "灵动岛展开和收起动画的快慢"), + "zh-Hant": ("展開 / 收合速度", "靈動島展開和收合動畫的快慢"), + "ja": ("開閉スピード", "アイランドが展開・収縮する速さ"), + "ko": ("열기 / 닫기 속도", "아일랜드가 펼쳐지고 접히는 속도"), + "tr": ("Açılma / Kapanma Hızı", "Adanın ne kadar hızlı açılıp kapandığı"), + ] + + for (language, translation) in expected { + L10n.shared.language = language + XCTAssertEqual(L10n.shared["notch_animation_speed"], translation.title) + XCTAssertEqual(L10n.shared["notch_animation_speed_desc"], translation.description) + } + } + func testEffectiveLanguageReturnsTurkishWhenSystemLocaleIsTurkish() { L10n.shared.language = "system" diff --git a/Tests/CodeIslandTests/NotchAnimationSpeedTests.swift b/Tests/CodeIslandTests/NotchAnimationSpeedTests.swift new file mode 100644 index 00000000..53c4dea5 --- /dev/null +++ b/Tests/CodeIslandTests/NotchAnimationSpeedTests.swift @@ -0,0 +1,64 @@ +import XCTest +@testable import CodeIsland + +final class NotchAnimationSpeedTests: XCTestCase { + private var previousStoredSpeed: Any? + + override func setUp() { + previousStoredSpeed = UserDefaults.standard.object(forKey: SettingsKey.notchAnimationSpeed) + } + + override func tearDown() { + if let previousStoredSpeed { + UserDefaults.standard.set(previousStoredSpeed, forKey: SettingsKey.notchAnimationSpeed) + } else { + UserDefaults.standard.removeObject(forKey: SettingsKey.notchAnimationSpeed) + } + } + + func testExplicitNonPositiveSpeedClampsToMinimum() { + XCTAssertEqual(NotchAnimationSpeed.clamped(0), 0.5) + XCTAssertEqual(NotchAnimationSpeed.clamped(-1), 0.5) + } + + func testSpeedIsClampedToTheSliderRange() { + XCTAssertEqual(NotchAnimationSpeed.clamped(0.1), 0.5) + XCTAssertEqual(NotchAnimationSpeed.clamped(99), 2.0) + XCTAssertEqual(NotchAnimationSpeed.clamped(1.3), 1.3, accuracy: 0.0001) + } + + func testHigherSpeedShortensTheSpringResponse() { + XCTAssertEqual( + NotchAnimationSpeed.response(base: 0.42, speed: 2.0), + 0.21, + accuracy: 0.0001 + ) + } + + func testLowerSpeedLengthensTheSpringResponse() { + XCTAssertEqual( + NotchAnimationSpeed.response(base: 0.42, speed: 0.5), + 0.84, + accuracy: 0.0001 + ) + } + + func testShippedOpenAndCloseResponsesRemainDistinct() { + XCTAssertEqual(NotchAnimationMetrics.baseOpenResponse, 0.42, accuracy: 0.0001) + XCTAssertEqual(NotchAnimationMetrics.baseCloseResponse, 0.38, accuracy: 0.0001) + } + + func testStoredSpeedScalesBothAnimationResponses() { + UserDefaults.standard.set(2.0, forKey: SettingsKey.notchAnimationSpeed) + + XCTAssertEqual(NotchAnimationMetrics.openResponse, 0.21, accuracy: 0.0001) + XCTAssertEqual(NotchAnimationMetrics.closeResponse, 0.19, accuracy: 0.0001) + } + + func testMissingStoredSpeedUsesNormalResponses() { + UserDefaults.standard.removeObject(forKey: SettingsKey.notchAnimationSpeed) + + XCTAssertEqual(NotchAnimationMetrics.openResponse, 0.42, accuracy: 0.0001) + XCTAssertEqual(NotchAnimationMetrics.closeResponse, 0.38, accuracy: 0.0001) + } +}