From 1d9b1055a300572b002803eaeb85ccc3e87e038f Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 11 Oct 2026 11:01:58 +0800 Subject: [PATCH 1/5] Keep the settings' value rules in LookInsideHostCore The Preferences form clamped a typed timeout to 0.1-120 s (falling back to the default for anything that is not a positive, finite number) and kept the image contrast level between 0 and 2, in private helpers of its view model that no test reached. PreferenceValuePolicy holds both rules now, so the Settings window can use them and swift test covers their edges. --- .../PreferenceValuePolicy.swift | 31 +++++++++++++++++++ .../PreferenceValuePolicyTests.swift | 28 +++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 Packages/LookInsideHostCore/Sources/LookInsideHostCore/PreferenceValuePolicy.swift create mode 100644 Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PreferenceValuePolicyTests.swift diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/PreferenceValuePolicy.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/PreferenceValuePolicy.swift new file mode 100644 index 00000000..11e6ea51 --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/PreferenceValuePolicy.swift @@ -0,0 +1,31 @@ +// +// PreferenceValuePolicy.swift +// LookInsideHostCore +// +// The ranges the Settings window keeps user-entered preference values in, +// before the app target writes them to its PreferenceManager. +// + +/// What the Settings window stores for a value the user entered. +public enum PreferenceValuePolicy { + /// Request timeouts, in seconds. + public static let timeoutRange: ClosedRange = 0.1 ... 120 + + /// Image contrast levels: normal, medium and high. + public static let imageContrastLevelRange: ClosedRange = 0 ... 2 + + /// A typed timeout moved into `timeoutRange`. A value that is not + /// finite, or not above 0, means nothing usable was entered, so it + /// falls back to `defaultValue` instead. + public static func sanitizedTimeout(_ value: Double, defaultValue: Double) -> Double { + guard value.isFinite, value > 0 else { + return defaultValue + } + return min(max(value, timeoutRange.lowerBound), timeoutRange.upperBound) + } + + /// A contrast level moved into `imageContrastLevelRange`. + public static func clampedImageContrastLevel(_ level: Int) -> Int { + min(max(level, imageContrastLevelRange.lowerBound), imageContrastLevelRange.upperBound) + } +} diff --git a/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PreferenceValuePolicyTests.swift b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PreferenceValuePolicyTests.swift new file mode 100644 index 00000000..1920e8da --- /dev/null +++ b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PreferenceValuePolicyTests.swift @@ -0,0 +1,28 @@ +@testable import LookInsideHostCore +import Testing + +struct PreferenceValuePolicyTests { + @Test(arguments: [ + (typed: 15.0, stored: 15.0), + (typed: 0.05, stored: 0.1), + (typed: 500, stored: 120), + (typed: 0, stored: 7), + (typed: -3, stored: 7), + (typed: .nan, stored: 7), + (typed: .infinity, stored: 7), + ]) + func aTypedTimeoutIsKeptBetweenATenthOfASecondAndTwoMinutes(typed: Double, stored: Double) { + #expect(PreferenceValuePolicy.sanitizedTimeout(typed, defaultValue: 7) == stored) + } + + @Test(arguments: [ + (chosen: -1, stored: 0), + (chosen: 0, stored: 0), + (chosen: 1, stored: 1), + (chosen: 2, stored: 2), + (chosen: 5, stored: 2), + ]) + func theImageContrastLevelStaysBetweenNormalAndHigh(chosen: Int, stored: Int) { + #expect(PreferenceValuePolicy.clampedImageContrastLevel(chosen) == stored) + } +} From 4e8fac75d81bdb64380b609f917cf8a392aed6b8 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 11 Oct 2026 11:05:18 +0800 Subject: [PATCH 2/5] Show the settings in UIFoundation's Settings window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Preferences window and the separate Private Discriminator Settings window become two pages, General and Private Discriminator, of one Settings window: UIFoundation's SettingsWindowController, from the package's Settings trait (product UIFoundationSettingsUI). Only the presentation changes. PreferenceManager still keeps the general settings in the standard user defaults under the same keys, and PrivateDiscriminatorStore still keeps its own in config.json. - App menu: "Preferences…" is now "Settings…" (still Command-comma); the "Private Discriminator Settings…" item is gone. - The General page writes through PreferenceManager and follows it by KVO, so a change made elsewhere, such as the Dashboard's color format switch, shows up while the window is open. Typed timeouts and the contrast level go through PreferenceValuePolicy. - NavigationManager keeps one window controller for the app's lifetime, so the window reopens on the last page; with two pages the back and forward controls are hidden. - The DEBUG UI snapshots capture the new window as the "settings" scene. - The app target's membership list swaps the three removed files for the three new ones in LookInside/Settings. - String catalog: "Settings", "Settings…" and "General" in all 13 languages; the five strings no code uses any more are marked stale. --- LookInside.xcodeproj/project.pbxproj | 15 +- LookInside/DebugE2E/DebugUISnapshots.swift | 11 +- LookInside/Localizable.xcstrings | 255 ++++++++++++ LookInside/Manager/AppMenuManager.swift | 14 +- LookInside/Manager/NavigationManager.swift | 32 +- LookInside/Preference/PreferenceView.swift | 389 ----------------- .../PreferenceWindowController.swift | 62 --- ...iscriminatorSettingsWindowController.swift | 392 ------------------ LookInside/Settings/GeneralSettingsPage.swift | 264 ++++++++++++ .../PrivateDiscriminatorSettingsPage.swift | 300 ++++++++++++++ LookInside/Settings/SettingsPages.swift | 75 ++++ .../MenuPopoverSettingController.swift | 2 +- 12 files changed, 933 insertions(+), 878 deletions(-) delete mode 100644 LookInside/Preference/PreferenceView.swift delete mode 100644 LookInside/Preference/PreferenceWindowController.swift delete mode 100644 LookInside/PrivateDiscriminator/PrivateDiscriminatorSettingsWindowController.swift create mode 100644 LookInside/Settings/GeneralSettingsPage.swift create mode 100644 LookInside/Settings/PrivateDiscriminatorSettingsPage.swift create mode 100644 LookInside/Settings/SettingsPages.swift diff --git a/LookInside.xcodeproj/project.pbxproj b/LookInside.xcodeproj/project.pbxproj index 1a80531d..f6531b67 100644 --- a/LookInside.xcodeproj/project.pbxproj +++ b/LookInside.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ E972100D2FC2E5C0003AAEA6 /* InjectionServiceInterface in Frameworks */ = {isa = PBXBuildFile; productRef = E972100C2FC2E5C0003AAEA6 /* InjectionServiceInterface */; }; E9FE990C3026C657008692AF /* UIFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = E9FE990B3026C657008692AF /* UIFoundation */; }; E9FE990E3026C657008692AF /* UIFoundationToolbox in Frameworks */ = {isa = PBXBuildFile; productRef = E9FE990D3026C657008692AF /* UIFoundationToolbox */; }; + E9FE99103026C657008692AF /* UIFoundationSettingsUI in Frameworks */ = {isa = PBXBuildFile; productRef = E9FE990F3026C657008692AF /* UIFoundationSettingsUI */; }; F0F0A10E2FD0000100A1B2C3 /* FoundationToolbox in Frameworks */ = {isa = PBXBuildFile; productRef = F0F0A10D2FD0000100A1B2C3 /* FoundationToolbox */; }; /* End PBXBuildFile section */ @@ -193,9 +194,6 @@ Measure/MeasureController.swift, Measure/MeasureResultView.swift, Other/VersionComparer.swift, - Preference/PreferenceView.swift, - Preference/PreferenceWindowController.swift, - PrivateDiscriminator/PrivateDiscriminatorSettingsWindowController.swift, PrivateDiscriminator/PrivateDiscriminatorStore.swift, Read/ArchiveCoding.swift, Read/ArchiveDocument.swift, @@ -204,6 +202,9 @@ Read/ReadScreenshotLookup.swift, Read/ReadViewController.swift, Read/ReadWindowController.swift, + Settings/GeneralSettingsPage.swift, + Settings/PrivateDiscriminatorSettingsPage.swift, + Settings/SettingsPages.swift, Static/MessageManager.swift, Static/Preview/DisplayItemNode.swift, Static/Preview/PreviewController.swift, @@ -299,6 +300,7 @@ E972100B2FC2E5C0003AAEA6 /* HelperCommunication in Frameworks */, E972100D2FC2E5C0003AAEA6 /* InjectionServiceInterface in Frameworks */, E9FE990E3026C657008692AF /* UIFoundationToolbox in Frameworks */, + E9FE99103026C657008692AF /* UIFoundationSettingsUI in Frameworks */, F0F0A10E2FD0000100A1B2C3 /* FoundationToolbox in Frameworks */, 15FFB4D96CB7375162952FFE /* LookInsideActivation in Frameworks */, 79CC3F736CA547232F1827F4 /* LookInsideActivationUI in Frameworks */, @@ -366,6 +368,7 @@ E972100C2FC2E5C0003AAEA6 /* InjectionServiceInterface */, E9FE990B3026C657008692AF /* UIFoundation */, E9FE990D3026C657008692AF /* UIFoundationToolbox */, + E9FE990F3026C657008692AF /* UIFoundationSettingsUI */, F0F0A10D2FD0000100A1B2C3 /* FoundationToolbox */, 4CF60A9D44E04A6BE91035EF /* LookInsideActivation */, F05DCA7C93CF923C02603F72 /* LookInsideActivationUI */, @@ -884,6 +887,7 @@ }; traits = ( AppleInternal, + Settings, ); }; F0F0A10A2FD0000100A1B2C3 /* XCRemoteSwiftPackageReference "FrameworkToolbox" */ = { @@ -952,6 +956,11 @@ package = E9FE990A3026C657008692AF /* XCRemoteSwiftPackageReference "UIFoundation" */; productName = UIFoundationToolbox; }; + E9FE990F3026C657008692AF /* UIFoundationSettingsUI */ = { + isa = XCSwiftPackageProductDependency; + package = E9FE990A3026C657008692AF /* XCRemoteSwiftPackageReference "UIFoundation" */; + productName = UIFoundationSettingsUI; + }; F05DCA7C93CF923C02603F72 /* LookInsideActivationUI */ = { isa = XCSwiftPackageProductDependency; package = E81BCA16A46AEA38803B27B7 /* XCLocalSwiftPackageReference "Packages/LookInsideActivation" */; diff --git a/LookInside/DebugE2E/DebugUISnapshots.swift b/LookInside/DebugE2E/DebugUISnapshots.swift index 244933b9..bf206868 100644 --- a/LookInside/DebugE2E/DebugUISnapshots.swift +++ b/LookInside/DebugE2E/DebugUISnapshots.swift @@ -217,10 +217,10 @@ try await readRoundTrip(exported) // Windows of their own. - NavigationManager.shared.showPreference() - try await captureStandaloneWindow("preferences", controllerKey: "preferenceWindowController") + NavigationManager.shared.showSettings() + try await captureStandaloneWindow("settings") { NavigationManager.shared.settingsWindowController } NavigationManager.shared.showAbout() - try await captureStandaloneWindow("about", controllerKey: "aboutWindowController") + try await captureStandaloneWindow("about") { NavigationManager.shared.aboutWindowController } try await captureActivationWindow() } @@ -276,10 +276,9 @@ window.close() } - private func captureStandaloneWindow(_ name: String, controllerKey: String) async throws { - let navigation = NavigationManager.shared + private func captureStandaloneWindow(_ name: String, controller: () -> NSWindowController?) async throws { let window = try await waitFor("the \(name) window") { - (navigation.value(forKey: controllerKey) as? NSWindowController)?.window.flatMap { $0.isVisible ? $0 : nil } + controller()?.window.flatMap { $0.isVisible ? $0 : nil } } quietWindows() window.center() diff --git a/LookInside/Localizable.xcstrings b/LookInside/Localizable.xcstrings index d0640747..6fb4a5ec 100644 --- a/LookInside/Localizable.xcstrings +++ b/LookInside/Localizable.xcstrings @@ -9241,6 +9241,7 @@ } }, "Done" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -13927,6 +13928,89 @@ } } }, + "General" : { + "comment" : "Title of the Settings window's General page.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "عام" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allgemein" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "General" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "General" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Général" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generali" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "一般" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일반" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geral" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Основные" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cài đặt chung" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "一般" + } + } + } + }, "Geometry" : { "comment" : "Native region detail field title", "localizations" : { @@ -27278,6 +27362,7 @@ } }, "Preferences" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -27361,6 +27446,7 @@ }, "Preferences…" : { "comment" : "App menu item", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -27690,6 +27776,7 @@ } }, "Private Discriminator" : { + "comment" : "Title of the Settings window's Private Discriminator page.", "localizations" : { "ar" : { "stringUnit" : { @@ -27772,6 +27859,7 @@ } }, "Private Discriminator Settings" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -27855,6 +27943,7 @@ }, "Private Discriminator Settings…" : { "comment" : "App menu item", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -32059,6 +32148,172 @@ } } }, + "Settings" : { + "comment" : "Title of the Settings window.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الإعدادات" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réglages" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impostazioni" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cài đặt" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定" + } + } + } + }, + "Settings…" : { + "comment" : "App menu item", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الإعدادات…" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen …" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réglages…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impostazioni…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정…" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки…" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cài đặt…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定…" + } + } + } + }, "Shape parameters or path are unavailable. The overlay shows dashed layout bounds without a hit-area fill." : { "comment" : "Node detail note", "localizations" : { diff --git a/LookInside/Manager/AppMenuManager.swift b/LookInside/Manager/AppMenuManager.swift index 9b49439a..c0a96a0b 100644 --- a/LookInside/Manager/AppMenuManager.swift +++ b/LookInside/Manager/AppMenuManager.swift @@ -43,7 +43,7 @@ final class AppMenuManager: NSObject, NSMenuDelegate { private enum Tag { static let about = 11 - static let preferences = 12 + static let settings = 12 static let checkUpdates = 13 static let activateSwiftUISupport = 14 static let swiftUISupportLicense = 15 @@ -51,7 +51,6 @@ final class AppMenuManager: NSObject, NSMenuDelegate { static let purchaseSwiftUISupport = 17 static let swiftUISupportCustomerSupport = 18 static let swiftUISupportSubmenu = 19 - static let privateDiscriminatorSettings = 20 static let reload = 21 static let dimension = 22 @@ -152,8 +151,7 @@ final class AppMenuManager: NSObject, NSMenuDelegate { menu.delegate = self menu.addItem(item(String(format: NSLocalizedString("About %@", comment: ""), appName), action: #selector(handleAbout), tag: Tag.about)) menu.addItem(.separator()) - menu.addItem(item(NSLocalizedString("Preferences…", comment: ""), action: #selector(handlePreferences), key: ",", modifiers: .command, tag: Tag.preferences)) - menu.addItem(item(NSLocalizedString("Private Discriminator Settings…", comment: ""), action: #selector(handlePrivateDiscriminatorSettings), tag: Tag.privateDiscriminatorSettings)) + menu.addItem(item(NSLocalizedString("Settings…", comment: ""), action: #selector(handleSettings), key: ",", modifiers: .command, tag: Tag.settings)) menu.addItem(.separator()) menu.addItem(item(NSLocalizedString("Check for Updates…", comment: ""), action: #selector(handleCheckUpdates), tag: Tag.checkUpdates)) menu.addItem(.separator()) @@ -432,12 +430,8 @@ final class AppMenuManager: NSObject, NSMenuDelegate { NavigationManager.shared.showAbout() } - @objc private func handlePreferences() { - NavigationManager.shared.showPreference() - } - - @objc private func handlePrivateDiscriminatorSettings() { - PrivateDiscriminatorSettingsWindowController.showSettingsWindow() + @objc private func handleSettings() { + NavigationManager.shared.showSettings() } @objc private func handleCheckUpdates() { diff --git a/LookInside/Manager/NavigationManager.swift b/LookInside/Manager/NavigationManager.swift index 01c1da71..5b2e310a 100644 --- a/LookInside/Manager/NavigationManager.swift +++ b/LookInside/Manager/NavigationManager.swift @@ -5,11 +5,12 @@ // Created by Li Kai on 2018/11/3. // https://lookin.work // -// Opens the app's standalone windows: launch, preferences, about, the JSON +// Opens the app's standalone windows: launch, settings, about, the JSON // viewer, and readers for `.lookin` files or in-memory hierarchies. // import AppKit +import UIFoundationSettingsUI @MainActor final class NavigationManager: NSObject, NSWindowDelegate { @@ -21,9 +22,18 @@ final class NavigationManager: NSObject, NSWindowDelegate { @objc private(set) var launchWindowController: LaunchWindowController? - // Read by name (KVC) by the DEBUG UI snapshots. - @objc private(set) var preferenceWindowController: PreferenceWindowController? - @objc private(set) var aboutWindowController: AboutWindowController? + /// Kept for the app's lifetime rather than dropped on close, so the + /// window opens again on the page the user left it on. + private(set) lazy var settingsWindowController = SettingsWindowController( + configuration: SettingsConfiguration( + title: String(localized: "Settings", comment: "Title of the Settings window."), + // Two pages leave nothing to go back and forth between. + showsNavigationControls: false + ), + pages: { SettingsPages.make() } + ) + + private(set) var aboutWindowController: AboutWindowController? private var jsonWindowController: JSONAttributeWindowController? /// Set by the hierarchy view from its top inset. @@ -45,14 +55,8 @@ final class NavigationManager: NSObject, NSWindowDelegate { launchWindowController = nil } - @objc func showPreference() { - let controller = preferenceWindowController ?? { - let controller = PreferenceWindowController() - controller.window?.delegate = self - return controller - }() - preferenceWindowController = controller - controller.showWindow(self) + @objc func showSettings() { + settingsWindowController.showWindow(self) } @objc func showAbout() { @@ -116,9 +120,7 @@ final class NavigationManager: NSObject, NSWindowDelegate { func windowWillClose(_ notification: Notification) { let closingWindow = notification.object as? NSWindow - if closingWindow == preferenceWindowController?.window { - preferenceWindowController = nil - } else if closingWindow == aboutWindowController?.window { + if closingWindow == aboutWindowController?.window { aboutWindowController = nil } // Live and archive document windows belong to NSDocumentController, diff --git a/LookInside/Preference/PreferenceView.swift b/LookInside/Preference/PreferenceView.swift deleted file mode 100644 index 5daf5d61..00000000 --- a/LookInside/Preference/PreferenceView.swift +++ /dev/null @@ -1,389 +0,0 @@ -import AppKit -import SwiftUI - -public final class PreferenceHostingController: NSViewController { - override public func loadView() { - view = NSHostingView(rootView: PreferenceRootView()) - } -} - -private struct PreferenceRootView: View { - @StateObject private var model = PreferenceViewModel() - - var body: some View { - VStack(spacing: 0) { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - Text("Preferences") - .font(.system(size: 24, weight: .semibold)) - .padding(.bottom, 18) - - PreferenceRow( - title: "Appearance", - message: nil, - controlWidth: 300 - ) { - Picker("Appearance", selection: model.appearanceTypeBinding) { - Text("Dark Mode").tag(0) - Text("Light Mode").tag(1) - Text("System Default").tag(2) - } - .pickerStyle(.segmented) - } - - PreferenceDivider() - - PreferenceRow( - title: "Color Format", - message: model.colorFormatMessage, - controlWidth: 220 - ) { - Picker("Color Format", selection: model.colorFormatBinding) { - Text(verbatim: "RGBA").tag(0) - Text(verbatim: "HEX").tag(1) - } - .pickerStyle(.segmented) - } - - PreferenceDivider() - - PreferenceRow( - title: "Image contrast", - message: "Adjust this option to use a deeper layer selection color.", - controlWidth: 300 - ) { - Picker("Image contrast", selection: model.imageContrastBinding) { - Text("Normal").tag(0) - Text("Medium").tag(1) - Text("High").tag(2) - } - .pickerStyle(.segmented) - } - - PreferenceDivider() - - PreferenceRow( - title: "Double click", - message: nil, - controlWidth: 300 - ) { - Picker("Double click", selection: model.doubleClickBinding) { - Text("Expand or collapse layer").tag(0) - Text("Focus on layer").tag(1) - } - .pickerStyle(.segmented) - } - - PreferenceDivider() - - PreferenceRow( - title: "Remember expansion state per app", - message: "When on, the hierarchy expand/collapse state is restored per target-app bundle id across host and target-app restarts. The 20 most recently inspected apps are kept; toggling off preserves existing data but stops reading or writing it.", - controlWidth: 80 - ) { - Toggle("Remember expansion state per app", isOn: model.rememberExpansionStateBinding) - .toggleStyle(.switch) - } - - PreferenceDivider() - - PreferenceRow( - title: "Hierarchy Timeout", - message: "Timeout for hierarchy and hierarchy-details requests, in seconds. Default: 15s.", - controlWidth: 168 - ) { - TimeoutEditor(value: model.hierarchyTimeoutBinding) - } - - #if DEBUG - PreferenceDivider() - - PreferenceRow( - title: "License Timeout", - message: "Timeout for license challenge and verification requests, in seconds. Default: 5s.", - controlWidth: 168 - ) { - TimeoutEditor(value: model.licenseTimeoutBinding) - } - #endif - } - .padding(.horizontal, 32) - .padding(.top, 28) - .padding(.bottom, 24) - } - - Divider() - - HStack { - Spacer() - Button("Reset", role: .destructive) { - model.reset() - } - .keyboardShortcut(.delete, modifiers: [.command, .shift]) - } - .padding(.horizontal, 24) - .padding(.vertical, 14) - .background(.bar) - } - .frame(minWidth: 640, idealWidth: 660, minHeight: minimumHeight) - .background(Color(nsColor: .windowBackgroundColor)) - .onAppear { - model.reload() - } - } - - private var minimumHeight: CGFloat { - #if DEBUG - 520 - #else - 455 - #endif - } -} - -private struct PreferenceRow: View { - let title: LocalizedStringKey - let message: LocalizedStringKey? - let controlWidth: CGFloat - @ViewBuilder let control: Control - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 24) { - VStack(alignment: .leading, spacing: 5) { - Text(title) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.primary) - - if let message { - Text(message) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - control - .labelsHidden() - .frame(width: controlWidth, alignment: .trailing) - } - .padding(.vertical, 13) - } -} - -private struct PreferenceDivider: View { - var body: some View { - Divider() - .padding(.leading, 0) - } -} - -private struct TimeoutEditor: View { - @Binding var value: Double - - var body: some View { - HStack(spacing: 8) { - TextField(Self.emptyTitle, value: $value, formatter: Self.formatter) - .multilineTextAlignment(.trailing) - .textFieldStyle(.roundedBorder) - .frame(width: 72) - - Text(verbatim: "s") - .foregroundStyle(.secondary) - .frame(width: 12, alignment: .leading) - - Stepper("", value: $value, in: 0.1 ... 120, step: 1) - .labelsHidden() - } - } - - private static let emptyTitle = "" - - private static let formatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.minimum = 0.1 - formatter.maximum = 120 - formatter.minimumFractionDigits = 0 - formatter.maximumFractionDigits = 2 - formatter.allowsFloats = true - return formatter - }() -} - -/// Stays an ObservableObject: as an @Observable model the segmented pickers -/// laid out narrower than before (the ui-snapshots preferences scene). -@MainActor -private final class PreferenceViewModel: ObservableObject { - @Published private var appearanceType: Int - @Published private var colorFormat: Int - @Published private var imageContrast: Int - @Published private var doubleClickBehavior: Int - @Published private var rememberExpansionState: Bool - @Published private var hierarchyTimeout: Double - - #if DEBUG - @Published private var licenseTimeout: Double - #endif - - private let manager: PreferenceManager - - init(manager: PreferenceManager = PreferenceManager.shared) { - self.manager = manager - appearanceType = manager.appearanceType.rawValue - colorFormat = manager.rgbaFormat ? 0 : 1 - imageContrast = manager.imageContrastLevel - doubleClickBehavior = manager.doubleClickBehavior.rawValue - rememberExpansionState = manager.rememberExpansionState - hierarchyTimeout = manager.hierarchyRequestTimeoutInterval - #if DEBUG - licenseTimeout = manager.licenseHandshakeTimeoutInterval - #endif - } - - var appearanceTypeBinding: Binding { - Binding( - get: { self.appearanceType }, - set: { self.setAppearanceType($0) } - ) - } - - var colorFormatBinding: Binding { - Binding( - get: { self.colorFormat }, - set: { self.setColorFormat($0) } - ) - } - - var imageContrastBinding: Binding { - Binding( - get: { self.imageContrast }, - set: { self.setImageContrast($0) } - ) - } - - var doubleClickBinding: Binding { - Binding( - get: { self.doubleClickBehavior }, - set: { self.setDoubleClickBehavior($0) } - ) - } - - var rememberExpansionStateBinding: Binding { - Binding( - get: { self.rememberExpansionState }, - set: { self.setRememberExpansionState($0) } - ) - } - - var hierarchyTimeoutBinding: Binding { - Binding( - get: { self.hierarchyTimeout }, - set: { self.setHierarchyTimeout($0) } - ) - } - - #if DEBUG - var licenseTimeoutBinding: Binding { - Binding( - get: { self.licenseTimeout }, - set: { self.setLicenseTimeout($0) } - ) - } - #endif - - var colorFormatMessage: LocalizedStringKey { - if colorFormat == 0 { - return "Color will be displayed in format like (255, 12, 34, 0.5). Alpha value is between 0 and 1." - } - return "Color will be displayed in format like #7e7e7eff. The components are #RRGGBBAA." - } - - func reload() { - appearanceType = manager.appearanceType.rawValue - colorFormat = manager.rgbaFormat ? 0 : 1 - imageContrast = manager.imageContrastLevel - doubleClickBehavior = manager.doubleClickBehavior.rawValue - rememberExpansionState = manager.rememberExpansionState - hierarchyTimeout = manager.hierarchyRequestTimeoutInterval - #if DEBUG - licenseTimeout = manager.licenseHandshakeTimeoutInterval - #endif - } - - func reset() { - setAppearanceType(PreferredAppearanceType.system.rawValue) - setColorFormat(0) - setImageContrast(0) - setDoubleClickBehavior(DoubleClickBehavior.collapse.rawValue) - setRememberExpansionState(true) - setHierarchyTimeout(defaultHierarchyRequestTimeoutInterval) - setLicenseTimeoutIfAvailable(defaultLicenseHandshakeTimeoutInterval) - - #if DEBUG - MessageManager.sharedInstance().reset() - manager.reset() - UserDefaults.standard.removeObject(forKey: "IgnoreFastModeTips") - #endif - } - - private func setAppearanceType(_ rawValue: Int) { - guard let type = PreferredAppearanceType(rawValue: rawValue) else { - return - } - appearanceType = rawValue - manager.appearanceType = type - } - - private func setColorFormat(_ rawValue: Int) { - colorFormat = rawValue - manager.rgbaFormat = rawValue == 0 - } - - private func setImageContrast(_ rawValue: Int) { - let clampedValue = min(max(rawValue, 0), 2) - imageContrast = clampedValue - manager.imageContrastLevel = clampedValue - } - - private func setDoubleClickBehavior(_ rawValue: Int) { - guard let behavior = DoubleClickBehavior(rawValue: rawValue) else { - return - } - doubleClickBehavior = rawValue - manager.doubleClickBehavior = behavior - } - - private func setRememberExpansionState(_ newValue: Bool) { - rememberExpansionState = newValue - manager.rememberExpansionState = newValue - } - - private func setHierarchyTimeout(_ value: Double) { - let sanitizedValue = sanitizedTimeout(value, defaultValue: defaultHierarchyRequestTimeoutInterval) - hierarchyTimeout = sanitizedValue - manager.hierarchyRequestTimeoutInterval = sanitizedValue - } - - private func setLicenseTimeoutIfAvailable(_ value: Double) { - #if DEBUG - setLicenseTimeout(value) - #else - _ = value - #endif - } - - #if DEBUG - private func setLicenseTimeout(_ value: Double) { - let sanitizedValue = sanitizedTimeout(value, defaultValue: defaultLicenseHandshakeTimeoutInterval) - licenseTimeout = sanitizedValue - manager.licenseHandshakeTimeoutInterval = sanitizedValue - } - #endif - - private func sanitizedTimeout(_ value: Double, defaultValue: Double) -> Double { - guard value.isFinite, value > 0 else { - return defaultValue - } - return min(max(value, 0.1), 120) - } -} diff --git a/LookInside/Preference/PreferenceWindowController.swift b/LookInside/Preference/PreferenceWindowController.swift deleted file mode 100644 index cd33ca53..00000000 --- a/LookInside/Preference/PreferenceWindowController.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// PreferenceWindowController.swift -// LookInside -// -// The Preferences window. Its content is the SwiftUI preference form -// (PreferenceView.swift) hosted inside an BaseViewController. -// - -import AppKit - -final class PreferenceWindowController: WindowController { - @objc init() { - #if DEBUG - let windowHeight: CGFloat = 520 - #else - let windowHeight: CGFloat = 455 - #endif - let window = AppWindow(contentRect: NSRect(x: 0, y: 0, width: 660, height: windowHeight), - styleMask: [.titled, .closable, .miniaturizable], - backing: .buffered, defer: true) - window.isMovableByWindowBackground = true - window.title = NSLocalizedString("Preferences", comment: "") - window.titlebarAppearsTransparent = true - window.center() - - super.init(window: window) - let viewController = PreferenceViewController() - window.contentView = viewController.view - contentViewController = viewController - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) is not supported") - } -} - -final class PreferenceViewController: BaseViewController { - init() { - super.init(containerView: nil) - installPreferenceForm() - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) is not supported") - } - - private func installPreferenceForm() { - let hostingController = PreferenceHostingController() - addChild(hostingController) - let hostedView = hostingController.view - hostedView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(hostedView) - NSLayoutConstraint.activate([ - hostedView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - hostedView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - hostedView.topAnchor.constraint(equalTo: view.topAnchor), - hostedView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - } -} diff --git a/LookInside/PrivateDiscriminator/PrivateDiscriminatorSettingsWindowController.swift b/LookInside/PrivateDiscriminator/PrivateDiscriminatorSettingsWindowController.swift deleted file mode 100644 index 7de0cd97..00000000 --- a/LookInside/PrivateDiscriminator/PrivateDiscriminatorSettingsWindowController.swift +++ /dev/null @@ -1,392 +0,0 @@ -import AppKit -import SwiftUI - -final class PrivateDiscriminatorSettingsWindowController: NSWindowController { - private static let sharedController = PrivateDiscriminatorSettingsWindowController() - - @objc(showSettingsWindow) - static func showSettingsWindow() { - sharedController.showWindow(nil) - sharedController.window?.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - } - - private init() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 660, height: 640), - styleMask: [.titled, .closable, .miniaturizable, .resizable], - backing: .buffered, - defer: false - ) - window.title = NSLocalizedString("Private Discriminator Settings", comment: "") - window.titlebarAppearsTransparent = true - window.isReleasedWhenClosed = false - window.minSize = NSSize(width: 640, height: 520) - window.contentViewController = NSHostingController(rootView: PrivateDiscriminatorSettingsRootView()) - window.center() - super.init(window: window) - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -private struct PrivateDiscriminatorSettingsRootView: View { - private var store = PrivateDiscriminatorStore.shared - @State private var importError: String? - - var body: some View { - VStack(spacing: 0) { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - Text("Private Discriminator") - .font(.system(size: 24, weight: .semibold)) - .padding(.bottom, 18) - - SettingsRow( - title: "Enable", - message: "Resolve Swift private-discriminator hashes from imported module CSVs. Disabled by default.", - controlWidth: 84 - ) { - Toggle( - "", - isOn: Binding( - get: { store.featureEnabled }, - set: { store.setFeatureEnabled($0) } - ) - ) - .toggleStyle(.switch) - } - - SettingsDivider() - - SettingsRow( - title: "Default Library", - message: "Download SwiftUI and SwiftUICore indexes from GitHub into local imported CSVs.", - controlWidth: 190 - ) { - Button { - importError = nil - store.updateDefaultLibrary() - } label: { - if store.isUpdatingDefaultLibrary { - Label("Updating…", systemImage: "arrow.down.circle") - } else { - Label("Update Default Library", systemImage: "arrow.down.circle") - } - } - .disabled(store.isUpdatingDefaultLibrary) - } - - if let message = store.lastDefaultLibraryUpdateMessage { - HStack(spacing: 8) { - if store.isUpdatingDefaultLibrary { - ProgressView() - .controlSize(.small) - } - Text(message) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.top, -5) - .padding(.bottom, 13) - } - - if store.featureEnabled { - SettingsDivider() - - SettingsRow( - title: "Autosave", - message: "Allow future guessed rows to use autosaved module CSVs.", - controlWidth: 84 - ) { - Toggle( - "", - isOn: Binding( - get: { store.autosaveEnabled }, - set: { store.setAutosaveEnabled($0) } - ) - ) - .toggleStyle(.switch) - } - - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Button { - beginImport() - } label: { - Label("Import Module", systemImage: "square.and.arrow.down") - } - - Button { - store.revealStorageDirectory() - } label: { - Label("Reveal Storage", systemImage: "folder") - } - - Spacer() - } - - if store.moduleStatuses.isEmpty { - Text("No modules imported yet.") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } else { - VStack(spacing: 0) { - ForEach(store.moduleStatuses) { status in - ModuleStatusRow(status: status, store: store) { message in - importError = message - } - - if status.id != store.moduleStatuses.last?.id { - Divider() - } - } - } - } - - diagnosticsView - errorView - } - .padding(.vertical, 13) - } - } - .padding(.horizontal, 32) - .padding(.top, 28) - .padding(.bottom, 24) - } - - Divider() - - HStack { - Spacer() - Button("Done") { - NSApp.keyWindow?.performClose(nil) - } - .keyboardShortcut(.defaultAction) - } - .padding(.horizontal, 24) - .padding(.vertical, 14) - .background(.bar) - } - .frame(minWidth: 640, idealWidth: 660, minHeight: 520, idealHeight: 640) - .background(Color(nsColor: .windowBackgroundColor)) - .onAppear { - store.reloadFromDisk() - } - } - - @ViewBuilder - private var diagnosticsView: some View { - if !store.invalidDiagnostics.isEmpty { - VStack(alignment: .leading, spacing: 4) { - Text("Invalid CSV Diagnostics") - .font(.system(size: 12, weight: .medium)) - ForEach(store.invalidDiagnostics) { diagnostic in - Text("\(diagnostic.module) \(diagnostic.source): \(diagnostic.message)") - .font(.system(size: 11)) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } - } - } - } - - @ViewBuilder - private var errorView: some View { - if let importError { - Text(importError) - .font(.system(size: 11)) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } else if let error = store.lastSettingsError { - Text(error) - .font(.system(size: 11)) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } - } - - private func beginImport() { - importError = nil - guard let module = promptForModuleName() else { - return - } - - let panel = NSOpenPanel() - panel.canChooseDirectories = true - panel.canChooseFiles = false - panel.allowsMultipleSelection = false - panel.prompt = NSLocalizedString("Import", comment: "") - panel.message = String( - format: NSLocalizedString("Choose the local Swift source folder for %@.", comment: ""), - module - ) - - guard panel.runModal() == .OK, let url = panel.url else { - return - } - - do { - try store.importModule(named: module, from: url) - } catch { - importError = error.localizedDescription - } - } - - private func promptForModuleName() -> String? { - let alert = NSAlert() - alert.messageText = NSLocalizedString("Import Private Discriminator Module", comment: "") - alert.informativeText = NSLocalizedString("Enter the Swift module name for this source folder.", comment: "") - alert.addButton(withTitle: NSLocalizedString("Continue", comment: "")) - alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "")) - - let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24)) - textField.placeholderString = NSLocalizedString("ModuleName", comment: "") - alert.accessoryView = textField - - guard alert.runModal() == .alertFirstButtonReturn else { - return nil - } - - let module = textField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) - if module.isEmpty { - importError = NSLocalizedString("Module name is required.", comment: "") - return nil - } - return module - } -} - -private struct SettingsRow: View { - let title: LocalizedStringKey - let message: LocalizedStringKey? - let controlWidth: CGFloat - @ViewBuilder let control: Control - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 24) { - VStack(alignment: .leading, spacing: 5) { - Text(title) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.primary) - - if let message { - Text(message) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - control - .labelsHidden() - .frame(width: controlWidth, alignment: .trailing) - } - .padding(.vertical, 13) - } -} - -private struct SettingsDivider: View { - var body: some View { - Divider() - } -} - -private struct ModuleStatusRow: View { - let status: PrivateDiscriminatorModuleStatus - let store: PrivateDiscriminatorStore - let onError: (String) -> Void - - var body: some View { - HStack(alignment: .center, spacing: 10) { - Toggle( - "", - isOn: Binding( - get: { status.isEnabled }, - set: { store.setModule(status.module, enabled: $0) } - ) - ) - .labelsHidden() - .toggleStyle(.switch) - - VStack(alignment: .leading, spacing: 3) { - Text(status.module) - .font(.system(size: 13, weight: .medium)) - - Text(status.sourceSummary) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - - if let diagnostic = status.diagnostic { - Text(diagnostic) - .font(.system(size: 11)) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } - } - - Spacer(minLength: 8) - - Button { - reimport() - } label: { - Image(systemName: "arrow.clockwise") - } - .buttonStyle(.borderless) - .help("Reimport from saved source folder") - .disabled(status.sourceFolderPath == nil) - - Button { - store.revealModule(status.module) - } label: { - Image(systemName: "folder") - } - .buttonStyle(.borderless) - .help("Reveal CSV") - - Button(role: .destructive) { - remove() - } label: { - Image(systemName: "trash") - } - .buttonStyle(.borderless) - .help("Remove module CSVs") - } - .padding(.vertical, 8) - } - - private func reimport() { - do { - try store.reimportModule(status.module) - } catch { - onError(error.localizedDescription) - } - } - - private func remove() { - let alert = NSAlert() - alert.messageText = String( - format: NSLocalizedString("Remove %@?", comment: ""), - status.module - ) - alert.informativeText = NSLocalizedString("This removes imported and autosaved CSVs for the module.", comment: "") - alert.addButton(withTitle: NSLocalizedString("Remove", comment: "")) - alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "")) - alert.alertStyle = .warning - - guard alert.runModal() == .alertFirstButtonReturn else { - return - } - - do { - try store.removeModule(status.module) - } catch { - onError(error.localizedDescription) - } - } -} diff --git a/LookInside/Settings/GeneralSettingsPage.swift b/LookInside/Settings/GeneralSettingsPage.swift new file mode 100644 index 00000000..385d27fb --- /dev/null +++ b/LookInside/Settings/GeneralSettingsPage.swift @@ -0,0 +1,264 @@ +// +// GeneralSettingsPage.swift +// LookInside +// +// The General page of the Settings window: what the Preferences window +// used to hold. PreferenceManager.shared still owns every value here and +// stores it in the standard user defaults under its original key. +// + +import Foundation +import LookInsideHostCore +import SwiftUI +import UIFoundationSettingsUI + +struct GeneralSettingsPage: View { + let model: GeneralSettingsModel + + var body: some View { + SettingsForm { + GeneralDisplaySection(model: model) + GeneralHierarchySection(model: model) + GeneralTimeoutSection(model: model) + GeneralResetSection(model: model) + } + } +} + +/// Appearance, the color format and the image contrast. +private struct GeneralDisplaySection: View { + @Bindable var model: GeneralSettingsModel + + var body: some View { + Section { + Picker("Appearance", selection: $model.appearanceType) { + Text("Dark Mode").tag(PreferredAppearanceType.dark) + Text("Light Mode").tag(PreferredAppearanceType.light) + Text("System Default").tag(PreferredAppearanceType.system) + } + + DescribedSettingRow("Color Format", description: colorFormatDescription) { + Picker("Color Format", selection: $model.usesRGBAColorFormat) { + Text(verbatim: "RGBA").tag(true) + Text(verbatim: "HEX").tag(false) + } + .pickerStyle(.segmented) + .fixedSize() + } + + DescribedSettingRow("Image contrast", description: "Adjust this option to use a deeper layer selection color.") { + Picker("Image contrast", selection: $model.imageContrastLevel) { + Text("Normal").tag(0) + Text("Medium").tag(1) + Text("High").tag(2) + } + .pickerStyle(.segmented) + .fixedSize() + } + } + } + + private var colorFormatDescription: LocalizedStringResource { + model.usesRGBAColorFormat + ? "Color will be displayed in format like (255, 12, 34, 0.5). Alpha value is between 0 and 1." + : "Color will be displayed in format like #7e7e7eff. The components are #RRGGBBAA." + } +} + +/// What a double click does in the hierarchy, and whether its expansion +/// state is kept per app. +private struct GeneralHierarchySection: View { + @Bindable var model: GeneralSettingsModel + + var body: some View { + Section { + Picker("Double click", selection: $model.doubleClickBehavior) { + Text("Expand or collapse layer").tag(DoubleClickBehavior.collapse) + Text("Focus on layer").tag(DoubleClickBehavior.focus) + } + + DescribedSettingRow( + "Remember expansion state per app", + description: "When on, the hierarchy expand/collapse state is restored per target-app bundle id across host and target-app restarts. The 20 most recently inspected apps are kept; toggling off preserves existing data but stops reading or writing it." + ) { + Toggle("Remember expansion state per app", isOn: $model.remembersExpansionState) + .toggleStyle(.switch) + } + } + } +} + +/// Request timeouts. The license one is for debugging the handshake only. +private struct GeneralTimeoutSection: View { + @Bindable var model: GeneralSettingsModel + + var body: some View { + Section { + DescribedSettingRow("Hierarchy Timeout", description: "Timeout for hierarchy and hierarchy-details requests, in seconds. Default: 15s.") { + TimeoutField("Hierarchy Timeout", seconds: $model.hierarchyRequestTimeout) + } + + #if DEBUG + DescribedSettingRow("License Timeout", description: "Timeout for license challenge and verification requests, in seconds. Default: 5s.") { + TimeoutField("License Timeout", seconds: $model.licenseHandshakeTimeout) + } + #endif + } + } +} + +private struct GeneralResetSection: View { + let model: GeneralSettingsModel + + var body: some View { + Section { + Button("Reset", role: .destructive) { + model.resetToDefaults() + } + .keyboardShortcut(.delete, modifiers: [.command, .shift]) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } +} + +/// Seconds, typed or stepped. +private struct TimeoutField: View { + private let title: LocalizedStringKey + @Binding private var seconds: Double + + init(_ title: LocalizedStringKey, seconds: Binding) { + self.title = title + _seconds = seconds + } + + var body: some View { + HStack(spacing: 8) { + TextField(title, value: $seconds, format: .number.precision(.fractionLength(0 ... 2))) + .multilineTextAlignment(.trailing) + .textFieldStyle(.roundedBorder) + .frame(width: 72) + + Text(verbatim: "s") + .foregroundStyle(.secondary) + + Stepper(title, value: $seconds, in: PreferenceValuePolicy.timeoutRange, step: 1) + } + .labelsHidden() + } +} + +/// The General page's side of PreferenceManager.shared. Setting a property +/// writes the manager, and KVO brings every change back, including the ones +/// made elsewhere (the Dashboard switches the color format too), so the page +/// never shows a stale value. +@MainActor +@Observable +final class GeneralSettingsModel { + private let preferences: PreferenceManager + @ObservationIgnored private var observations: [NSKeyValueObservation] = [] + + // What the manager holds, as of its last KVO notification. + private var currentAppearanceType: PreferredAppearanceType + private var currentUsesRGBAColorFormat: Bool + private var currentImageContrastLevel: Int + private var currentDoubleClickBehavior: DoubleClickBehavior + private var currentRemembersExpansionState: Bool + private var currentHierarchyRequestTimeout: TimeInterval + private var currentLicenseHandshakeTimeout: TimeInterval + + init(preferences: PreferenceManager = .shared) { + self.preferences = preferences + currentAppearanceType = preferences.appearanceType + currentUsesRGBAColorFormat = preferences.rgbaFormat + currentImageContrastLevel = preferences.imageContrastLevel + currentDoubleClickBehavior = preferences.doubleClickBehavior + currentRemembersExpansionState = preferences.rememberExpansionState + currentHierarchyRequestTimeout = preferences.hierarchyRequestTimeoutInterval + currentLicenseHandshakeTimeout = preferences.licenseHandshakeTimeoutInterval + + observations = [ + preferences.observe(\.appearanceType, options: [.new]) { [weak self] preferences, _ in + MainActor.assumeIsolated { self?.currentAppearanceType = preferences.appearanceType } + }, + preferences.observe(\.rgbaFormat, options: [.new]) { [weak self] preferences, _ in + MainActor.assumeIsolated { self?.currentUsesRGBAColorFormat = preferences.rgbaFormat } + }, + preferences.observe(\.imageContrastLevel, options: [.new]) { [weak self] preferences, _ in + MainActor.assumeIsolated { self?.currentImageContrastLevel = preferences.imageContrastLevel } + }, + preferences.observe(\.doubleClickBehavior, options: [.new]) { [weak self] preferences, _ in + MainActor.assumeIsolated { self?.currentDoubleClickBehavior = preferences.doubleClickBehavior } + }, + preferences.observe(\.rememberExpansionState, options: [.new]) { [weak self] preferences, _ in + MainActor.assumeIsolated { self?.currentRemembersExpansionState = preferences.rememberExpansionState } + }, + preferences.observe(\.hierarchyRequestTimeoutInterval, options: [.new]) { [weak self] preferences, _ in + MainActor.assumeIsolated { self?.currentHierarchyRequestTimeout = preferences.hierarchyRequestTimeoutInterval } + }, + preferences.observe(\.licenseHandshakeTimeoutInterval, options: [.new]) { [weak self] preferences, _ in + MainActor.assumeIsolated { self?.currentLicenseHandshakeTimeout = preferences.licenseHandshakeTimeoutInterval } + }, + ] + } + + var appearanceType: PreferredAppearanceType { + get { currentAppearanceType } + set { preferences.appearanceType = newValue } + } + + var usesRGBAColorFormat: Bool { + get { currentUsesRGBAColorFormat } + set { preferences.rgbaFormat = newValue } + } + + var imageContrastLevel: Int { + get { currentImageContrastLevel } + set { preferences.imageContrastLevel = PreferenceValuePolicy.clampedImageContrastLevel(newValue) } + } + + var doubleClickBehavior: DoubleClickBehavior { + get { currentDoubleClickBehavior } + set { preferences.doubleClickBehavior = newValue } + } + + var remembersExpansionState: Bool { + get { currentRemembersExpansionState } + set { preferences.rememberExpansionState = newValue } + } + + var hierarchyRequestTimeout: TimeInterval { + get { currentHierarchyRequestTimeout } + set { + preferences.hierarchyRequestTimeoutInterval = PreferenceValuePolicy.sanitizedTimeout( + newValue, defaultValue: defaultHierarchyRequestTimeoutInterval + ) + } + } + + var licenseHandshakeTimeout: TimeInterval { + get { currentLicenseHandshakeTimeout } + set { + preferences.licenseHandshakeTimeoutInterval = PreferenceValuePolicy.sanitizedTimeout( + newValue, defaultValue: defaultLicenseHandshakeTimeoutInterval + ) + } + } + + /// Every value on the page back to its default. Debug builds also forget + /// the dismissed tips, as the Preferences window's Reset did. + func resetToDefaults() { + appearanceType = .system + usesRGBAColorFormat = true + imageContrastLevel = 0 + doubleClickBehavior = .collapse + remembersExpansionState = true + hierarchyRequestTimeout = defaultHierarchyRequestTimeoutInterval + + #if DEBUG + licenseHandshakeTimeout = defaultLicenseHandshakeTimeoutInterval + MessageManager.sharedInstance().reset() + preferences.reset() + UserDefaults.standard.removeObject(forKey: "IgnoreFastModeTips") + #endif + } +} diff --git a/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift b/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift new file mode 100644 index 00000000..347eedec --- /dev/null +++ b/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift @@ -0,0 +1,300 @@ +// +// PrivateDiscriminatorSettingsPage.swift +// LookInside +// +// The Private Discriminator page of the Settings window: the feature +// switch, the default library download and the imported modules. +// PrivateDiscriminatorStore.shared owns all of it and its config.json. +// + +import AppKit +import SwiftUI +import UIFoundationSettingsUI + +struct PrivateDiscriminatorSettingsPage: View { + private let store = PrivateDiscriminatorStore.shared + + /// The last import, reimport or removal failure. It outranks the + /// store's own error, which covers loading and saving. + @State private var importError: String? + + var body: some View { + SettingsForm { + PrivateDiscriminatorFeatureSection(store: store, importError: $importError) + + if store.featureEnabled { + PrivateDiscriminatorModulesSection(store: store, importError: $importError) + PrivateDiscriminatorProblemsSection(store: store, importError: importError) + } + } + .onAppear { + store.reloadFromDisk() + } + } +} + +/// The feature switch and the default library download. +private struct PrivateDiscriminatorFeatureSection: View { + @Bindable var store: PrivateDiscriminatorStore + @Binding var importError: String? + + var body: some View { + Section { + DescribedSettingRow("Enable", description: "Resolve Swift private-discriminator hashes from imported module CSVs. Disabled by default.") { + Toggle("Enable", isOn: $store.isFeatureEnabledSetting) + .toggleStyle(.switch) + } + + DescribedSettingRow("Default Library", description: "Download SwiftUI and SwiftUICore indexes from GitHub into local imported CSVs.") { + Button { + importError = nil + store.updateDefaultLibrary() + } label: { + if store.isUpdatingDefaultLibrary { + Label("Updating…", systemImage: "arrow.down.circle") + } else { + Label("Update Default Library", systemImage: "arrow.down.circle") + } + } + .disabled(store.isUpdatingDefaultLibrary) + } + + if let message = store.lastDefaultLibraryUpdateMessage { + HStack(spacing: 8) { + if store.isUpdatingDefaultLibrary { + ProgressView() + .controlSize(.small) + } + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } +} + +/// Autosave, importing a module, and the modules imported so far. +private struct PrivateDiscriminatorModulesSection: View { + @Bindable var store: PrivateDiscriminatorStore + @Binding var importError: String? + + var body: some View { + Section { + DescribedSettingRow("Autosave", description: "Allow future guessed rows to use autosaved module CSVs.") { + Toggle("Autosave", isOn: $store.isAutosaveEnabledSetting) + .toggleStyle(.switch) + } + + HStack(spacing: 8) { + Button { + beginImport() + } label: { + Label("Import Module", systemImage: "square.and.arrow.down") + } + + Button { + store.revealStorageDirectory() + } label: { + Label("Reveal Storage", systemImage: "folder") + } + } + + if store.moduleStatuses.isEmpty { + Text("No modules imported yet.") + .foregroundStyle(.secondary) + } else { + ForEach(store.moduleStatuses) { status in + PrivateDiscriminatorModuleRow(status: status, store: store) { message in + importError = message + } + } + } + } + } + + private func beginImport() { + importError = nil + guard let module = promptForModuleName() else { + return + } + + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.prompt = String(localized: "Import") + panel.message = String(localized: "Choose the local Swift source folder for \(module).") + + guard panel.runModal() == .OK, let url = panel.url else { + return + } + + do { + try store.importModule(named: module, from: url) + } catch { + importError = error.localizedDescription + } + } + + private func promptForModuleName() -> String? { + let alert = NSAlert() + alert.messageText = String(localized: "Import Private Discriminator Module") + alert.informativeText = String(localized: "Enter the Swift module name for this source folder.") + alert.addButton(withTitle: String(localized: "Continue")) + alert.addButton(withTitle: String(localized: "Cancel")) + + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24)) + textField.placeholderString = String(localized: "ModuleName") + alert.accessoryView = textField + + guard alert.runModal() == .alertFirstButtonReturn else { + return nil + } + + let module = textField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + if module.isEmpty { + importError = String(localized: "Module name is required.") + return nil + } + return module + } +} + +/// CSV files that failed to load, and the last error. +private struct PrivateDiscriminatorProblemsSection: View { + let store: PrivateDiscriminatorStore + let importError: String? + + var body: some View { + if !store.invalidDiagnostics.isEmpty || errorMessage != nil { + Section { + if !store.invalidDiagnostics.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Invalid CSV Diagnostics") + ForEach(store.invalidDiagnostics) { diagnostic in + Text("\(diagnostic.module) \(diagnostic.source): \(diagnostic.message)") + .font(.subheadline) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + if let errorMessage { + Text(errorMessage) + .font(.subheadline) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + private var errorMessage: String? { + importError ?? store.lastSettingsError + } +} + +private struct PrivateDiscriminatorModuleRow: View { + let status: PrivateDiscriminatorModuleStatus + @Bindable var store: PrivateDiscriminatorStore + let onError: (String) -> Void + + var body: some View { + HStack(alignment: .center, spacing: 10) { + Toggle(status.module, isOn: $store[isModuleEnabled: status.module]) + .labelsHidden() + .toggleStyle(.switch) + + VStack(alignment: .leading, spacing: 3) { + Text(status.module) + + Text(status.sourceSummary) + .font(.subheadline) + .foregroundStyle(.secondary) + + if let diagnostic = status.diagnostic { + Text(diagnostic) + .font(.subheadline) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer(minLength: 8) + + Button { + reimport() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help("Reimport from saved source folder") + .disabled(status.sourceFolderPath == nil) + + Button { + store.revealModule(status.module) + } label: { + Image(systemName: "folder") + } + .buttonStyle(.borderless) + .help("Reveal CSV") + + Button(role: .destructive) { + remove() + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Remove module CSVs") + } + } + + private func reimport() { + do { + try store.reimportModule(status.module) + } catch { + onError(error.localizedDescription) + } + } + + private func remove() { + let alert = NSAlert() + alert.messageText = String(localized: "Remove \(status.module)?") + alert.informativeText = String(localized: "This removes imported and autosaved CSVs for the module.") + alert.addButton(withTitle: String(localized: "Remove")) + alert.addButton(withTitle: String(localized: "Cancel")) + alert.alertStyle = .warning + + guard alert.runModal() == .alertFirstButtonReturn else { + return + } + + do { + try store.removeModule(status.module) + } catch { + onError(error.localizedDescription) + } + } +} + +/// The store's switches as read-write properties, so the page can bind them +/// by key path; writing one saves config.json and reloads the store. +private extension PrivateDiscriminatorStore { + var isFeatureEnabledSetting: Bool { + get { featureEnabled } + set { setFeatureEnabled(newValue) } + } + + var isAutosaveEnabledSetting: Bool { + get { autosaveEnabled } + set { setAutosaveEnabled(newValue) } + } + + subscript(isModuleEnabled module: String) -> Bool { + get { moduleStatuses.first { $0.module == module }?.isEnabled ?? false } + set { setModule(module, enabled: newValue) } + } +} diff --git a/LookInside/Settings/SettingsPages.swift b/LookInside/Settings/SettingsPages.swift new file mode 100644 index 00000000..8be73049 --- /dev/null +++ b/LookInside/Settings/SettingsPages.swift @@ -0,0 +1,75 @@ +// +// SettingsPages.swift +// LookInside +// +// The pages of the Settings window, which is UIFoundation's +// SettingsWindowController (NavigationManager holds it). The window only +// changes how the settings are shown: PreferenceManager keeps the general +// ones in the standard user defaults, PrivateDiscriminatorStore keeps its +// own in config.json. +// + +import SwiftUI +import UIFoundationSettingsUI + +enum SettingsPages { + /// Page identifiers. Unlike the titles they do not change with the + /// language, so they are what selects a page. + static let generalIdentifier = "general" + static let privateDiscriminatorIdentifier = "privateDiscriminator" + + /// The sidebar, in order. + @MainActor + @SettingsPageBuilder + static func make() -> [SettingsPage] { + // Made once per window, so the page keeps one model however often + // SwiftUI rebuilds it. + let generalModel = GeneralSettingsModel() + + SettingsPage( + String(localized: "General", comment: "Title of the Settings window's General page."), + id: generalIdentifier, + symbol: "gearshape", + tint: .gray + ) { + GeneralSettingsPage(model: generalModel) + } + + SettingsPage( + String(localized: "Private Discriminator", comment: "Title of the Settings window's Private Discriminator page."), + id: privateDiscriminatorIdentifier, + symbol: "number", + tint: .purple + ) { + PrivateDiscriminatorSettingsPage() + } + } +} + +/// A form row whose label is a title above a secondary description, the way +/// System Settings lays out a setting that needs explaining. +struct DescribedSettingRow: View { + private let title: LocalizedStringResource + private let description: LocalizedStringResource + private let control: Control + + init( + _ title: LocalizedStringResource, + description: LocalizedStringResource, + @ViewBuilder control: () -> Control + ) { + self.title = title + self.description = description + self.control = control() + } + + var body: some View { + LabeledContent { + control + .labelsHidden() + } label: { + Text(title) + Text(description) + } + } +} diff --git a/LookInside/Toolbar/MenuPopoverSettingController.swift b/LookInside/Toolbar/MenuPopoverSettingController.swift index cf38032b..632203eb 100644 --- a/LookInside/Toolbar/MenuPopoverSettingController.swift +++ b/LookInside/Toolbar/MenuPopoverSettingController.swift @@ -133,7 +133,7 @@ final class MenuPopoverSettingController: BaseViewController { } @objc private func handlePreferenceButton() { - NavigationManager.shared.showPreference() + NavigationManager.shared.showSettings() } @objc private func handleZInterspaceDidChange(_ param: MessageActionParameters) { From 51cf4ef734349d1f0163cd1b71c153054555a73a Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 11 Oct 2026 20:46:33 +0800 Subject: [PATCH 3/5] Lay out the Settings window the way RuntimeViewer does RuntimeViewer is the reference for UIFoundation's Settings window, and both pages now follow it: - The sidebar shows plain glyphs at 15 points. It is 20 points wider than UIFoundation's default so "Private Discriminator" fits, and the window grows by as much, so the pages keep their width. - The back and forward controls are shown again. - Each group has a header, and what a setting does moves from a line under the row to the group's footer. - Appearance is chosen from three thumbnails, read at run time from System Settings' Appearance extension, so LookInside ships no copy of them. Should a macOS version move them, the page falls back to the pop-up menu. - The timeouts are a field with a stepper; the footer gives the unit. The new headers and thumbnail titles are translated into all 13 languages. The empty key leaves the string catalog: the Stepper("") in the old Preferences view was the last thing that used it. --- LookInside/Localizable.xcstrings | 501 +++++++++++++++++- LookInside/Manager/NavigationManager.swift | 8 +- LookInside/Settings/GeneralSettingsPage.swift | 219 ++++++-- .../PrivateDiscriminatorSettingsPage.swift | 90 ++-- LookInside/Settings/SettingsPages.swift | 38 +- 5 files changed, 747 insertions(+), 109 deletions(-) diff --git a/LookInside/Localizable.xcstrings b/LookInside/Localizable.xcstrings index 6fb4a5ec..d3b5ac49 100644 --- a/LookInside/Localizable.xcstrings +++ b/LookInside/Localizable.xcstrings @@ -1,9 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "" : { - - }, " / " : { "comment" : "Separator between imported/autosaved CSV sources", "extractionState" : "stale", @@ -8342,6 +8339,89 @@ } } }, + "Dark" : { + "comment" : "Caption under the dark appearance thumbnail in the Settings window.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "داكن" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dunkel" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dark" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oscuro" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sombre" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scuro" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダーク" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다크" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escuro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тёмный" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "深色" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "深色" + } + } + } + }, "Dark Mode" : { "localizations" : { "ar" : { @@ -15086,6 +15166,89 @@ } } }, + "Hierarchy" : { + "comment" : "Header of the Settings window's group of hierarchy settings.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "التسلسل الهرمي" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hierarchie" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hierarchy" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jerarquía" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiérarchie" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gerarchia" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "階層" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "계층 구조" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hierarquia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Иерархия" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phân cấp" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "层级" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "階層" + } + } + } + }, "Hierarchy Depth" : { "comment" : "View menu submenu", "localizations" : { @@ -19942,6 +20105,89 @@ } } }, + "Light" : { + "comment" : "Caption under the light appearance thumbnail in the Settings window.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "فاتح" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hell" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Light" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Claro" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clair" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chiaro" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ライト" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "라이트" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Claro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Светлый" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sáng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "浅色" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "淺色" + } + } + } + }, "Light Mode" : { "localizations" : { "ar" : { @@ -22411,6 +22657,89 @@ } } }, + "Modules" : { + "comment" : "Header of the Private Discriminator settings group that lists the imported modules.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الوحدات" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Module" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modules" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Módulos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modules" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moduli" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "モジュール" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모듈" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Módulos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модули" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mô-đun" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "模块" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "模組" + } + } + } + }, "More than one connected device has a LookInside Injector ready." : { "comment" : "Alert body in device chooser", "localizations" : { @@ -35361,6 +35690,89 @@ } } }, + "System" : { + "comment" : "Caption under the appearance thumbnail that follows the system's light or dark setting, in the Settings window.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "النظام" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "System" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "System" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistema" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Système" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistema" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "システム" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시스템" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistema" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Система" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hệ thống" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "系统" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "系統" + } + } + } + }, "System Default" : { "localizations" : { "ar" : { @@ -39785,6 +40197,89 @@ } } }, + "Timeouts" : { + "comment" : "Header of the Settings window's group of request timeouts.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المهلات" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeitlimits" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timeouts" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiempos de espera" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Délais d’attente" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timeout" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "タイムアウト" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제한 시간" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tempos Limite" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тайм-ауты" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thời gian chờ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "超时" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "逾時" + } + } + } + }, "to measure between it and selected %@." : { "localizations" : { "ar" : { diff --git a/LookInside/Manager/NavigationManager.swift b/LookInside/Manager/NavigationManager.swift index 5b2e310a..4a02e253 100644 --- a/LookInside/Manager/NavigationManager.swift +++ b/LookInside/Manager/NavigationManager.swift @@ -27,8 +27,12 @@ final class NavigationManager: NSObject, NSWindowDelegate { private(set) lazy var settingsWindowController = SettingsWindowController( configuration: SettingsConfiguration( title: String(localized: "Settings", comment: "Title of the Settings window."), - // Two pages leave nothing to go back and forth between. - showsNavigationControls: false + // The sidebar is 20 points wider than UIFoundation's default so + // "Private Discriminator" fits; the window grows by as much, so + // the pages keep the default width. + contentWidth: 735, + sidebarWidth: 205, + sidebarIconSize: 15 ), pages: { SettingsPages.make() } ) diff --git a/LookInside/Settings/GeneralSettingsPage.swift b/LookInside/Settings/GeneralSettingsPage.swift index 385d27fb..ba92b30a 100644 --- a/LookInside/Settings/GeneralSettingsPage.swift +++ b/LookInside/Settings/GeneralSettingsPage.swift @@ -7,6 +7,7 @@ // stores it in the standard user defaults under its original key. // +import AppKit import Foundation import LookInsideHostCore import SwiftUI @@ -17,52 +18,91 @@ struct GeneralSettingsPage: View { var body: some View { SettingsForm { - GeneralDisplaySection(model: model) + GeneralAppearanceSection(model: model) + GeneralColorFormatSection(model: model) + GeneralImageContrastSection(model: model) GeneralHierarchySection(model: model) GeneralTimeoutSection(model: model) + #if DEBUG + GeneralLicenseTimeoutSection(model: model) + #endif GeneralResetSection(model: model) } } } -/// Appearance, the color format and the image contrast. -private struct GeneralDisplaySection: View { +/// Dark, light, or whichever the system uses: picked from the pictures +/// System Settings shows for the same choice, or from a menu when this +/// macOS does not have them where they are looked for. +private struct GeneralAppearanceSection: View { @Bindable var model: GeneralSettingsModel var body: some View { Section { - Picker("Appearance", selection: $model.appearanceType) { - Text("Dark Mode").tag(PreferredAppearanceType.dark) - Text("Light Mode").tag(PreferredAppearanceType.light) - Text("System Default").tag(PreferredAppearanceType.system) + if let thumbnails = SystemAppearanceThumbnails.current { + LabeledContent("Appearance") { + AppearanceThumbnailPicker(selection: $model.appearanceType, thumbnails: thumbnails) + } + } else { + Picker("Appearance", selection: $model.appearanceType) { + Text("Dark Mode").tag(PreferredAppearanceType.dark) + Text("Light Mode").tag(PreferredAppearanceType.light) + Text("System Default").tag(PreferredAppearanceType.system) + } } + } + } +} + +/// How colors are written out: RGBA components or a hex string. +private struct GeneralColorFormatSection: View { + @Bindable var model: GeneralSettingsModel - DescribedSettingRow("Color Format", description: colorFormatDescription) { + var body: some View { + Section { + LabeledContent("Color Format") { Picker("Color Format", selection: $model.usesRGBAColorFormat) { Text(verbatim: "RGBA").tag(true) Text(verbatim: "HEX").tag(false) } .pickerStyle(.segmented) + .labelsHidden() .fixedSize() } + } header: { + Text("Display") + } footer: { + Text(colorFormatDescription) + } + } - DescribedSettingRow("Image contrast", description: "Adjust this option to use a deeper layer selection color.") { + private var colorFormatDescription: LocalizedStringResource { + model.usesRGBAColorFormat + ? "Color will be displayed in format like (255, 12, 34, 0.5). Alpha value is between 0 and 1." + : "Color will be displayed in format like #7e7e7eff. The components are #RRGGBBAA." + } +} + +/// How deep the selected layer's color is. +private struct GeneralImageContrastSection: View { + @Bindable var model: GeneralSettingsModel + + var body: some View { + Section { + LabeledContent("Image contrast") { Picker("Image contrast", selection: $model.imageContrastLevel) { Text("Normal").tag(0) Text("Medium").tag(1) Text("High").tag(2) } .pickerStyle(.segmented) + .labelsHidden() .fixedSize() } + } footer: { + Text("Adjust this option to use a deeper layer selection color.") } } - - private var colorFormatDescription: LocalizedStringResource { - model.usesRGBAColorFormat - ? "Color will be displayed in format like (255, 12, 34, 0.5). Alpha value is between 0 and 1." - : "Color will be displayed in format like #7e7e7eff. The components are #RRGGBBAA." - } } /// What a double click does in the hierarchy, and whether its expansion @@ -77,35 +117,48 @@ private struct GeneralHierarchySection: View { Text("Focus on layer").tag(DoubleClickBehavior.focus) } - DescribedSettingRow( - "Remember expansion state per app", - description: "When on, the hierarchy expand/collapse state is restored per target-app bundle id across host and target-app restarts. The 20 most recently inspected apps are kept; toggling off preserves existing data but stops reading or writing it." - ) { - Toggle("Remember expansion state per app", isOn: $model.remembersExpansionState) - .toggleStyle(.switch) - } + Toggle("Remember expansion state per app", isOn: $model.remembersExpansionState) + } header: { + Text("Hierarchy") + } footer: { + Text("When on, the hierarchy expand/collapse state is restored per target-app bundle id across host and target-app restarts. The 20 most recently inspected apps are kept; toggling off preserves existing data but stops reading or writing it.") } } } -/// Request timeouts. The license one is for debugging the handshake only. +/// How long the hierarchy requests may take. private struct GeneralTimeoutSection: View { @Bindable var model: GeneralSettingsModel var body: some View { Section { - DescribedSettingRow("Hierarchy Timeout", description: "Timeout for hierarchy and hierarchy-details requests, in seconds. Default: 15s.") { - TimeoutField("Hierarchy Timeout", seconds: $model.hierarchyRequestTimeout) + LabeledContent("Hierarchy Timeout") { + TimeoutStepper("Hierarchy Timeout", seconds: $model.hierarchyRequestTimeout) } + } header: { + Text("Timeouts") + } footer: { + Text("Timeout for hierarchy and hierarchy-details requests, in seconds. Default: 15s.") + } + } +} - #if DEBUG - DescribedSettingRow("License Timeout", description: "Timeout for license challenge and verification requests, in seconds. Default: 5s.") { - TimeoutField("License Timeout", seconds: $model.licenseHandshakeTimeout) +#if DEBUG + /// The license handshake's timeout, there for debugging the handshake. + private struct GeneralLicenseTimeoutSection: View { + @Bindable var model: GeneralSettingsModel + + var body: some View { + Section { + LabeledContent("License Timeout") { + TimeoutStepper("License Timeout", seconds: $model.licenseHandshakeTimeout) } - #endif + } footer: { + Text("Timeout for license challenge and verification requests, in seconds. Default: 5s.") + } } } -} +#endif private struct GeneralResetSection: View { let model: GeneralSettingsModel @@ -121,8 +174,8 @@ private struct GeneralResetSection: View { } } -/// Seconds, typed or stepped. -private struct TimeoutField: View { +/// Seconds, typed into the field or stepped a whole second at a time. +private struct TimeoutStepper: View { private let title: LocalizedStringKey @Binding private var seconds: Double @@ -132,21 +185,105 @@ private struct TimeoutField: View { } var body: some View { - HStack(spacing: 8) { - TextField(title, value: $seconds, format: .number.precision(.fractionLength(0 ... 2))) - .multilineTextAlignment(.trailing) - .textFieldStyle(.roundedBorder) - .frame(width: 72) + Stepper( + title, + value: $seconds, + in: PreferenceValuePolicy.timeoutRange, + step: 1, + format: .number.precision(.fractionLength(0 ... 2)) + ) + .labelsHidden() + } +} + +// MARK: - Appearance thumbnails + +/// The pictures System Settings shows for its three appearance choices. +/// They are Apple's artwork, so the app reads them out of the system's +/// Appearance settings extension at run time instead of shipping copies. +private struct SystemAppearanceThumbnails { + let system: NSImage + let light: NSImage + let dark: NSImage + + /// Nil when this version of macOS has moved or renamed the pictures. + @MainActor static let current = SystemAppearanceThumbnails( + settingsExtensionPath: "/System/Library/ExtensionKit/Extensions/Appearance.appex" + ) + + init?(settingsExtensionPath: String) { + guard + let settingsExtension = Bundle(path: settingsExtensionPath), + let system = settingsExtension.image(forResource: "AppearanceAuto"), + let light = settingsExtension.image(forResource: "AppearanceLight"), + let dark = settingsExtension.image(forResource: "AppearanceDark") + else { + return nil + } + self.system = system + self.light = light + self.dark = dark + } +} + +/// System, light and dark side by side, the chosen one outlined. +private struct AppearanceThumbnailPicker: View { + @Binding var selection: PreferredAppearanceType + let thumbnails: SystemAppearanceThumbnails + + var body: some View { + HStack(spacing: 5) { + AppearanceThumbnailOption(image: thumbnails.system, title: "System", isSelected: selection == .system) { + selection = .system + } + AppearanceThumbnailOption(image: thumbnails.light, title: "Light", isSelected: selection == .light) { + selection = .light + } + AppearanceThumbnailOption(image: thumbnails.dark, title: "Dark", isSelected: selection == .dark) { + selection = .dark + } + } + .padding(.vertical, 8) + } +} + +private struct AppearanceThumbnailOption: View { + let image: NSImage + let title: LocalizedStringKey + let isSelected: Bool + let action: () -> Void - Text(verbatim: "s") - .foregroundStyle(.secondary) + @State private var isHovering = false - Stepper(title, value: $seconds, in: PreferenceValuePolicy.timeoutRange, step: 1) + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 72, height: 48) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay { + RoundedRectangle(cornerRadius: 5) + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 3) + } + .shadow(color: .black.opacity(isHovering ? 0.15 : 0.1), radius: isHovering ? 3 : 2, x: 0, y: 1) + + Text(title) + .font(.caption) + .foregroundStyle(isSelected ? .primary : .secondary) + } + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .onHover { hovering in + isHovering = hovering } - .labelsHidden() } } +// MARK: - Model + /// The General page's side of PreferenceManager.shared. Setting a property /// writes the manager, and KVO brings every change back, including the ones /// made elsewhere (the Dashboard switches the color format too), so the page diff --git a/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift b/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift index 347eedec..6e2b6942 100644 --- a/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift +++ b/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift @@ -20,9 +20,11 @@ struct PrivateDiscriminatorSettingsPage: View { var body: some View { SettingsForm { - PrivateDiscriminatorFeatureSection(store: store, importError: $importError) + PrivateDiscriminatorFeatureSection(store: store) + PrivateDiscriminatorDefaultLibrarySection(store: store, importError: $importError) if store.featureEnabled { + PrivateDiscriminatorAutosaveSection(store: store) PrivateDiscriminatorModulesSection(store: store, importError: $importError) PrivateDiscriminatorProblemsSection(store: store, importError: importError) } @@ -33,19 +35,28 @@ struct PrivateDiscriminatorSettingsPage: View { } } -/// The feature switch and the default library download. +/// The feature switch. private struct PrivateDiscriminatorFeatureSection: View { @Bindable var store: PrivateDiscriminatorStore - @Binding var importError: String? var body: some View { Section { - DescribedSettingRow("Enable", description: "Resolve Swift private-discriminator hashes from imported module CSVs. Disabled by default.") { - Toggle("Enable", isOn: $store.isFeatureEnabledSetting) - .toggleStyle(.switch) - } + Toggle("Enable", isOn: $store.isFeatureEnabledSetting) + } footer: { + Text("Resolve Swift private-discriminator hashes from imported module CSVs. Disabled by default.") + } + } +} + +/// Downloading the SwiftUI and SwiftUICore indexes, and how the last +/// download went. +private struct PrivateDiscriminatorDefaultLibrarySection: View { + let store: PrivateDiscriminatorStore + @Binding var importError: String? - DescribedSettingRow("Default Library", description: "Download SwiftUI and SwiftUICore indexes from GitHub into local imported CSVs.") { + var body: some View { + Section { + HStack { Button { importError = nil store.updateDefaultLibrary() @@ -57,6 +68,8 @@ private struct PrivateDiscriminatorFeatureSection: View { } } .disabled(store.isUpdatingDefaultLibrary) + + Spacer() } if let message = store.lastDefaultLibraryUpdateMessage { @@ -71,22 +84,33 @@ private struct PrivateDiscriminatorFeatureSection: View { .fixedSize(horizontal: false, vertical: true) } } + } header: { + Text("Default Library") + } footer: { + Text("Download SwiftUI and SwiftUICore indexes from GitHub into local imported CSVs.") } } } -/// Autosave, importing a module, and the modules imported so far. -private struct PrivateDiscriminatorModulesSection: View { +private struct PrivateDiscriminatorAutosaveSection: View { @Bindable var store: PrivateDiscriminatorStore - @Binding var importError: String? var body: some View { Section { - DescribedSettingRow("Autosave", description: "Allow future guessed rows to use autosaved module CSVs.") { - Toggle("Autosave", isOn: $store.isAutosaveEnabledSetting) - .toggleStyle(.switch) - } + Toggle("Autosave", isOn: $store.isAutosaveEnabledSetting) + } footer: { + Text("Allow future guessed rows to use autosaved module CSVs.") + } + } +} + +/// Importing a module, and the modules imported so far. +private struct PrivateDiscriminatorModulesSection: View { + let store: PrivateDiscriminatorStore + @Binding var importError: String? + var body: some View { + Section { HStack(spacing: 8) { Button { beginImport() @@ -99,6 +123,8 @@ private struct PrivateDiscriminatorModulesSection: View { } label: { Label("Reveal Storage", systemImage: "folder") } + + Spacer() } if store.moduleStatuses.isEmpty { @@ -111,6 +137,8 @@ private struct PrivateDiscriminatorModulesSection: View { } } } + } header: { + Text("Modules") } } @@ -162,32 +190,32 @@ private struct PrivateDiscriminatorModulesSection: View { } } -/// CSV files that failed to load, and the last error. +/// CSV files that failed to load, and the last error. Each shows only +/// while there is something to report. private struct PrivateDiscriminatorProblemsSection: View { let store: PrivateDiscriminatorStore let importError: String? var body: some View { - if !store.invalidDiagnostics.isEmpty || errorMessage != nil { + if !store.invalidDiagnostics.isEmpty { Section { - if !store.invalidDiagnostics.isEmpty { - VStack(alignment: .leading, spacing: 4) { - Text("Invalid CSV Diagnostics") - ForEach(store.invalidDiagnostics) { diagnostic in - Text("\(diagnostic.module) \(diagnostic.source): \(diagnostic.message)") - .font(.subheadline) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } - } - } - - if let errorMessage { - Text(errorMessage) + ForEach(store.invalidDiagnostics) { diagnostic in + Text("\(diagnostic.module) \(diagnostic.source): \(diagnostic.message)") .font(.subheadline) .foregroundStyle(.red) .fixedSize(horizontal: false, vertical: true) } + } header: { + Text("Invalid CSV Diagnostics") + } + } + + if let errorMessage { + Section { + Text(errorMessage) + .font(.subheadline) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) } } } diff --git a/LookInside/Settings/SettingsPages.swift b/LookInside/Settings/SettingsPages.swift index 8be73049..8b1f609e 100644 --- a/LookInside/Settings/SettingsPages.swift +++ b/LookInside/Settings/SettingsPages.swift @@ -8,6 +8,10 @@ // ones in the standard user defaults, PrivateDiscriminatorStore keeps its // own in config.json. // +// The pages are laid out the way RuntimeViewer lays out its settings with +// the same window: plain glyphs in the sidebar, a header over each group, +// and what a setting does in the footer under its group. +// import SwiftUI import UIFoundationSettingsUI @@ -29,8 +33,7 @@ enum SettingsPages { SettingsPage( String(localized: "General", comment: "Title of the Settings window's General page."), id: generalIdentifier, - symbol: "gearshape", - tint: .gray + plainSymbol: "gearshape" ) { GeneralSettingsPage(model: generalModel) } @@ -38,38 +41,9 @@ enum SettingsPages { SettingsPage( String(localized: "Private Discriminator", comment: "Title of the Settings window's Private Discriminator page."), id: privateDiscriminatorIdentifier, - symbol: "number", - tint: .purple + plainSymbol: "number" ) { PrivateDiscriminatorSettingsPage() } } } - -/// A form row whose label is a title above a secondary description, the way -/// System Settings lays out a setting that needs explaining. -struct DescribedSettingRow: View { - private let title: LocalizedStringResource - private let description: LocalizedStringResource - private let control: Control - - init( - _ title: LocalizedStringResource, - description: LocalizedStringResource, - @ViewBuilder control: () -> Control - ) { - self.title = title - self.description = description - self.control = control() - } - - var body: some View { - LabeledContent { - control - .labelsHidden() - } label: { - Text(title) - Text(description) - } - } -} From 0b949335e03a9aacff30e90677fe1113f21b476d Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 11 Oct 2026 21:21:23 +0800 Subject: [PATCH 4/5] Give the reset and the default library update labeled rows A group holding nothing but a button read as an empty box. The reset is now laid out the way Xcode's settings lay out theirs: a "Reset" header over a row titled "Reset Settings", with what it does under the title and the button at the trailing edge. The default library row names the modules it downloads, shows how the last update went under them, and keeps its button at the trailing edge. The module names come from the store, which now exposes them, and are formatted as a list for the user's language. The two new strings are translated into all 13 languages. --- LookInside/Localizable.xcstrings | 166 ++++++++++++++++++ .../PrivateDiscriminatorStore.swift | 5 + LookInside/Settings/GeneralSettingsPage.swift | 17 +- .../PrivateDiscriminatorSettingsPage.swift | 38 ++-- 4 files changed, 202 insertions(+), 24 deletions(-) diff --git a/LookInside/Localizable.xcstrings b/LookInside/Localizable.xcstrings index d3b5ac49..b8822b5f 100644 --- a/LookInside/Localizable.xcstrings +++ b/LookInside/Localizable.xcstrings @@ -30497,6 +30497,172 @@ } } }, + "Reset Settings" : { + "comment" : "Title of the row on the Settings window's General page whose button resets the page's settings.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إعادة تعيين الإعدادات" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen zurücksetzen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset Settings" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecer ajustes" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialiser les réglages" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ripristina impostazioni" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定をリセット" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정 재설정" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redefinir Ajustes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбросить настройки" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đặt lại cài đặt" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置设置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置設定" + } + } + } + }, + "Resets the settings on this page to their default values." : { + "comment" : "Description under Reset Settings on the Settings window's General page.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يعيد تعيين الإعدادات في هذه الصفحة إلى قيمها الافتراضية." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Setzt die Einstellungen auf dieser Seite auf ihre Standardwerte zurück." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resets the settings on this page to their default values." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablece los ajustes de esta página a sus valores predeterminados." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialise les réglages de cette page à leurs valeurs par défaut." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ripristina le impostazioni di questa pagina ai valori predefiniti." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このページの設定をデフォルト値にリセットします。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 페이지의 설정을 기본값으로 재설정합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redefine os ajustes desta página para os valores padrão." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбрасывает настройки на этой странице к значениям по умолчанию." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đặt lại các cài đặt trên trang này về giá trị mặc định." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将本页的设置重置为默认值。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "將本頁的設定重置為預設值。" + } + } + } + }, "Resolve Swift private-discriminator hashes from imported module CSVs. Disabled by default." : { "localizations" : { "ar" : { diff --git a/LookInside/PrivateDiscriminator/PrivateDiscriminatorStore.swift b/LookInside/PrivateDiscriminator/PrivateDiscriminatorStore.swift index b344c357..cc6ca15d 100644 --- a/LookInside/PrivateDiscriminator/PrivateDiscriminatorStore.swift +++ b/LookInside/PrivateDiscriminator/PrivateDiscriminatorStore.swift @@ -1008,6 +1008,11 @@ final class PrivateDiscriminatorStore: NSObject { DefaultLibraryModule(name: "SwiftUICore"), ] + /// The modules the default library holds, in the order they download. + static var defaultLibraryModuleNames: [String] { + defaultLibraryModules.map(\.name) + } + private func promptForModuleName(defaultModule: String?, window _: NSWindow?) -> String? { let alert = NSAlert() alert.messageText = NSLocalizedString("Import Private Discriminator Module", comment: "") diff --git a/LookInside/Settings/GeneralSettingsPage.swift b/LookInside/Settings/GeneralSettingsPage.swift index ba92b30a..6c4d4bab 100644 --- a/LookInside/Settings/GeneralSettingsPage.swift +++ b/LookInside/Settings/GeneralSettingsPage.swift @@ -160,16 +160,25 @@ private struct GeneralTimeoutSection: View { } #endif +/// Putting every setting on this page back to its default, laid out the +/// way Xcode's settings offer the same. private struct GeneralResetSection: View { let model: GeneralSettingsModel var body: some View { Section { - Button("Reset", role: .destructive) { - model.resetToDefaults() + LabeledContent { + Button("Reset", role: .destructive) { + model.resetToDefaults() + } + .keyboardShortcut(.delete, modifiers: [.command, .shift]) + } label: { + Text("Reset Settings") + .font(.headline) + Text("Resets the settings on this page to their default values.") } - .keyboardShortcut(.delete, modifiers: [.command, .shift]) - .frame(maxWidth: .infinity, alignment: .trailing) + } header: { + Text("Reset") } } } diff --git a/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift b/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift index 6e2b6942..97086f37 100644 --- a/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift +++ b/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift @@ -56,32 +56,30 @@ private struct PrivateDiscriminatorDefaultLibrarySection: View { var body: some View { Section { - HStack { - Button { - importError = nil - store.updateDefaultLibrary() - } label: { - if store.isUpdatingDefaultLibrary { - Label("Updating…", systemImage: "arrow.down.circle") - } else { - Label("Update Default Library", systemImage: "arrow.down.circle") - } - } - .disabled(store.isUpdatingDefaultLibrary) - - Spacer() - } - - if let message = store.lastDefaultLibraryUpdateMessage { + LabeledContent { HStack(spacing: 8) { if store.isUpdatingDefaultLibrary { ProgressView() .controlSize(.small) } + + Button { + importError = nil + store.updateDefaultLibrary() + } label: { + if store.isUpdatingDefaultLibrary { + Label("Updating…", systemImage: "arrow.down.circle") + } else { + Label("Update Default Library", systemImage: "arrow.down.circle") + } + } + .disabled(store.isUpdatingDefaultLibrary) + } + } label: { + Text(PrivateDiscriminatorStore.defaultLibraryModuleNames, format: .list(type: .and)) + + if let message = store.lastDefaultLibraryUpdateMessage { Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) } } } header: { From 2df221c3502d6657cd9bb061418c5aa4f78832f2 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 11 Oct 2026 21:29:59 +0800 Subject: [PATCH 5/5] Center the action rows' buttons on both lines LabeledContent lines its control up with the first line of its label, so the Reset button sat level with "Reset Settings" instead of in the middle of the row, and the default library's button did the same once its status showed. Both rows are now an HStack, the way RuntimeViewer lays out its own, with the line under the title styled as before. --- LookInside/Settings/GeneralSettingsPage.swift | 20 ++++++-- .../PrivateDiscriminatorSettingsPage.swift | 47 +++++++++++-------- 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/LookInside/Settings/GeneralSettingsPage.swift b/LookInside/Settings/GeneralSettingsPage.swift index 6c4d4bab..b00f2672 100644 --- a/LookInside/Settings/GeneralSettingsPage.swift +++ b/LookInside/Settings/GeneralSettingsPage.swift @@ -167,15 +167,25 @@ private struct GeneralResetSection: View { var body: some View { Section { - LabeledContent { + // An HStack rather than LabeledContent, which would line the + // button up with the title alone instead of centering it on + // both lines. + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Reset Settings") + .font(.headline) + Text("Resets the settings on this page to their default values.") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + Button("Reset", role: .destructive) { model.resetToDefaults() } .keyboardShortcut(.delete, modifiers: [.command, .shift]) - } label: { - Text("Reset Settings") - .font(.headline) - Text("Resets the settings on this page to their default values.") } } header: { Text("Reset") diff --git a/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift b/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift index 97086f37..81d811b3 100644 --- a/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift +++ b/LookInside/Settings/PrivateDiscriminatorSettingsPage.swift @@ -56,31 +56,38 @@ private struct PrivateDiscriminatorDefaultLibrarySection: View { var body: some View { Section { - LabeledContent { - HStack(spacing: 8) { - if store.isUpdatingDefaultLibrary { - ProgressView() - .controlSize(.small) + // An HStack rather than LabeledContent, so the button stays + // centered on the row once the status adds a second line. + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(PrivateDiscriminatorStore.defaultLibraryModuleNames, format: .list(type: .and)) + + if let message = store.lastDefaultLibraryUpdateMessage { + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } + } - Button { - importError = nil - store.updateDefaultLibrary() - } label: { - if store.isUpdatingDefaultLibrary { - Label("Updating…", systemImage: "arrow.down.circle") - } else { - Label("Update Default Library", systemImage: "arrow.down.circle") - } - } - .disabled(store.isUpdatingDefaultLibrary) + Spacer() + + if store.isUpdatingDefaultLibrary { + ProgressView() + .controlSize(.small) } - } label: { - Text(PrivateDiscriminatorStore.defaultLibraryModuleNames, format: .list(type: .and)) - if let message = store.lastDefaultLibraryUpdateMessage { - Text(message) + Button { + importError = nil + store.updateDefaultLibrary() + } label: { + if store.isUpdatingDefaultLibrary { + Label("Updating…", systemImage: "arrow.down.circle") + } else { + Label("Update Default Library", systemImage: "arrow.down.circle") + } } + .disabled(store.isUpdatingDefaultLibrary) } } header: { Text("Default Library")