From 942786191ad12b4bb8fe34aa758cdfb4630cd388 Mon Sep 17 00:00:00 2001 From: Jack Yan Date: Wed, 26 Aug 2026 00:18:42 +0800 Subject: [PATCH 1/7] Fix Settings window chrome and dragging --- PingIsland/UI/Views/SettingsWindowView.swift | 188 +++++------------- .../UI/Window/SettingsWindowController.swift | 25 ++- .../SettingsWindowControllerTests.swift | 20 +- 3 files changed, 82 insertions(+), 151 deletions(-) diff --git a/PingIsland/UI/Views/SettingsWindowView.swift b/PingIsland/UI/Views/SettingsWindowView.swift index 37304dd5..11136fed 100644 --- a/PingIsland/UI/Views/SettingsWindowView.swift +++ b/PingIsland/UI/Views/SettingsWindowView.swift @@ -2459,22 +2459,6 @@ private struct SettingsGlassSurface: NSViewRepresentable { } } -private struct SettingsWindowDragHandle: NSViewRepresentable { - func makeNSView(context: Context) -> NSView { - DragHandleView() - } - - func updateNSView(_ nsView: NSView, context: Context) {} - - private final class DragHandleView: NSView { - override var mouseDownCanMoveWindow: Bool { false } - - override func mouseDown(with event: NSEvent) { - window?.performDrag(with: event) - } - } -} - private enum SettingsPanelMetrics { static let windowSize = AppSettings.defaultSettingsWindowSize static let windowMinSize = AppSettings.minimumSettingsWindowSize @@ -2490,7 +2474,6 @@ private enum SettingsPanelMetrics { private struct SettingsPanelContentView: View { let presentation: SettingsPanelPresentation var onClose: (() -> Void)? = nil - var onMinimize: (() -> Void)? = nil @StateObject private var viewModel = SettingsPanelViewModel() @ObservedObject private var settings = AppSettings.shared @@ -2536,7 +2519,7 @@ private struct SettingsPanelContentView: View { } .background(panelBackgroundColor) .ignoresSafeArea() - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + .clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) .preferredColorScheme(.dark) .environment(\.mascotAnimationsEnabled, arePreviewAnimationsActive) .onAppear { @@ -2747,6 +2730,32 @@ private struct SettingsPanelContentView: View { .clear } + private var panelCornerRadius: CGFloat { + presentation == .window ? 0 : 18 + } + + private var sidebarShape: UnevenRoundedRectangle { + let radius: CGFloat = presentation == .window ? 0 : 24 + return UnevenRoundedRectangle( + topLeadingRadius: radius, + bottomLeadingRadius: radius, + bottomTrailingRadius: 0, + topTrailingRadius: 0, + style: .continuous + ) + } + + private var detailShape: UnevenRoundedRectangle { + let radius: CGFloat = presentation == .window ? 0 : 26 + return UnevenRoundedRectangle( + topLeadingRadius: 0, + bottomLeadingRadius: 0, + bottomTrailingRadius: radius, + topTrailingRadius: radius, + style: .continuous + ) + } + private var contentTopInset: CGFloat { switch presentation { case .window: @@ -2768,10 +2777,6 @@ private struct SettingsPanelContentView: View { private var sidebar: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 18) { - if presentation == .window { - sidebarWindowControls - } - ForEach(sidebarSections) { section in VStack(alignment: .leading, spacing: 8) { if let title = section.title { @@ -2801,29 +2806,16 @@ private struct SettingsPanelContentView: View { Spacer(minLength: 0) } .padding(.horizontal, 12) - .padding(.vertical, 14) + .padding(.top, presentation == .window ? 42 : 14) + .padding(.bottom, 14) } .padding(8) .background( - UnevenRoundedRectangle( - topLeadingRadius: 24, - bottomLeadingRadius: 24, - bottomTrailingRadius: 0, - topTrailingRadius: 0, - style: .continuous - ) + sidebarShape .fill(Color.white.opacity(0.055)) .overlay { SettingsGlassSurface(material: .sidebar, blendingMode: .withinWindow) - .clipShape( - UnevenRoundedRectangle( - topLeadingRadius: 24, - bottomLeadingRadius: 24, - bottomTrailingRadius: 0, - topTrailingRadius: 0, - style: .continuous - ) - ) + .clipShape(sidebarShape) .opacity(0.94) } .overlay { @@ -2836,15 +2828,7 @@ private struct SettingsPanelContentView: View { startPoint: .topLeading, endPoint: .bottomTrailing ) - .clipShape( - UnevenRoundedRectangle( - topLeadingRadius: 24, - bottomLeadingRadius: 24, - bottomTrailingRadius: 0, - topTrailingRadius: 0, - style: .continuous - ) - ) + .clipShape(sidebarShape) } .overlay(alignment: .topTrailing) { Circle() @@ -2854,43 +2838,12 @@ private struct SettingsPanelContentView: View { .offset(x: 28, y: -26) } ) - .overlay( - UnevenRoundedRectangle( - topLeadingRadius: 24, - bottomLeadingRadius: 24, - bottomTrailingRadius: 0, - topTrailingRadius: 0, - style: .continuous - ) - .strokeBorder(Color.white.opacity(0.10), lineWidth: 1) + .overlay(sidebarShape.strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) + .shadow( + color: Color.black.opacity(presentation == .window ? 0 : 0.20), + radius: 24, + y: 14 ) - .shadow(color: Color.black.opacity(0.20), radius: 24, y: 14) - } - - private var sidebarWindowControls: some View { - HStack(spacing: 10) { - WindowControlButton(color: Color(red: 1.0, green: 0.37, blue: 0.36)) { - if let onClose { - onClose() - } else { - currentWindow?.performClose(nil) - } - } - - WindowControlButton(color: Color(red: 1.0, green: 0.74, blue: 0.18)) { - if let onMinimize { - onMinimize() - } else { - currentWindow?.miniaturize(nil) - } - } - - SettingsWindowDragHandle() - .frame(maxWidth: .infinity, minHeight: 22, maxHeight: 22) - .accessibilityHidden(true) - } - .padding(.horizontal, 8) - .padding(.bottom, 2) } @ViewBuilder @@ -2925,32 +2878,18 @@ private struct SettingsPanelContentView: View { } } .padding(.horizontal, 22) - .padding(.top, 24) + .padding(.top, presentation == .window ? 42 : 24) .padding(.bottom, 24) .frame(maxWidth: .infinity, alignment: .leading) } .id(currentCategory) .accessibilityIdentifier("settings.detail.\(currentCategory.rawValue)") .background( - UnevenRoundedRectangle( - topLeadingRadius: 0, - bottomLeadingRadius: 0, - bottomTrailingRadius: 26, - topTrailingRadius: 26, - style: .continuous - ) + detailShape .fill(Color.white.opacity(0.035)) .overlay { SettingsGlassSurface(material: .hudWindow, blendingMode: .withinWindow) - .clipShape( - UnevenRoundedRectangle( - topLeadingRadius: 0, - bottomLeadingRadius: 0, - bottomTrailingRadius: 26, - topTrailingRadius: 26, - style: .continuous - ) - ) + .clipShape(detailShape) .opacity(0.96) } .overlay { @@ -2963,28 +2902,15 @@ private struct SettingsPanelContentView: View { startPoint: .topLeading, endPoint: .bottomTrailing ) - .clipShape( - UnevenRoundedRectangle( - topLeadingRadius: 0, - bottomLeadingRadius: 0, - bottomTrailingRadius: 26, - topTrailingRadius: 26, - style: .continuous - ) - ) + .clipShape(detailShape) } ) - .overlay( - UnevenRoundedRectangle( - topLeadingRadius: 0, - bottomLeadingRadius: 0, - bottomTrailingRadius: 26, - topTrailingRadius: 26, - style: .continuous - ) - .strokeBorder(Color.white.opacity(0.10), lineWidth: 1) + .overlay(detailShape.strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) + .shadow( + color: Color.black.opacity(presentation == .window ? 0 : 0.16), + radius: 24, + y: 14 ) - .shadow(color: Color.black.opacity(0.16), radius: 24, y: 14) } private var currentCategory: SettingsCategory { @@ -4034,14 +3960,12 @@ private struct SettingsPanelContentView: View { struct SettingsWindowView: View { var onClose: (() -> Void)? = nil - var onMinimize: (() -> Void)? = nil var body: some View { AppLocalizedRootView { SettingsPanelContentView( presentation: .window, - onClose: onClose, - onMinimize: onMinimize + onClose: onClose ) .accessibilityIdentifier("settings.root") } @@ -4136,24 +4060,6 @@ private struct SidebarItemView: View { } } -private struct WindowControlButton: View { - let color: Color - let action: () -> Void - - var body: some View { - Button(action: action) { - Circle() - .fill(color) - .frame(width: 12, height: 12) - .overlay( - Circle() - .strokeBorder(Color.black.opacity(0.18), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) - } -} - private struct SettingsSectionCard: View { let title: String private let titleAccessory: AnyView? diff --git a/PingIsland/UI/Window/SettingsWindowController.swift b/PingIsland/UI/Window/SettingsWindowController.swift index 56b5ecf1..679bdd85 100644 --- a/PingIsland/UI/Window/SettingsWindowController.swift +++ b/PingIsland/UI/Window/SettingsWindowController.swift @@ -94,7 +94,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { ) let window = SettingsPanelWindow( contentRect: NSRect(origin: .zero, size: defaultContentSize), - styleMask: [.borderless, .resizable], + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false ) @@ -103,10 +103,15 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { window.title = "" window.titleVisibility = .hidden window.titlebarAppearsTransparent = true - window.isMovableByWindowBackground = false - window.isOpaque = false - window.backgroundColor = .clear - window.hasShadow = false + window.isMovableByWindowBackground = true + window.isOpaque = true + window.backgroundColor = NSColor( + calibratedRed: 0.055, + green: 0.065, + blue: 0.085, + alpha: 1 + ) + window.hasShadow = true window.minSize = minimumContentSize window.maxSize = maximumContentSize window.setContentSize(defaultContentSize) @@ -115,10 +120,15 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { window.toolbar = nil window.showsToolbarButton = false window.titlebarSeparatorStyle = .none - window.collectionBehavior = [.fullScreenAuxiliary, .moveToActiveSpace] + window.collectionBehavior = [.fullScreenPrimary, .moveToActiveSpace] window.tabbingMode = .disallowed window.isReleasedWhenClosed = false + window.standardWindowButton(.closeButton)?.isHidden = false + window.standardWindowButton(.miniaturizeButton)?.isHidden = false + window.standardWindowButton(.zoomButton)?.isHidden = false + window.standardWindowButton(.zoomButton)?.isEnabled = true + super.init(window: window) self.window?.delegate = self @@ -126,9 +136,6 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { SettingsWindowView( onClose: { [weak self] in self?.dismiss() - }, - onMinimize: { [weak self] in - self?.window?.miniaturize(nil) } ) } diff --git a/PingIslandTests/SettingsWindowControllerTests.swift b/PingIslandTests/SettingsWindowControllerTests.swift index f2b3e034..cf6b7cb1 100644 --- a/PingIslandTests/SettingsWindowControllerTests.swift +++ b/PingIslandTests/SettingsWindowControllerTests.swift @@ -107,7 +107,25 @@ final class SettingsWindowControllerTests: XCTestCase { XCTAssertTrue(window.isVisible) XCTAssertFalse(window.isMiniaturized) - XCTAssertFalse(window.isMovableByWindowBackground) + XCTAssertTrue(window.isMovableByWindowBackground) + XCTAssertTrue(window.isOpaque) + XCTAssertTrue(window.hasShadow) + XCTAssertEqual(window.backgroundColor.alphaComponent, 1, accuracy: 0.001) + XCTAssertTrue(window.styleMask.contains(.titled)) + XCTAssertTrue(window.styleMask.contains(.closable)) + XCTAssertTrue(window.styleMask.contains(.miniaturizable)) + XCTAssertTrue(window.styleMask.contains(.resizable)) + XCTAssertTrue(window.styleMask.contains(.fullSizeContentView)) + XCTAssertTrue(window.collectionBehavior.contains(.fullScreenPrimary)) + XCTAssertFalse(window.collectionBehavior.contains(.fullScreenAuxiliary)) + + let closeButton = try XCTUnwrap(window.standardWindowButton(.closeButton)) + let minimizeButton = try XCTUnwrap(window.standardWindowButton(.miniaturizeButton)) + let zoomButton = try XCTUnwrap(window.standardWindowButton(.zoomButton)) + XCTAssertFalse(closeButton.isHidden) + XCTAssertFalse(minimizeButton.isHidden) + XCTAssertFalse(zoomButton.isHidden) + XCTAssertTrue(zoomButton.isEnabled) XCTAssertEqual(window.contentRect(forFrameRect: window.frame).size.width, SettingsWindowDefaults.defaultContentSize.width) XCTAssertEqual(window.contentRect(forFrameRect: window.frame).size.height, SettingsWindowDefaults.defaultContentSize.height) From 92b3fed04a8d97997ecf2d0e80c9b4f400595240 Mon Sep 17 00:00:00 2001 From: Jack Yan Date: Wed, 26 Aug 2026 00:28:51 +0800 Subject: [PATCH 2/7] Make Settings category switching nonblocking --- PingIsland/App/AppDelegate.swift | 3 + PingIsland/Core/SoundPackCatalog.swift | 167 ++++++------ PingIsland/UI/Views/SettingsWindowView.swift | 247 ++++++------------ .../SettingsPanelViewModelTests.swift | 40 +++ PingIslandUITests/PingIslandUITests.swift | 18 ++ 5 files changed, 228 insertions(+), 247 deletions(-) diff --git a/PingIsland/App/AppDelegate.swift b/PingIsland/App/AppDelegate.swift index a751e4be..a937cfa7 100644 --- a/PingIsland/App/AppDelegate.swift +++ b/PingIsland/App/AppDelegate.swift @@ -27,6 +27,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { if !launchConfiguration.isRunningTests { UpdateManager.shared.start() UserIdleAutoProtection.shared.start() + Task { + await SoundPackCatalog.shared.refreshInBackground() + } Task { await TelemetryService.shared.start() } diff --git a/PingIsland/Core/SoundPackCatalog.swift b/PingIsland/Core/SoundPackCatalog.swift index 76fd4b02..9897588a 100644 --- a/PingIsland/Core/SoundPackCatalog.swift +++ b/PingIsland/Core/SoundPackCatalog.swift @@ -145,17 +145,17 @@ enum Island8BitSound: String, CaseIterable, Identifiable { static let allOrdered: [Island8BitSound] = Island8BitSound.allCases.sorted { $0.label < $1.label } } -struct OpenPeonSoundEntry: Decodable, Equatable { +struct OpenPeonSoundEntry: Decodable, Equatable, Sendable { let file: String let label: String? let sha256: String? } -struct OpenPeonCategoryManifest: Decodable, Equatable { +struct OpenPeonCategoryManifest: Decodable, Equatable, Sendable { let sounds: [OpenPeonSoundEntry] } -struct OpenPeonManifest: Decodable, Equatable { +struct OpenPeonManifest: Decodable, Equatable, Sendable { let cespVersion: String let name: String let displayName: String? @@ -173,7 +173,7 @@ struct OpenPeonManifest: Decodable, Equatable { } } -struct SoundPack: Identifiable, Equatable { +struct SoundPack: Identifiable, Equatable, Sendable { let rootURL: URL let manifest: OpenPeonManifest @@ -195,6 +195,76 @@ struct SoundPack: Identifiable, Equatable { } } +private enum SoundPackScanner { + nonisolated static func load(importedPaths: [String]) -> [SoundPack] { + var dedupedRoots: [String: URL] = [:] + let importedRoots = importedPaths.map { URL(fileURLWithPath: $0, isDirectory: true) } + + for url in discoverPackRoots() + importedRoots { + let standardized = url.standardizedFileURL + dedupedRoots[standardized.path] = standardized + } + + return dedupedRoots.values.compactMap(loadPack(at:)) + .sorted { + if $0.displayName == $1.displayName { + return $0.rootURL.path < $1.rootURL.path + } + return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + } + + nonisolated static func loadPack(at rootURL: URL) -> SoundPack? { + let manifestURL = rootURL.appendingPathComponent("openpeon.json") + guard FileManager.default.fileExists(atPath: manifestURL.path) else { return nil } + + do { + let data = try Data(contentsOf: manifestURL) + let manifest = try JSONDecoder().decode(OpenPeonManifest.self, from: data) + guard manifest.cespVersion.hasPrefix("1.") else { + return nil + } + return SoundPack(rootURL: rootURL, manifest: manifest) + } catch { + return nil + } + } + + private nonisolated static func discoverPackRoots() -> [URL] { + let fileManager = FileManager.default + let home = fileManager.homeDirectoryForCurrentUser + let currentDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true) + let candidateDirectories = [ + home.appendingPathComponent(".openpeon/packs", isDirectory: true), + home.appendingPathComponent(".claude/hooks/peon-ping/packs", isDirectory: true), + currentDirectory.appendingPathComponent(".claude/hooks/peon-ping/packs", isDirectory: true) + ] + + return candidateDirectories.flatMap { directory in + guard fileManager.fileExists(atPath: directory.path) else { return [] } + return packDirectories(in: directory) + } + } + + private nonisolated static func packDirectories(in directory: URL) -> [URL] { + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { + return [] + } + + var roots: [URL] = [] + for case let url as URL in enumerator { + guard url.lastPathComponent == "openpeon.json" else { continue } + roots.append(url.deletingLastPathComponent()) + enumerator.skipDescendants() + } + return roots + } +} + @MainActor final class SoundPackCatalog: NSObject, ObservableObject, NSSoundDelegate { static let shared = SoundPackCatalog() @@ -210,25 +280,21 @@ final class SoundPackCatalog: NSObject, ObservableObject, NSSoundDelegate { private override init() { super.init() - refresh() } func refresh() { - var dedupedRoots: [String: URL] = [:] - for url in discoverPackRoots() + importedPackRoots() { - let standardized = url.standardizedFileURL - dedupedRoots[standardized.path] = standardized - } - - let packs = dedupedRoots.values.compactMap(loadPack(at:)) - .sorted { - if $0.displayName == $1.displayName { - return $0.rootURL.path < $1.rootURL.path - } - return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending - } + availablePacks = SoundPackScanner.load(importedPaths: importedPackPaths) + } - availablePacks = packs + func refreshInBackground() async { + let paths = importedPackPaths + let packs = await Task.detached(priority: .userInitiated) { + SoundPackScanner.load(importedPaths: paths) + }.value + guard !Task.isCancelled else { return } + if availablePacks != packs { + availablePacks = packs + } } func importPack() -> Bool { @@ -242,12 +308,12 @@ final class SoundPackCatalog: NSObject, ObservableObject, NSSoundDelegate { guard panel.runModal() == .OK, let url = panel.url?.standardizedFileURL else { return false } - guard loadPack(at: url) != nil else { + guard SoundPackScanner.loadPack(at: url) != nil else { NSSound.beep() return false } - var paths = Set(importedPackRoots().map(\.path)) + var paths = Set(importedPackPaths) paths.insert(url.path) defaults.set(Array(paths).sorted(), forKey: Keys.importedPackPaths) refresh() @@ -305,63 +371,8 @@ final class SoundPackCatalog: NSObject, ObservableObject, NSSoundDelegate { AppSoundPlayback.shared.clearIfActive(sound) } - private func importedPackRoots() -> [URL] { - let paths = defaults.stringArray(forKey: Keys.importedPackPaths) ?? [] - return paths.map { URL(fileURLWithPath: $0, isDirectory: true) } - } - - private func discoverPackRoots() -> [URL] { - let fm = FileManager.default - let home = fm.homeDirectoryForCurrentUser - let cwd = URL(fileURLWithPath: fm.currentDirectoryPath, isDirectory: true) - - let candidateDirectories = [ - home.appendingPathComponent(".openpeon/packs", isDirectory: true), - home.appendingPathComponent(".claude/hooks/peon-ping/packs", isDirectory: true), - cwd.appendingPathComponent(".claude/hooks/peon-ping/packs", isDirectory: true) - ] - - var roots: [URL] = [] - for directory in candidateDirectories where fileExists(directory) { - roots.append(contentsOf: packDirectories(in: directory)) - } - return roots - } - - private func packDirectories(in directory: URL) -> [URL] { - let fm = FileManager.default - guard let enumerator = fm.enumerator( - at: directory, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles, .skipsPackageDescendants] - ) else { - return [] - } - - var roots: [URL] = [] - for case let url as URL in enumerator { - guard url.lastPathComponent == "openpeon.json" else { continue } - roots.append(url.deletingLastPathComponent()) - enumerator.skipDescendants() - } - - return roots - } - - private func loadPack(at rootURL: URL) -> SoundPack? { - let manifestURL = rootURL.appendingPathComponent("openpeon.json") - guard fileExists(manifestURL) else { return nil } - - do { - let data = try Data(contentsOf: manifestURL) - let manifest = try JSONDecoder().decode(OpenPeonManifest.self, from: data) - guard manifest.cespVersion.hasPrefix("1.") else { - return nil - } - return SoundPack(rootURL: rootURL, manifest: manifest) - } catch { - return nil - } + private var importedPackPaths: [String] { + defaults.stringArray(forKey: Keys.importedPackPaths) ?? [] } private func resolvedSoundURL(for entry: OpenPeonSoundEntry, in pack: SoundPack) -> URL? { diff --git a/PingIsland/UI/Views/SettingsWindowView.swift b/PingIsland/UI/Views/SettingsWindowView.swift index 11136fed..dcbf3ee2 100644 --- a/PingIsland/UI/Views/SettingsWindowView.swift +++ b/PingIsland/UI/Views/SettingsWindowView.swift @@ -105,13 +105,12 @@ struct QoderCLIHookRefreshNoticeGate { } } -struct ClosedNotchUsageAvailability: Equatable { +struct ClosedNotchUsageAvailability: Equatable, Sendable { var hasClaudeSevenDay = false var hasCodexSevenDay = false - @MainActor - static func current() -> ClosedNotchUsageAvailability { - guard AppSettings.showUsage else { + nonisolated static func load(showUsage: Bool) -> ClosedNotchUsageAvailability { + guard showUsage else { return ClosedNotchUsageAvailability() } @@ -195,6 +194,11 @@ final class SettingsPanelViewModel: ObservableObject { ) private var hookFeedbackClearTasks: [String: Task] = [:] + private var lastCategoryRefreshDates: [SettingsCategory: Date] = [:] + private let categoryRefreshInterval: TimeInterval + private let categoryRefreshClock: @MainActor () -> Date + private let closedNotchUsageAvailabilityLoader: @MainActor (Bool) async -> ClosedNotchUsageAvailability + private let soundPackRefreshProvider: @MainActor () async -> Void private let qoderCLIHookRefreshStatusProvider: @MainActor () -> HookInstaller.QoderCLIHookRefreshStatus? private let qoderCLIHookRefreshNoticeGate: QoderCLIHookRefreshNoticeGate private let accessibilityStatusProvider: @MainActor (_ prompt: Bool) -> Bool @@ -213,6 +217,16 @@ final class SettingsPanelViewModel: ObservableObject { return } NSWorkspace.shared.open(url) + }, + categoryRefreshInterval: TimeInterval = 5, + categoryRefreshClock: @escaping @MainActor () -> Date = Date.init, + closedNotchUsageAvailabilityLoader: @escaping @MainActor (Bool) async -> ClosedNotchUsageAvailability = { showUsage in + await Task.detached(priority: .userInitiated) { + ClosedNotchUsageAvailability.load(showUsage: showUsage) + }.value + }, + soundPackRefreshProvider: @escaping @MainActor () async -> Void = { + await SoundPackCatalog.shared.refreshInBackground() } ) { self.qoderCLIHookRefreshStatusProvider = qoderCLIHookRefreshStatusProvider @@ -221,6 +235,10 @@ final class SettingsPanelViewModel: ObservableObject { ) self.accessibilityStatusProvider = accessibilityStatusProvider self.accessibilitySettingsOpener = accessibilitySettingsOpener + self.categoryRefreshInterval = categoryRefreshInterval + self.categoryRefreshClock = categoryRefreshClock + self.closedNotchUsageAvailabilityLoader = closedNotchUsageAvailabilityLoader + self.soundPackRefreshProvider = soundPackRefreshProvider } var visibleHookProfiles: [ManagedHookClientProfile] { @@ -254,17 +272,20 @@ final class SettingsPanelViewModel: ObservableObject { refreshLocalizedState() } - func refresh(for category: SettingsCategory) { - launchAtLogin = SMAppService.mainApp.status == .enabled - refreshAccessibilityStatus() - refreshLocalizedState() + func refresh(for category: SettingsCategory, force: Bool = false) async { + let refreshDate = categoryRefreshClock() + if !force, + let lastRefresh = lastCategoryRefreshDates[category], + refreshDate.timeIntervalSince(lastRefresh) < categoryRefreshInterval { + return + } switch category { case .display: ScreenSelector.shared.refreshScreens() - refreshClosedNotchUsageAvailability() + await refreshClosedNotchUsageAvailability() case .sound: - SoundPackCatalog.shared.refresh() + await soundPackRefreshProvider() case .integration: refreshHookInstallationStates() refreshIDEExtensionInstallationStates() @@ -274,6 +295,9 @@ final class SettingsPanelViewModel: ObservableObject { case .general, .shortcuts, .mascot, .analytics, .remote, .labs, .about: break } + + guard !Task.isCancelled else { return } + lastCategoryRefreshDates[category] = refreshDate } func refreshAccessibilityStatus() { @@ -311,8 +335,11 @@ final class SettingsPanelViewModel: ObservableObject { qoderCLIHookRefreshNoticeStatus = status } - func refreshClosedNotchUsageAvailability() { - closedNotchUsageAvailability = ClosedNotchUsageAvailability.current() + func refreshClosedNotchUsageAvailability() async { + let showUsage = AppSettings.showUsage + let availability = await closedNotchUsageAvailabilityLoader(showUsage) + guard !Task.isCancelled else { return } + closedNotchUsageAvailability = availability } func refreshBridgeHealthStatus() { @@ -867,8 +894,9 @@ private final class AgentUsageAnalyticsViewModel: ObservableObject { private var refreshTask: Task? - var isInitialLoading: Bool { - isRefreshing && !hasLoadedSnapshot + func refreshIfNeeded() { + guard !hasLoadedSnapshot, !isRefreshing else { return } + refresh() } func refresh() { @@ -909,7 +937,7 @@ private final class AgentUsageAnalyticsViewModel: ObservableObject { } private struct AgentUsageAnalyticsContent: View { - @StateObject private var viewModel = AgentUsageAnalyticsViewModel() + @ObservedObject var viewModel: AgentUsageAnalyticsViewModel var body: some View { VStack(alignment: .leading, spacing: 20) { @@ -951,16 +979,6 @@ private struct AgentUsageAnalyticsContent: View { .frame(maxWidth: .infinity) } } - .overlay { - if viewModel.isInitialLoading { - AgentUsageLoadingOverlay() - .transition(.opacity) - } - } - .animation(.easeInOut(duration: 0.18), value: viewModel.isInitialLoading) - .onAppear { - viewModel.refresh() - } } private var activityMapCard: some View { @@ -1050,42 +1068,6 @@ private struct AgentUsageAnalyticsContent: View { } } -private struct AgentUsageLoadingOverlay: View { - var body: some View { - ZStack { - RoundedRectangle(cornerRadius: 24, style: .continuous) - .fill(Color.black.opacity(0.22)) - .background( - SettingsGlassSurface(material: .hudWindow, blendingMode: .withinWindow) - .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) - .opacity(0.90) - ) - - VStack(spacing: 10) { - ProgressView() - .controlSize(.small) - .tint(.white.opacity(0.84)) - - Text(appLocalized: "正在加载统计") - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.white.opacity(0.72)) - } - .padding(.horizontal, 18) - .padding(.vertical, 14) - .background( - Capsule(style: .continuous) - .fill(Color.white.opacity(0.08)) - .overlay( - Capsule(style: .continuous) - .strokeBorder(Color.white.opacity(0.12), lineWidth: 1) - ) - ) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .allowsHitTesting(true) - } -} - private struct AgentUsageRangeControl: View { let selectedRange: AgentUsageRange let isRefreshing: Bool @@ -2388,50 +2370,6 @@ private enum AgentUsageFormat { } } -private struct SettingsCategoryLoadingView: View { - let category: SettingsCategory - - var body: some View { - SettingsSectionCard(title: category.title) { - VStack(spacing: 12) { - ProgressView() - .controlSize(.regular) - .tint(.white.opacity(0.82)) - - Text(verbatim: loadingTitle) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.white.opacity(0.88)) - - Text(verbatim: loadingSubtitle) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white.opacity(0.54)) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity, minHeight: 180) - .padding(.horizontal, 24) - .padding(.vertical, 24) - } - } - - private var loadingTitle: String { - AppLocalization.format("正在加载%@设置…", AppLocalization.string(category.title)) - } - - private var loadingSubtitle: String { - switch category { - case .display: - return AppLocalization.string("正在刷新显示器与用量展示状态") - case .sound: - return AppLocalization.string("正在扫描可用声音主题包") - case .integration: - return AppLocalization.string("正在检查 Hooks、IDE 扩展与客户端安装状态") - case .general, .shortcuts, .mascot, .analytics, .remote, .labs, .about: - return AppLocalization.string("马上就好") - } - } -} - private struct SettingsSidebarSection: Identifiable { let title: String? let categories: [SettingsCategory] @@ -2476,6 +2414,7 @@ private struct SettingsPanelContentView: View { var onClose: (() -> Void)? = nil @StateObject private var viewModel = SettingsPanelViewModel() + @StateObject private var analyticsViewModel = AgentUsageAnalyticsViewModel() @ObservedObject private var settings = AppSettings.shared @ObservedObject private var screenSelector = ScreenSelector.shared @ObservedObject private var updateManager = UpdateManager.shared @@ -2491,7 +2430,6 @@ private struct SettingsPanelContentView: View { @State private var consecutiveGeneralTapCount = 0 @State private var isAccessibilityPollingActive = false @State private var arePreviewAnimationsActive = false - @State private var loadingCategory: SettingsCategory? @State private var categoryRefreshTask: Task? var body: some View { @@ -2528,7 +2466,7 @@ private struct SettingsPanelContentView: View { isAccessibilityPollingActive = isVisible arePreviewAnimationsActive = isVisible - scheduleCategoryRefresh(for: currentCategory, showLoading: false) + scheduleCategoryRefresh(for: currentCategory) showAnalyticsConsentPromptIfNeeded() } .onDisappear { @@ -2536,7 +2474,6 @@ private struct SettingsPanelContentView: View { arePreviewAnimationsActive = false categoryRefreshTask?.cancel() categoryRefreshTask = nil - loadingCategory = nil } .task(id: isAccessibilityPollingActive) { guard isAccessibilityPollingActive else { return } @@ -2555,7 +2492,7 @@ private struct SettingsPanelContentView: View { isAccessibilityPollingActive = isVisible arePreviewAnimationsActive = isVisible if isVisible { - scheduleCategoryRefresh(for: currentCategory, showLoading: false) + scheduleCategoryRefresh(for: currentCategory) showAnalyticsConsentPromptIfNeeded() } } @@ -2569,7 +2506,7 @@ private struct SettingsPanelContentView: View { selectSidebarCategory(category) } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - scheduleCategoryRefresh(for: currentCategory, showLoading: false) + scheduleCategoryRefresh(for: currentCategory, force: true) } .onChange(of: settings.appLanguage) { _, _ in viewModel.refreshLocalizedState() @@ -2850,31 +2787,27 @@ private struct SettingsPanelContentView: View { private var detail: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 22) { - if loadingCategory == currentCategory { - SettingsCategoryLoadingView(category: currentCategory) - } else { - switch currentCategory { - case .general: - generalContent - case .shortcuts: - shortcutsContent - case .display: - displayContent - case .mascot: - mascotContent - case .sound: - soundContent - case .analytics: - analyticsContent - case .integration: - integrationContent - case .remote: - remoteContent - case .labs: - labsContent - case .about: - aboutContent - } + switch currentCategory { + case .general: + generalContent + case .shortcuts: + shortcutsContent + case .display: + displayContent + case .mascot: + mascotContent + case .sound: + soundContent + case .analytics: + analyticsContent + case .integration: + integrationContent + case .remote: + remoteContent + case .labs: + labsContent + case .about: + aboutContent } } .padding(.horizontal, 22) @@ -2882,7 +2815,6 @@ private struct SettingsPanelContentView: View { .padding(.bottom, 24) .frame(maxWidth: .infinity, alignment: .leading) } - .id(currentCategory) .accessibilityIdentifier("settings.detail.\(currentCategory.rawValue)") .background( detailShape @@ -2939,11 +2871,7 @@ private struct SettingsPanelContentView: View { selectedCategory = .labs } - let categoryToRefresh = currentCategory - scheduleCategoryRefresh( - for: categoryToRefresh, - showLoading: shouldShowLoading(for: categoryToRefresh) - ) + scheduleCategoryRefresh(for: currentCategory) } private func showAnalyticsConsentPromptIfNeeded() { @@ -2955,39 +2883,20 @@ private struct SettingsPanelContentView: View { showingAnalyticsConsentPrompt = true } - private func shouldShowLoading(for category: SettingsCategory) -> Bool { - switch category { - case .display, .sound, .integration: - return true - case .general, .shortcuts, .mascot, .analytics, .remote, .labs, .about: - return false - } - } - - private func scheduleCategoryRefresh(for category: SettingsCategory, showLoading: Bool) { + private func scheduleCategoryRefresh(for category: SettingsCategory, force: Bool = false) { categoryRefreshTask?.cancel() categoryRefreshTask = nil - if showLoading { - loadingCategory = category - } else if loadingCategory == category { - loadingCategory = nil - } - - categoryRefreshTask = Task { @MainActor in - if showLoading { - try? await Task.sleep(nanoseconds: 80_000_000) + if category == .analytics { + if force { + analyticsViewModel.refresh() } else { - await Task.yield() + analyticsViewModel.refreshIfNeeded() } + } - guard !Task.isCancelled else { return } - viewModel.refresh(for: category) - - guard !Task.isCancelled else { return } - if loadingCategory == category { - loadingCategory = nil - } + categoryRefreshTask = Task { + await viewModel.refresh(for: category, force: force) } } @@ -3313,7 +3222,7 @@ private struct SettingsPanelContentView: View { } private var analyticsContent: some View { - AgentUsageAnalyticsContent() + AgentUsageAnalyticsContent(viewModel: analyticsViewModel) } private var integrationContent: some View { diff --git a/PingIslandTests/SettingsPanelViewModelTests.swift b/PingIslandTests/SettingsPanelViewModelTests.swift index d4c251c9..700562ef 100644 --- a/PingIslandTests/SettingsPanelViewModelTests.swift +++ b/PingIslandTests/SettingsPanelViewModelTests.swift @@ -12,6 +12,15 @@ private final class AccessibilityStatusProbe { } } +@MainActor +private final class SettingsCategoryRefreshProbe { + var soundRefreshCount = 0 + + func refreshSoundPacks() async { + soundRefreshCount += 1 + } +} + final class SettingsPanelViewModelTests: XCTestCase { private func makeDefaults(testName: String = #function) -> UserDefaults { let suiteName = "PingIslandTests.SettingsPanelViewModel.\(testName).\(UUID().uuidString)" @@ -94,4 +103,35 @@ final class SettingsPanelViewModelTests: XCTestCase { XCTAssertEqual(openSettingsCount, 0) } } + + func testCategoryRefreshSkipsRepeatedWorkUntilCacheExpires() async { + let defaults = makeDefaults() + + await Task { @MainActor in + let probe = SettingsCategoryRefreshProbe() + var now = Date(timeIntervalSince1970: 1_000) + let viewModel = SettingsPanelViewModel( + qoderCLIHookRefreshStatusProvider: { nil }, + qoderCLIHookRefreshNoticeDefaults: defaults, + accessibilityStatusProvider: { _ in false }, + accessibilitySettingsOpener: {}, + categoryRefreshInterval: 5, + categoryRefreshClock: { now }, + soundPackRefreshProvider: { + await probe.refreshSoundPacks() + } + ) + + await viewModel.refresh(for: .sound) + await viewModel.refresh(for: .sound) + XCTAssertEqual(probe.soundRefreshCount, 1) + + now = now.addingTimeInterval(6) + await viewModel.refresh(for: .sound) + XCTAssertEqual(probe.soundRefreshCount, 2) + + await viewModel.refresh(for: .sound, force: true) + XCTAssertEqual(probe.soundRefreshCount, 3) + }.value + } } diff --git a/PingIslandUITests/PingIslandUITests.swift b/PingIslandUITests/PingIslandUITests.swift index 51e9023d..188e9851 100644 --- a/PingIslandUITests/PingIslandUITests.swift +++ b/PingIslandUITests/PingIslandUITests.swift @@ -27,4 +27,22 @@ final class PingIslandUITests: XCTestCase { XCTAssertTrue(app.staticTexts["应用信息"].waitForExistence(timeout: 5)) } + + @MainActor + func testSettingsCategoriesSwitchWithoutBlockingContent() throws { + let app = XCUIApplication() + app.launchEnvironment["PING_ISLAND_UI_TEST_MODE"] = "1" + app.launch() + + for category in ["display", "analytics", "sound", "general", "display", "sound"] { + let sidebarButton = app.buttons["settings.sidebar.\(category)"] + XCTAssertTrue(sidebarButton.waitForExistence(timeout: 5)) + sidebarButton.tap() + + XCTAssertTrue( + app.scrollViews["settings.detail.\(category)"].waitForExistence(timeout: 1), + "Settings content for \(category) should become available immediately" + ) + } + } } From 980260fdfa548f664c543257ba2c215eb4dc29d5 Mon Sep 17 00:00:00 2001 From: Jack Yan Date: Wed, 26 Aug 2026 01:14:16 +0800 Subject: [PATCH 3/7] Fix sound pack directory type inference --- PingIsland/Core/SoundPackCatalog.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PingIsland/Core/SoundPackCatalog.swift b/PingIsland/Core/SoundPackCatalog.swift index 9897588a..c2b0f1d7 100644 --- a/PingIsland/Core/SoundPackCatalog.swift +++ b/PingIsland/Core/SoundPackCatalog.swift @@ -240,7 +240,7 @@ private enum SoundPackScanner { currentDirectory.appendingPathComponent(".claude/hooks/peon-ping/packs", isDirectory: true) ] - return candidateDirectories.flatMap { directory in + return candidateDirectories.flatMap { directory -> [URL] in guard fileManager.fileExists(atPath: directory.path) else { return [] } return packDirectories(in: directory) } From 7608c9f8e0683663aba848684e15d5ac066c9a85 Mon Sep 17 00:00:00 2001 From: Jack Yan Date: Wed, 26 Aug 2026 02:21:20 +0800 Subject: [PATCH 4/7] Polish Settings sidebar navigation --- PingIsland/UI/Views/SettingsWindowView.swift | 212 ++++++++++++------ .../UI/Window/SettingsWindowController.swift | 9 +- .../SettingsWindowControllerTests.swift | 16 ++ PingIslandUITests/PingIslandUITests.swift | 4 + 4 files changed, 166 insertions(+), 75 deletions(-) diff --git a/PingIsland/UI/Views/SettingsWindowView.swift b/PingIsland/UI/Views/SettingsWindowView.swift index dcbf3ee2..b5261b19 100644 --- a/PingIsland/UI/Views/SettingsWindowView.swift +++ b/PingIsland/UI/Views/SettingsWindowView.swift @@ -51,16 +51,16 @@ enum SettingsCategory: String, CaseIterable, Identifiable { var icon: String { switch self { - case .general: return "gearshape.fill" - case .shortcuts: return "command.square.fill" - case .display: return "rectangle.on.rectangle" - case .mascot: return "face.smiling.fill" - case .sound: return "speaker.wave.2.fill" + case .general: return "gearshape" + case .shortcuts: return "command" + case .display: return "display" + case .mascot: return "pawprint" + case .sound: return "speaker.wave.2" case .analytics: return "chart.bar.xaxis" - case .integration: return "link.circle.fill" - case .remote: return "network.badge.shield.half.filled" - case .labs: return "flask.fill" - case .about: return "info.circle.fill" + case .integration: return "link" + case .remote: return "network" + case .labs: return "flask" + case .about: return "info.circle" } } @@ -2404,6 +2404,7 @@ private enum SettingsPanelMetrics { static let popoverSize = CGSize(width: 760, height: 620) static let windowSidebarWidth: CGFloat = 236 static let popoverSidebarWidth: CGFloat = 212 + static let windowSidebarTopInset: CGFloat = 56 static let windowContentTopInset: CGFloat = 0 static let popoverContentTopInset: CGFloat = 0 static let outerPadding: CGFloat = 0 @@ -2420,6 +2421,7 @@ private struct SettingsPanelContentView: View { @ObservedObject private var updateManager = UpdateManager.shared @ObservedObject private var remoteManager = RemoteConnectorManager.shared @State private var selectedCategory: SettingsCategory? = .general + @State private var displayedCategory: SettingsCategory = .general @State private var pendingHookReinstallProfile: ManagedHookClientProfile? @State private var pendingHookOptionsRequest: HookInstallOptionsRequest? @State private var showingUninstallAllHooksConfirmation = false @@ -2430,6 +2432,7 @@ private struct SettingsPanelContentView: View { @State private var consecutiveGeneralTapCount = 0 @State private var isAccessibilityPollingActive = false @State private var arePreviewAnimationsActive = false + @State private var categoryPresentationTask: Task? @State private var categoryRefreshTask: Task? var body: some View { @@ -2472,6 +2475,8 @@ private struct SettingsPanelContentView: View { .onDisappear { isAccessibilityPollingActive = false arePreviewAnimationsActive = false + categoryPresentationTask?.cancel() + categoryPresentationTask = nil categoryRefreshTask?.cancel() categoryRefreshTask = nil } @@ -2734,20 +2739,55 @@ private struct SettingsPanelContentView: View { showsNoticeDot: category == .integration && viewModel.hasIntegrationNotice ) } - .buttonStyle(.plain) + .buttonStyle(SettingsSidebarButtonStyle()) + .help(Text(appLocalized: category.subtitle)) .accessibilityIdentifier("settings.sidebar.\(category.rawValue)") + .accessibilityLabel(Text(appLocalized: category.title)) + .accessibilityHint(Text(appLocalized: category.subtitle)) + .accessibilityAddTraits( + selectedCategory == category ? .isSelected : [] + ) } } } } Spacer(minLength: 0) } - .padding(.horizontal, 12) - .padding(.top, presentation == .window ? 42 : 14) - .padding(.bottom, 14) + .padding(.horizontal, presentation == .window ? 10 : 12) + .padding( + .top, + presentation == .window ? SettingsPanelMetrics.windowSidebarTopInset : 14 + ) + .padding(.bottom, presentation == .window ? 12 : 14) + } + .padding(presentation == .window ? 0 : 8) + .background { sidebarBackground } + .overlay(alignment: .trailing) { + if presentation == .window { + Rectangle() + .fill(Color.white.opacity(0.075)) + .frame(width: 1) + .accessibilityHidden(true) + } else { + sidebarShape.strokeBorder(Color.white.opacity(0.10), lineWidth: 1) + } } - .padding(8) - .background( + .shadow( + color: Color.black.opacity(presentation == .window ? 0 : 0.20), + radius: 24, + y: 14 + ) + } + + @ViewBuilder + private var sidebarBackground: some View { + if presentation == .window { + SettingsGlassSurface( + material: .sidebar, + blendingMode: .withinWindow, + state: .followsWindowActiveState + ) + } else { sidebarShape .fill(Color.white.opacity(0.055)) .overlay { @@ -2774,20 +2814,14 @@ private struct SettingsPanelContentView: View { .blur(radius: 36) .offset(x: 28, y: -26) } - ) - .overlay(sidebarShape.strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) - .shadow( - color: Color.black.opacity(presentation == .window ? 0 : 0.20), - radius: 24, - y: 14 - ) + } } @ViewBuilder private var detail: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 22) { - switch currentCategory { + switch currentDetailCategory { case .general: generalContent case .shortcuts: @@ -2815,7 +2849,7 @@ private struct SettingsPanelContentView: View { .padding(.bottom, 24) .frame(maxWidth: .infinity, alignment: .leading) } - .accessibilityIdentifier("settings.detail.\(currentCategory.rawValue)") + .accessibilityIdentifier("settings.detail.\(currentDetailCategory.rawValue)") .background( detailShape .fill(Color.white.opacity(0.035)) @@ -2853,6 +2887,13 @@ private struct SettingsPanelContentView: View { return category } + private var currentDetailCategory: SettingsCategory { + guard displayedCategory != .labs || settings.labsSettingsUnlocked else { + return .general + } + return displayedCategory + } + private var currentWindow: NSWindow? { NSApp.keyWindow ?? NSApp.mainWindow } @@ -2871,7 +2912,34 @@ private struct SettingsPanelContentView: View { selectedCategory = .labs } - scheduleCategoryRefresh(for: currentCategory) + presentSelectedCategory(currentCategory) + } + + private func presentSelectedCategory(_ category: SettingsCategory) { + categoryPresentationTask?.cancel() + categoryPresentationTask = nil + + guard currentDetailCategory != category else { + scheduleCategoryRefresh(for: category) + return + } + + categoryPresentationTask = Task { @MainActor in + // Preserve one display frame for the sidebar selection before building + // a potentially large detail hierarchy. + try? await Task.sleep(nanoseconds: 16_000_000) + guard !Task.isCancelled else { return } + + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + displayedCategory = category + } + + await Task.yield() + guard !Task.isCancelled else { return } + scheduleCategoryRefresh(for: category) + } } private func showAnalyticsConsentPromptIfNeeded() { @@ -3894,37 +3962,17 @@ private struct SidebarItemView: View { let category: SettingsCategory let isSelected: Bool var showsNoticeDot: Bool = false + @Environment(\.controlActiveState) private var controlActiveState + @State private var isHovered = false var body: some View { - HStack(spacing: 10) { + HStack(spacing: 9) { ZStack(alignment: .topTrailing) { Image(systemName: category.icon) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.white.opacity(isSelected ? 0.95 : 1)) - .frame(width: 24, height: 24) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill( - isSelected - ? LinearGradient( - colors: [ - category.tint.opacity(0.95), - category.tint.opacity(0.60) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - : LinearGradient( - colors: [ - category.tint.opacity(0.92), - category.tint.opacity(0.74) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .symbolRenderingMode(.monochrome) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(isSelected ? Color.accentColor : Color.white.opacity(0.58)) + .frame(width: 18, height: 20) if showsNoticeDot { Circle() @@ -3934,38 +3982,54 @@ private struct SidebarItemView: View { Circle() .strokeBorder(Color.black.opacity(0.42), lineWidth: 1) ) - .offset(x: 2, y: -2) + .offset(x: 3, y: -1) .accessibilityLabel("有需要注意的集成提示") } } - VStack(alignment: .leading, spacing: 2) { - Text(appLocalized: category.title) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.white.opacity(isSelected ? 0.94 : 0.80)) - .lineLimit(1) - - Text(appLocalized: category.subtitle) - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.white.opacity(isSelected ? 0.60 : 0.42)) - .lineLimit(1) - } + Text(appLocalized: category.title) + .font(.system(size: 13, weight: isSelected ? .semibold : .medium)) + .foregroundColor(.white.opacity(isSelected ? 0.94 : 0.76)) + .lineLimit(1) Spacer(minLength: 0) } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 10) - .padding(.vertical, 9) + .padding(.horizontal, 9) + .padding(.vertical, 6) + .frame(minHeight: 30) .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(isSelected ? Color.white.opacity(0.12) : Color.white.opacity(0.02)) - ) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(Color.white.opacity(isSelected ? 0.10 : 0.04), lineWidth: 1) + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(rowBackgroundColor) ) - .shadow(color: isSelected ? category.tint.opacity(0.18) : .clear, radius: 14, y: 8) - .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + .onHover { isHovered = $0 } + .animation(.easeOut(duration: 0.10), value: isHovered) + .animation(.easeOut(duration: 0.10), value: isSelected) + } + + private var rowBackgroundColor: Color { + if isSelected { + switch controlActiveState { + case .key: + return Color.accentColor.opacity(0.20) + case .active: + return Color.white.opacity(0.10) + case .inactive: + return Color.white.opacity(0.065) + @unknown default: + return Color.white.opacity(0.10) + } + } + return isHovered ? Color.white.opacity(0.055) : .clear + } +} + +private struct SettingsSidebarButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .opacity(configuration.isPressed ? 0.72 : 1) + .animation(.easeOut(duration: 0.06), value: configuration.isPressed) } } diff --git a/PingIsland/UI/Window/SettingsWindowController.swift b/PingIsland/UI/Window/SettingsWindowController.swift index 679bdd85..d8980e73 100644 --- a/PingIsland/UI/Window/SettingsWindowController.swift +++ b/PingIsland/UI/Window/SettingsWindowController.swift @@ -117,7 +117,14 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { window.setContentSize(defaultContentSize) window.identifier = NSUserInterfaceItemIdentifier("settings.window") window.center() - window.toolbar = nil + let toolbar = NSToolbar(identifier: "settings.toolbar") + toolbar.allowsUserCustomization = false + toolbar.autosavesConfiguration = false + toolbar.displayMode = .iconOnly + toolbar.sizeMode = .regular + toolbar.showsBaselineSeparator = false + window.toolbar = toolbar + window.toolbarStyle = .unified window.showsToolbarButton = false window.titlebarSeparatorStyle = .none window.collectionBehavior = [.fullScreenPrimary, .moveToActiveSpace] diff --git a/PingIslandTests/SettingsWindowControllerTests.swift b/PingIslandTests/SettingsWindowControllerTests.swift index cf6b7cb1..f1342a53 100644 --- a/PingIslandTests/SettingsWindowControllerTests.swift +++ b/PingIslandTests/SettingsWindowControllerTests.swift @@ -118,6 +118,15 @@ final class SettingsWindowControllerTests: XCTestCase { XCTAssertTrue(window.styleMask.contains(.fullSizeContentView)) XCTAssertTrue(window.collectionBehavior.contains(.fullScreenPrimary)) XCTAssertFalse(window.collectionBehavior.contains(.fullScreenAuxiliary)) + XCTAssertTrue(window.titlebarAppearsTransparent) + XCTAssertEqual(window.titlebarSeparatorStyle, .none) + XCTAssertEqual(window.toolbarStyle, .unified) + + let toolbar = try XCTUnwrap(window.toolbar) + XCTAssertTrue(toolbar.isVisible) + XCTAssertFalse(toolbar.allowsUserCustomization) + XCTAssertEqual(toolbar.sizeMode, .regular) + XCTAssertFalse(toolbar.showsBaselineSeparator) let closeButton = try XCTUnwrap(window.standardWindowButton(.closeButton)) let minimizeButton = try XCTUnwrap(window.standardWindowButton(.miniaturizeButton)) @@ -138,6 +147,13 @@ final class SettingsWindowControllerTests: XCTestCase { controller.dismiss() } + func testSettingsSidebarUsesOutlineSystemSymbols() { + for category in SettingsCategory.allCases { + XCTAssertNotNil(NSImage(systemSymbolName: category.icon, accessibilityDescription: nil)) + XCTAssertFalse(category.icon.contains("fill"), "\(category) should use an outline symbol") + } + } + func testSettingsWindowUsesAppLocaleRootView() throws { let controller = SettingsWindowController.shared let window = try XCTUnwrap(controller.window) diff --git a/PingIslandUITests/PingIslandUITests.swift b/PingIslandUITests/PingIslandUITests.swift index 188e9851..a053a85c 100644 --- a/PingIslandUITests/PingIslandUITests.swift +++ b/PingIslandUITests/PingIslandUITests.swift @@ -38,6 +38,10 @@ final class PingIslandUITests: XCTestCase { let sidebarButton = app.buttons["settings.sidebar.\(category)"] XCTAssertTrue(sidebarButton.waitForExistence(timeout: 5)) sidebarButton.tap() + XCTAssertTrue( + sidebarButton.isSelected, + "Sidebar selection for \(category) should update before detail loading finishes" + ) XCTAssertTrue( app.scrollViews["settings.detail.\(category)"].waitForExistence(timeout: 1), From 4612ae788cb3a5b6b68c3d7d3211b751aba9e795 Mon Sep 17 00:00:00 2001 From: Jack Yan Date: Wed, 26 Aug 2026 02:26:52 +0800 Subject: [PATCH 5/7] Keep unified toolbar sizing stable --- PingIsland/UI/Window/SettingsWindowController.swift | 7 +++++-- PingIslandTests/SettingsCategoryLabsTests.swift | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/PingIsland/UI/Window/SettingsWindowController.swift b/PingIsland/UI/Window/SettingsWindowController.swift index d8980e73..7df0bdac 100644 --- a/PingIsland/UI/Window/SettingsWindowController.swift +++ b/PingIsland/UI/Window/SettingsWindowController.swift @@ -114,19 +114,22 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { window.hasShadow = true window.minSize = minimumContentSize window.maxSize = maximumContentSize - window.setContentSize(defaultContentSize) window.identifier = NSUserInterfaceItemIdentifier("settings.window") - window.center() let toolbar = NSToolbar(identifier: "settings.toolbar") toolbar.allowsUserCustomization = false toolbar.autosavesConfiguration = false toolbar.displayMode = .iconOnly toolbar.sizeMode = .regular toolbar.showsBaselineSeparator = false + toolbar.isVisible = true window.toolbar = toolbar window.toolbarStyle = .unified window.showsToolbarButton = false window.titlebarSeparatorStyle = .none + // Attaching a toolbar changes the content layout rect, so size the + // window only after its complete titlebar hierarchy is installed. + window.setContentSize(defaultContentSize) + window.center() window.collectionBehavior = [.fullScreenPrimary, .moveToActiveSpace] window.tabbingMode = .disallowed window.isReleasedWhenClosed = false diff --git a/PingIslandTests/SettingsCategoryLabsTests.swift b/PingIslandTests/SettingsCategoryLabsTests.swift index 1755687e..b3472147 100644 --- a/PingIslandTests/SettingsCategoryLabsTests.swift +++ b/PingIslandTests/SettingsCategoryLabsTests.swift @@ -33,6 +33,6 @@ final class SettingsCategoryLabsTests: XCTestCase { func testLabsCategoryLabelsExperimentalContent() { XCTAssertEqual(SettingsCategory.labs.title, "实验室") XCTAssertEqual(SettingsCategory.labs.subtitle, "试验性特性") - XCTAssertEqual(SettingsCategory.labs.icon, "flask.fill") + XCTAssertEqual(SettingsCategory.labs.icon, "flask") } } From 42e23b82ceff92d2f1f6d44796fb5613617897db Mon Sep 17 00:00:00 2001 From: Jack Yan Date: Wed, 26 Aug 2026 02:42:29 +0800 Subject: [PATCH 6/7] Use the system Settings window appearance --- PingIsland/UI/Window/SettingsWindowController.swift | 8 ++------ PingIslandTests/SettingsWindowControllerTests.swift | 1 + 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/PingIsland/UI/Window/SettingsWindowController.swift b/PingIsland/UI/Window/SettingsWindowController.swift index 7df0bdac..23e98c52 100644 --- a/PingIsland/UI/Window/SettingsWindowController.swift +++ b/PingIsland/UI/Window/SettingsWindowController.swift @@ -105,12 +105,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { window.titlebarAppearsTransparent = true window.isMovableByWindowBackground = true window.isOpaque = true - window.backgroundColor = NSColor( - calibratedRed: 0.055, - green: 0.065, - blue: 0.085, - alpha: 1 - ) + window.appearance = NSAppearance(named: .darkAqua) + window.backgroundColor = .windowBackgroundColor window.hasShadow = true window.minSize = minimumContentSize window.maxSize = maximumContentSize diff --git a/PingIslandTests/SettingsWindowControllerTests.swift b/PingIslandTests/SettingsWindowControllerTests.swift index f1342a53..3034b0ae 100644 --- a/PingIslandTests/SettingsWindowControllerTests.swift +++ b/PingIslandTests/SettingsWindowControllerTests.swift @@ -110,6 +110,7 @@ final class SettingsWindowControllerTests: XCTestCase { XCTAssertTrue(window.isMovableByWindowBackground) XCTAssertTrue(window.isOpaque) XCTAssertTrue(window.hasShadow) + XCTAssertEqual(window.appearance?.name, .darkAqua) XCTAssertEqual(window.backgroundColor.alphaComponent, 1, accuracy: 0.001) XCTAssertTrue(window.styleMask.contains(.titled)) XCTAssertTrue(window.styleMask.contains(.closable)) From 32e45d4385d00d45f17e0db15e36f21b237ae0f3 Mon Sep 17 00:00:00 2001 From: Jack Yan Date: Wed, 26 Aug 2026 03:09:09 +0800 Subject: [PATCH 7/7] Keep Settings content below the titlebar --- PingIsland/UI/Views/SettingsWindowView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/PingIsland/UI/Views/SettingsWindowView.swift b/PingIsland/UI/Views/SettingsWindowView.swift index b5261b19..ac2ee95a 100644 --- a/PingIsland/UI/Views/SettingsWindowView.swift +++ b/PingIsland/UI/Views/SettingsWindowView.swift @@ -2405,6 +2405,7 @@ private enum SettingsPanelMetrics { static let windowSidebarWidth: CGFloat = 236 static let popoverSidebarWidth: CGFloat = 212 static let windowSidebarTopInset: CGFloat = 56 + static let windowDetailTopInset: CGFloat = 56 static let windowContentTopInset: CGFloat = 0 static let popoverContentTopInset: CGFloat = 0 static let outerPadding: CGFloat = 0 @@ -2845,7 +2846,10 @@ private struct SettingsPanelContentView: View { } } .padding(.horizontal, 22) - .padding(.top, presentation == .window ? 42 : 24) + .padding( + .top, + presentation == .window ? SettingsPanelMetrics.windowDetailTopInset : 24 + ) .padding(.bottom, 24) .frame(maxWidth: .infinity, alignment: .leading) }