From 3890beb9d38d41e415b35ac4dab932fefa800dfa Mon Sep 17 00:00:00 2001 From: lwouis Date: Sat, 14 Feb 2026 20:47:32 +0100 Subject: [PATCH] feat: new settings experience (closes #5287) --- alt-tab-macos.xcodeproj/project.pbxproj | 14 +- resources/l10n/Localizable.strings | 38 +- src/logic/ATShortcut.swift | 4 +- src/logic/MacroPreferences.swift | 4 +- src/logic/Preferences.swift | 8 +- src/logic/events/TrackpadEvents.swift | 2 +- src/ui/App.swift | 55 +- src/ui/Menubar.swift | 6 +- .../ImageTextButtonView.swift | 14 +- .../generic-components/TableGroupView.swift | 94 +- src/ui/generic-components/TableView.swift | 35 +- src/ui/main-window/TilesView.swift | 2 +- .../PreferencesWindow.swift | 126 -- src/ui/preferences-window/tabs/AboutTab.swift | 50 - .../tabs/AcknowledgmentsTab.swift | 46 - .../LabelAndControl.swift | 14 + src/ui/settings-window/SettingsWindow.swift | 1330 +++++++++++++++++ src/ui/settings-window/tabs/AboutTab.swift | 124 ++ .../tabs/AcknowledgmentsTab.swift | 69 + .../tabs/BlacklistsTab.swift | 10 +- .../tabs/GeneralTab.swift | 8 +- .../tabs/PoliciesTab.swift | 7 +- .../tabs/appearance/AnimationsSheet.swift | 1 - .../tabs/appearance/AppearanceTab.swift | 125 +- .../tabs/appearance/CustomizeStyleSheet.swift | 9 +- .../controls/AdditionalControlsSheet.swift | 4 +- .../tabs/controls/ControlsTab.swift | 26 +- .../controls/ShortcutsWhenActiveSheet.swift | 0 28 files changed, 1841 insertions(+), 384 deletions(-) delete mode 100644 src/ui/preferences-window/PreferencesWindow.swift delete mode 100644 src/ui/preferences-window/tabs/AboutTab.swift delete mode 100644 src/ui/preferences-window/tabs/AcknowledgmentsTab.swift rename src/ui/{preferences-window => settings-window}/LabelAndControl.swift (96%) create mode 100644 src/ui/settings-window/SettingsWindow.swift create mode 100644 src/ui/settings-window/tabs/AboutTab.swift create mode 100644 src/ui/settings-window/tabs/AcknowledgmentsTab.swift rename src/ui/{preferences-window => settings-window}/tabs/BlacklistsTab.swift (72%) rename src/ui/{preferences-window => settings-window}/tabs/GeneralTab.swift (86%) rename src/ui/{preferences-window => settings-window}/tabs/PoliciesTab.swift (85%) rename src/ui/{preferences-window => settings-window}/tabs/appearance/AnimationsSheet.swift (98%) rename src/ui/{preferences-window => settings-window}/tabs/appearance/AppearanceTab.swift (80%) rename src/ui/{preferences-window => settings-window}/tabs/appearance/CustomizeStyleSheet.swift (94%) rename src/ui/{preferences-window => settings-window}/tabs/controls/AdditionalControlsSheet.swift (95%) rename src/ui/{preferences-window => settings-window}/tabs/controls/ControlsTab.swift (94%) rename src/ui/{preferences-window => settings-window}/tabs/controls/ShortcutsWhenActiveSheet.swift (100%) diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index 4821cd2d..05f82990 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -290,7 +290,7 @@ D04BAD451966B43720120D2E /* Menubar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD905546AA93E5117B0A /* Menubar.swift */; }; D04BADA3F2DAB820D6FC6E75 /* AppCenterApplication.m in Sources */ = {isa = PBXBuildFile; fileRef = D04BA48BC82D3060C3A1AB11 /* AppCenterApplication.m */; }; D04BADCDA9F9A6C3D6499877 /* SystemPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA7C6F2519091717F4B4E /* SystemPermissions.swift */; }; - D04BADE4DDF7597749FB13BD /* PreferencesWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA099FA155FB4D517D66B /* PreferencesWindow.swift */; }; + D04BADE4DDF7597749FB13BD /* SettingsWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA099FA155FB4D517D66B /* SettingsWindow.swift */; }; D04BAE8B16A06A10E2FA94DE /* AccessibilityEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA8FDCF137D892114F5F3 /* AccessibilityEvents.swift */; }; D04BAEE31B6FFCDC779E6C17 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = D04BAC2FF99F629CD4ED20FC /* MainMenu.xib */; }; D04BAF12DF5D15B9D7D316A4 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA61693F710CD7BD054D7 /* InfoPlist.strings */; }; @@ -578,7 +578,7 @@ D04BA075E359F36C27CD5BAE /* package-lock.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "package-lock.json"; sourceTree = ""; }; D04BA084F638725465234830 /* favicon-16x16.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "favicon-16x16.png"; sourceTree = ""; }; D04BA088B8A550D9367A4AAB /* it */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = it; path = InfoPlist.strings; sourceTree = ""; }; - D04BA099FA155FB4D517D66B /* PreferencesWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesWindow.swift; sourceTree = ""; }; + D04BA099FA155FB4D517D66B /* SettingsWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsWindow.swift; sourceTree = ""; }; D04BA0AAAE82C72855DBBA26 /* update_appcast.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = update_appcast.sh; sourceTree = ""; }; D04BA0CE87BE264C52987ED1 /* 7 windows - 2 lines - wide window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "7 windows - 2 lines - wide window.jpg"; sourceTree = ""; }; D04BA0E071D2EDFDB9A20523 /* Podfile */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Podfile; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; @@ -1468,7 +1468,7 @@ children = ( D04BAAB92261FC04854FDDE9 /* App.swift */, D04BA62E30174C336E4080FA /* main-window */, - D04BABCD12CA72B06290C248 /* preferences-window */, + D04BABCD12CA72B06290C248 /* settings-window */, D04BAD905546AA93E5117B0A /* Menubar.swift */, D04BA26C75F76C277653C932 /* FeedbackWindow.swift */, D04BA2CBF267F267225CA671 /* generic-components */, @@ -1886,14 +1886,14 @@ path = ru.lproj; sourceTree = ""; }; - D04BABCD12CA72B06290C248 /* preferences-window */ = { + D04BABCD12CA72B06290C248 /* settings-window */ = { isa = PBXGroup; children = ( D04BA2526DC6726E0F7ACF7C /* LabelAndControl.swift */, D04BA3FA52AA40B827A72F10 /* tabs */, - D04BA099FA155FB4D517D66B /* PreferencesWindow.swift */, + D04BA099FA155FB4D517D66B /* SettingsWindow.swift */, ); - path = "preferences-window"; + path = "settings-window"; sourceTree = ""; }; D04BAC2B70BC798C57B0B492 /* l10n */ = { @@ -2543,7 +2543,7 @@ D04BA56789A2AF52557A9BFF /* AppCenterCrashes.swift in Sources */, D04BADA3F2DAB820D6FC6E75 /* AppCenterApplication.m in Sources */, D04BAA16F21AC41A23DDA63D /* ControlsTab.swift in Sources */, - D04BADE4DDF7597749FB13BD /* PreferencesWindow.swift in Sources */, + D04BADE4DDF7597749FB13BD /* SettingsWindow.swift in Sources */, BF0C8CC5057406014FD612CC /* ATShortcut.swift in Sources */, BF0C8C7E96DB48120462DA00 /* TrafficLightButton.swift in Sources */, BF0C898511686611E4D7D81E /* Button.swift in Sources */, diff --git a/resources/l10n/Localizable.strings b/resources/l10n/Localizable.strings index e32b5401..dd409e31 100644 --- a/resources/l10n/Localizable.strings +++ b/resources/l10n/Localizable.strings @@ -1,15 +1,9 @@ -/* No comment provided by engineer. */ -"About" = "About"; - /* Menubar option. %@ is AltTab */ "About %@" = "About %@"; /* No comment provided by engineer. */ "Accessibility" = "Accessibility"; -/* No comment provided by engineer. */ -"Acknowledgments" = "Acknowledgments"; - /* No comment provided by engineer. */ "Active app" = "Active app"; @@ -26,7 +20,7 @@ "Advanced" = "Advanced"; /* No comment provided by engineer. */ -"After release" = "After release"; +"After keys are released" = "After keys are released"; /* No comment provided by engineer. */ "Align windows" = "Align windows"; @@ -109,6 +103,9 @@ /* No comment provided by engineer. */ "Are you sure you don’t want a response?" = "Are you sure you don’t want a response?"; +/* No comment provided by engineer. */ +"Arrow keys" = "Arrow keys"; + /* No comment provided by engineer. */ "Ask whether to send crash reports" = "Ask whether to send crash reports"; @@ -121,9 +118,6 @@ /* No comment provided by engineer. */ "Cancel" = "Cancel"; -/* No comment provided by engineer. */ -"Cancel and hide" = "Cancel and hide"; - /* No comment provided by engineer. */ "Center" = "Center"; @@ -169,9 +163,6 @@ /* No comment provided by engineer. */ "Disabled" = "Disabled"; -/* No comment provided by engineer. */ -"Do nothing" = "Do nothing"; - /* No comment provided by engineer. */ "Done" = "Done"; @@ -241,6 +232,12 @@ /* No comment provided by engineer. */ "Ignore shortcuts when active" = "Ignore shortcuts when active"; +/* No comment provided by engineer. */ +"Keep open" = "Keep open"; + +/* No comment provided by engineer. */ +"Keep open and search" = "Keep open and search"; + /* No comment provided by engineer. */ "Language" = "Language"; @@ -262,6 +259,9 @@ /* No comment provided by engineer. */ "Light" = "Light"; +/* No comment provided by engineer. */ +"Lock search" = "Lock search"; + /* No comment provided by engineer. */ "Medium" = "Medium"; @@ -325,12 +325,6 @@ /* No comment provided by engineer. */ "Preview selected window" = "Preview selected window"; -/* No comment provided by engineer. */ -"Preview the selected window." = "Preview the selected window."; - -/* No comment provided by engineer. */ -"Quit" = "Quit"; - /* Menubar option. %@ is AltTab */ "Quit %@" = "Quit %@"; @@ -367,6 +361,9 @@ /* No comment provided by engineer. */ "Screen showing AltTab" = "Screen showing AltTab"; +/* No comment provided by engineer. */ +"Search" = "Search"; + /* No comment provided by engineer. */ "Select next window" = "Select next window"; @@ -562,6 +559,9 @@ /* No comment provided by engineer. */ "View existing discussions" = "View existing discussions"; +/* No comment provided by engineer. */ +"Vim keys" = "Vim keys"; + /* No comment provided by engineer. */ "Vim keys already assigned to other actions:\n%@" = "Vim keys already assigned to other actions:\n%@"; diff --git a/src/logic/ATShortcut.swift b/src/logic/ATShortcut.swift index 1b2f0682..1ec68718 100644 --- a/src/logic/ATShortcut.swift +++ b/src/logic/ATShortcut.swift @@ -59,7 +59,7 @@ class ATShortcut { if triggerPhase == .down && (!App.app.appIsBeingUsed || index == nil || index == App.app.shortcutIndex) { return true } - if triggerPhase == .up && App.app.appIsBeingUsed && (index == nil || index == App.app.shortcutIndex) && !App.app.forceDoNothingOnRelease && Preferences.shortcutStyle[App.app.shortcutIndex] == .focusOnRelease { + if triggerPhase == .up && App.app.appIsBeingUsed && (index == nil || index == App.app.shortcutIndex) && !App.app.forceDoNothingOnRelease && Preferences.shortcutStyle == .focusOnRelease { return true } } @@ -85,7 +85,7 @@ class ATShortcut { // Another issue is events being dropped by macOS, which we never receive // Knowing this, we handle these edge-cases by double checking if holdShortcut is UP, when any shortcut state is UP // If it is, then we trigger the holdShortcut action - if App.app.appIsBeingUsed && !App.app.forceDoNothingOnRelease && Preferences.shortcutStyle[App.app.shortcutIndex] == .focusOnRelease { + if App.app.appIsBeingUsed && !App.app.forceDoNothingOnRelease && Preferences.shortcutStyle == .focusOnRelease { if let currentHoldShortcut = ControlsTab.shortcuts[Preferences.indexToName("holdShortcut", App.app.shortcutIndex)], id == currentHoldShortcut.id { let currentModifiers = cocoaToCarbonFlags(ModifierFlags.current) diff --git a/src/logic/MacroPreferences.swift b/src/logic/MacroPreferences.swift index aafc28bc..ef1ca1d9 100644 --- a/src/logic/MacroPreferences.swift +++ b/src/logic/MacroPreferences.swift @@ -225,8 +225,8 @@ enum ShortcutStylePreference: CaseIterable, MacroPreference { var localizedString: LocalizedString { switch self { case .focusOnRelease: return NSLocalizedString("Focus selected window", comment: "") - case .doNothingOnRelease: return NSLocalizedString("Do nothing", comment: "") - case .searchOnRelease: return NSLocalizedString("Search", comment: "") + case .doNothingOnRelease: return NSLocalizedString("Keep open", comment: "") + case .searchOnRelease: return NSLocalizedString("Keep open and search", comment: "") } } } diff --git a/src/logic/Preferences.swift b/src/logic/Preferences.swift index 81bd8a40..348df1b9 100644 --- a/src/logic/Preferences.swift +++ b/src/logic/Preferences.swift @@ -90,6 +90,7 @@ class Preferences { "previewFocusedWindow": "false", "screenRecordingPermissionSkipped": "false", "trackpadHapticFeedbackEnabled": "true", + "settingsWindowShownOnFirstLaunch": "false", ] // system preferences @@ -129,6 +130,7 @@ class Preferences { static var blacklist: [BlacklistEntry] { CachedUserDefaults.json("blacklist", [BlacklistEntry].self) } static var previewSelectedWindow: Bool { CachedUserDefaults.bool("previewFocusedWindow") } static var screenRecordingPermissionSkipped: Bool { CachedUserDefaults.bool("screenRecordingPermissionSkipped") } + static var settingsWindowShownOnFirstLaunch: Bool { CachedUserDefaults.bool("settingsWindowShownOnFirstLaunch") } // macro values static var appearanceStyle: AppearanceStylePreference { CachedUserDefaults.macroPref("appearanceStyle", AppearanceStylePreference.allCases) } @@ -151,7 +153,7 @@ class Preferences { static var showFullscreenWindows: [ShowHowPreference] { ["showFullscreenWindows", "showFullscreenWindows2", "showFullscreenWindows3", "showFullscreenWindows4"].map { CachedUserDefaults.macroPref($0, ShowHowPreference.allCases) } } static var showWindowlessApps: [ShowHowPreference] { ["showWindowlessApps", "showWindowlessApps2", "showWindowlessApps3", "showWindowlessApps4"].map { CachedUserDefaults.macroPref($0, ShowHowPreference.allCases) } } static var windowOrder: [WindowOrderPreference] { ["windowOrder", "windowOrder2", "windowOrder3", "windowOrder4"].map { CachedUserDefaults.macroPref($0, WindowOrderPreference.allCases) } } - static var shortcutStyle: [ShortcutStylePreference] { ["shortcutStyle", "shortcutStyle2", "shortcutStyle3", "shortcutStyle4"].map { CachedUserDefaults.macroPref($0, ShortcutStylePreference.allCases) } } + static var shortcutStyle: ShortcutStylePreference { CachedUserDefaults.macroPref("shortcutStyle", ShortcutStylePreference.allCases) } static var menubarIcon: MenubarIconPreference { CachedUserDefaults.macroPref("menubarIcon", MenubarIconPreference.allCases) } static var menubarIconShown: Bool { CachedUserDefaults.bool("menubarIconShown") } static var language: LanguagePreference { CachedUserDefaults.macroPref("language", LanguagePreference.allCases) } @@ -172,6 +174,10 @@ class Preferences { UserDefaults.standard.register(defaults: defaultValues) } + static func markSettingsWindowShownOnFirstLaunch() { + set("settingsWindowShownOnFirstLaunch", "true", false) + } + static func set(_ key: String, _ value: T, _ notify: Bool = true) where T: Encodable { UserDefaults.standard.set(key == "blacklist" ? jsonEncode(value) : value, forKey: key) CachedUserDefaults.cache.removeValue(forKey: key) diff --git a/src/logic/events/TrackpadEvents.swift b/src/logic/events/TrackpadEvents.swift index 8598316a..5b8f7359 100644 --- a/src/logic/events/TrackpadEvents.swift +++ b/src/logic/events/TrackpadEvents.swift @@ -77,7 +77,7 @@ class TrackpadEvents { private static func handleEventIfAppIsBeingUsed(_ fingersDown: Int, _ activeTouches: Set, _ requiredFingers: Int) { if fingersDown <= TriggerSwipeDetector.maxFingersDownDuringTrigger - requiredFingers { - if App.app.shortcutIndex == Preferences.gestureIndex && !App.app.forceDoNothingOnRelease && Preferences.shortcutStyle[App.app.shortcutIndex] == .focusOnRelease { + if App.app.shortcutIndex == Preferences.gestureIndex && !App.app.forceDoNothingOnRelease && Preferences.shortcutStyle == .focusOnRelease { DispatchQueue.main.async { App.app.focusTarget() } diff --git a/src/ui/App.swift b/src/ui/App.swift index bb628dd0..6d18cfb6 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -16,11 +16,13 @@ class App: AppCenterApplication { static let repository = "https://github.com/lwouis/alt-tab-macos" static let website = "https://alt-tab-macos.netlify.app" static let appIcon = CGImage.named("app.icns") + static var supportProjectAction: Selector { #selector(App.app.supportProject) } static var app: App! var isTerminating = false var thumbnailsPanel: TilesPanel! var previewPanel: PreviewPanel! - var preferencesWindow: PreferencesWindow! + var settingsWindow: SettingsWindow! + var aboutWindow: AboutWindow! var permissionsWindow: PermissionsWindow? var appIsBeingUsed = false var shortcutIndex = 0 @@ -91,7 +93,8 @@ class App: AppCenterApplication { } private func allSecondaryWindowsCanBecomeKey(_ canBecomeKey_: Bool) { - preferencesWindow?.canBecomeKey_ = canBecomeKey_ + settingsWindow?.canBecomeKey_ = canBecomeKey_ + aboutWindow?.canBecomeKey_ = canBecomeKey_ feedbackWindow?.canBecomeKey_ = canBecomeKey_ permissionsWindow?.canBecomeKey_ = canBecomeKey_ } @@ -158,8 +161,17 @@ class App: AppCenterApplication { showSecondaryWindow(getOrCreateFeedbackWindow()) } - @objc func showPreferencesWindow() { - showSecondaryWindow(getOrCreatePreferencesWindow()) + @objc func showSettingsWindow() { + showSecondaryWindow(getOrCreateSettingsWindow()) + if settingsWindow?.isVisible != true { + settingsWindow = SettingsWindow() + showSecondaryWindow(settingsWindow) + settingsWindow?.orderFrontRegardless() + } + } + + @objc func showAboutWindow() { + showSecondaryWindow(getOrCreateAboutWindow()) } func showSecondaryWindow(_ window: NSWindow?) { @@ -173,11 +185,18 @@ class App: AppCenterApplication { } } - private func getOrCreatePreferencesWindow() -> PreferencesWindow { - if preferencesWindow == nil { - preferencesWindow = PreferencesWindow() + private func getOrCreateSettingsWindow() -> SettingsWindow { + if settingsWindow == nil { + settingsWindow = SettingsWindow() } - return preferencesWindow + return settingsWindow + } + + private func getOrCreateAboutWindow() -> AboutWindow { + if aboutWindow == nil { + aboutWindow = AboutWindow() + } + return aboutWindow } private func getOrCreateFeedbackWindow() -> FeedbackWindow { @@ -194,6 +213,14 @@ class App: AppCenterApplication { return permissionsWindow! } + @discardableResult + private func showSettingsWindowOnFirstLaunchIfNeeded() -> Bool { + guard !Preferences.settingsWindowShownOnFirstLaunch else { return false } + showSettingsWindow() + Preferences.markSettingsWindowShownOnFirstLaunch() + return true + } + func showPermissionsWindow() { getOrCreatePermissionsWindow().show() } @@ -206,11 +233,6 @@ class App: AppCenterApplication { showUi(0) } - @objc func showAboutTab() { - getOrCreatePreferencesWindow().selectTab("about") - showPreferencesWindow() - } - func cycleSelection(_ direction: Direction, allowWrap: Bool = true) { if direction == .up || direction == .down { thumbnailsPanel.tilesView.navigateUpOrDown(direction, allowWrap: allowWrap) @@ -296,7 +318,7 @@ class App: AppCenterApplication { } isFirstSummon = false self.shortcutIndex = shortcutIndex - let shouldStartInSearchMode = Preferences.shortcutStyle[self.shortcutIndex] == .searchOnRelease + let shouldStartInSearchMode = Preferences.shortcutStyle == .searchOnRelease thumbnailsPanel.tilesView.startSearchSession(shouldStartInSearchMode) if shouldStartInSearchMode { forceDoNothingOnRelease = true @@ -393,13 +415,14 @@ extension App: NSApplicationDelegate { CliEvents.observe() Logger.info { "Finished launching AltTab" } BenchmarkRunner.startIfNeeded() + showSettingsWindowOnFirstLaunchIfNeeded() #if DEBUG -// self.showPreferencesWindow() +// self.showSettingsWindow() #endif } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - showPreferencesWindow() + showSettingsWindow() return true } diff --git a/src/ui/Menubar.swift b/src/ui/Menubar.swift index 9f2bb96b..14026f56 100644 --- a/src/ui/Menubar.swift +++ b/src/ui/Menubar.swift @@ -24,13 +24,13 @@ class Menubar { permissionCalloutMenuItems = [permissionCalloutMenuItem, calloutSeparator] addMenuItem(NSLocalizedString("Show", comment: "Menubar option"), #selector(App.app.showUiFromShortcut0), "", "eye") menu.addItem(NSMenuItem.separator()) - addMenuItem(NSLocalizedString("Settings…", comment: "Menubar option"), #selector(App.app.showPreferencesWindow), ",", "gear") + addMenuItem(NSLocalizedString("Settings…", comment: "Menubar option"), #selector(App.app.showSettingsWindow), ",", "gear") addMenuItem(NSLocalizedString("Check for updates…", comment: "Menubar option"), #selector(App.app.checkForUpdatesNow), "", "checkmark.arrow.trianglehead.clockwise") addMenuItem(NSLocalizedString("Check permissions…", comment: "Menubar option"), #selector(App.app.checkPermissions), "", "hand.raised") menu.addItem(NSMenuItem.separator()) - addMenuItem(String(format: NSLocalizedString("About %@", comment: "Menubar option. %@ is AltTab"), App.name), #selector(App.app.showAboutTab), "", "info.circle") + addMenuItem(String(format: NSLocalizedString("About %@", comment: "Menubar option. %@ is AltTab"), App.name), #selector(App.app.showAboutWindow), "", "info.circle") addMenuItem(NSLocalizedString("Send feedback…", comment: "Menubar option"), #selector(App.app.showFeedbackPanel), "", "text.bubble") - addMenuItem(NSLocalizedString("Support this project", comment: "Menubar option"), #selector(App.app.supportProject), "", "heart.fill", .red) + addMenuItem(NSLocalizedString("Support this project", comment: "Menubar option"), App.supportProjectAction, "", "heart.fill", .red) menu.addItem(NSMenuItem.separator()) addMenuItem(String(format: NSLocalizedString("Quit %@", comment: "Menubar option. %@ is AltTab"), App.name), #selector(NSApplication.terminate(_:)), "q", nil) // "xmark.rectangle" is not necessary; macos automatically recognizes Quit statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) diff --git a/src/ui/generic-components/ImageTextButtonView.swift b/src/ui/generic-components/ImageTextButtonView.swift index 99d892d7..e54568dc 100644 --- a/src/ui/generic-components/ImageTextButtonView.swift +++ b/src/ui/generic-components/ImageTextButtonView.swift @@ -26,7 +26,7 @@ class ImageTextButtonView: NSStackView { static let spacing = CGFloat(5) static let cornerRadius = CGFloat(7) static let borderWidth = CGFloat(3) - static let padding = CGFloat(2) + static let padding = CGFloat(0) var onClick: ActionClosure? var button: NSButton! @@ -81,22 +81,22 @@ class ImageTextButtonView: NSStackView { imageView.layer?.cornerRadius = cornerRadius - 3 imageContainer.addSubview(imageView) NSLayoutConstraint.activate([ - imageView.widthAnchor.constraint(equalToConstant: image.width), - imageView.heightAnchor.constraint(equalToConstant: image.height), imageView.topAnchor.constraint(equalTo: imageContainer.topAnchor, constant: padding), imageView.bottomAnchor.constraint(equalTo: imageContainer.bottomAnchor, constant: -padding), imageView.leadingAnchor.constraint(equalTo: imageContainer.leadingAnchor, constant: padding), imageView.trailingAnchor.constraint(equalTo: imageContainer.trailingAnchor, constant: -padding), ]) button.addSubview(imageContainer) + let imageAspectRatio = image.height / image.width NSLayoutConstraint.activate([ - imageContainer.centerXAnchor.constraint(equalTo: button.centerXAnchor), - imageContainer.centerYAnchor.constraint(equalTo: button.centerYAnchor), - imageContainer.widthAnchor.constraint(equalTo: button.widthAnchor), - imageContainer.heightAnchor.constraint(equalTo: button.heightAnchor), + imageContainer.topAnchor.constraint(equalTo: button.topAnchor), + imageContainer.bottomAnchor.constraint(equalTo: button.bottomAnchor), + imageContainer.leadingAnchor.constraint(equalTo: button.leadingAnchor), + imageContainer.trailingAnchor.constraint(equalTo: button.trailingAnchor), button.topAnchor.constraint(equalTo: topAnchor, constant: ImageTextButtonView.borderWidth), button.leadingAnchor.constraint(equalTo: leadingAnchor, constant: ImageTextButtonView.borderWidth), button.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -ImageTextButtonView.borderWidth), + button.heightAnchor.constraint(equalTo: button.widthAnchor, multiplier: imageAspectRatio), ]) button.identifier = NSUserInterfaceItemIdentifier(rawName) button.onAction = { control in diff --git a/src/ui/generic-components/TableGroupView.swift b/src/ui/generic-components/TableGroupView.swift index 42050b53..87108a90 100644 --- a/src/ui/generic-components/TableGroupView.swift +++ b/src/ui/generic-components/TableGroupView.swift @@ -7,7 +7,7 @@ class TableGroupSetView: NSStackView { static let spacing = CGFloat(20) static let tableGroupSpacing = CGFloat(5) static let othersSpacing = CGFloat(10) - static let titleTableGroupSpacing = CGFloat(30) + static let titleTableGroupSpacing = CGFloat(20) static let padding = CGFloat(20) static let leftRightPadding = 2 * TableGroupSetView.padding @@ -18,11 +18,16 @@ class TableGroupSetView: NSStackView { toolsViews: [NSView]? = nil, spacing: CGFloat = TableGroupSetView.spacing, tableGroupSpacing: CGFloat = TableGroupSetView.tableGroupSpacing, + titleTableGroupSpacing: CGFloat = TableGroupSetView.titleTableGroupSpacing, othersSpacing: CGFloat = TableGroupSetView.othersSpacing, padding: CGFloat = TableGroupSetView.padding, + topPadding: CGFloat? = nil, + bottomPadding: CGFloat? = nil, othersAlignment: NSLayoutConstraint.Attribute = .trailing, toolsAlignment: NSLayoutConstraint.Attribute = .centerX) { self.init(frame: .zero) + let resolvedTopPadding = topPadding ?? padding + let resolvedBottomPadding = bottomPadding ?? padding self.spacing = spacing orientation = .vertical self.originalViews = originalViews @@ -33,39 +38,71 @@ class TableGroupSetView: NSStackView { if view is TableGroupView { if !lastViewWasTableGroup { // Only reset other views if we are switching from non-TableGroupView to TableGroupView - addContinuousOthersToSetViews(&continuousOthers, views: &verticalViews, othersSpacing: othersSpacing, padding: padding, alignment: othersAlignment) + addContinuousOthersToSetViews(&continuousOthers, + views: &verticalViews, + othersSpacing: othersSpacing, + horizontalPadding: padding, + topPadding: resolvedTopPadding, + alignment: othersAlignment) } continuousTableGroups.append(view) lastViewWasTableGroup = true } else if view is IllustratedImageThemeView { lastViewWasTableGroup = false - addContinuousTableGroupsToSetViews(&continuousTableGroups, views: &verticalViews, tableGroupSpacing: tableGroupSpacing, padding: padding) - addContinuousOthersToSetViews(&continuousOthers, views: &verticalViews, othersSpacing: othersSpacing, padding: padding, alignment: othersAlignment) - addToolsViewToSetViews([view], views: &verticalViews, padding: padding, alignment: toolsAlignment) + addContinuousTableGroupsToSetViews(&continuousTableGroups, + views: &verticalViews, + tableGroupSpacing: tableGroupSpacing, + titleTableGroupSpacing: titleTableGroupSpacing, + horizontalPadding: padding, + topPadding: resolvedTopPadding) + addContinuousOthersToSetViews(&continuousOthers, + views: &verticalViews, + othersSpacing: othersSpacing, + horizontalPadding: padding, + topPadding: resolvedTopPadding, + alignment: othersAlignment) + addToolsViewToSetViews([view], views: &verticalViews, horizontalPadding: padding, topPadding: resolvedTopPadding, alignment: toolsAlignment) } else { if lastViewWasTableGroup { // Only reset table group views if we are switching from TableGroupView to non-TableGroupView - addContinuousTableGroupsToSetViews(&continuousTableGroups, views: &verticalViews, tableGroupSpacing: tableGroupSpacing, padding: padding) + addContinuousTableGroupsToSetViews(&continuousTableGroups, + views: &verticalViews, + tableGroupSpacing: tableGroupSpacing, + titleTableGroupSpacing: titleTableGroupSpacing, + horizontalPadding: padding, + topPadding: resolvedTopPadding) } continuousOthers.append(view) lastViewWasTableGroup = false } } // Ensure any remaining views are added - addContinuousTableGroupsToSetViews(&continuousTableGroups, views: &verticalViews, tableGroupSpacing: tableGroupSpacing, padding: padding) - addContinuousOthersToSetViews(&continuousOthers, views: &verticalViews, othersSpacing: othersSpacing, padding: padding, alignment: othersAlignment) + addContinuousTableGroupsToSetViews(&continuousTableGroups, + views: &verticalViews, + tableGroupSpacing: tableGroupSpacing, + titleTableGroupSpacing: titleTableGroupSpacing, + horizontalPadding: padding, + topPadding: resolvedTopPadding) + addContinuousOthersToSetViews(&continuousOthers, + views: &verticalViews, + othersSpacing: othersSpacing, + horizontalPadding: padding, + topPadding: resolvedTopPadding, + alignment: othersAlignment) if let toolsViews { - addToolsViewToSetViews(toolsViews, views: &verticalViews, padding: padding, alignment: toolsAlignment) + addToolsViewToSetViews(toolsViews, views: &verticalViews, horizontalPadding: padding, topPadding: resolvedTopPadding, alignment: toolsAlignment) } if let lastStackView = verticalViews.last { - lastStackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -padding).isActive = true + lastStackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -resolvedBottomPadding).isActive = true } } func addContinuousTableGroupsToSetViews(_ continuousTableGroups: inout [NSView], views: inout [NSView], tableGroupSpacing: CGFloat, - padding: CGFloat) { + titleTableGroupSpacing: CGFloat, + horizontalPadding: CGFloat, + topPadding: CGFloat) { if !continuousTableGroups.isEmpty { let stackView = NSStackView() stackView.orientation = .vertical @@ -76,10 +113,10 @@ class TableGroupSetView: NSStackView { var spacerHeight = CGFloat(0) if index == 0 && !views.isEmpty { // at first, height: spacing + padding - spacerHeight = TableGroupSetView.titleTableGroupSpacing - spacing + spacerHeight = titleTableGroupSpacing - spacing } else if index > 0 { // inner, height: tableGroupSpacing + padding - spacerHeight = TableGroupSetView.titleTableGroupSpacing + spacerHeight = titleTableGroupSpacing } let spacerView = NSView() spacerView.translatesAutoresizingMaskIntoConstraints = false @@ -92,12 +129,17 @@ class TableGroupSetView: NSStackView { } continuousTableGroups.removeAll() addArrangedSubview(stackView) - setStackViewConstraints(stackView, isFirst: views.isEmpty, padding: padding) + setStackViewConstraints(stackView, isFirst: views.isEmpty, horizontalPadding: horizontalPadding, topPadding: topPadding) views.append(stackView) } } - func addContinuousOthersToSetViews(_ continuousOthers: inout [NSView], views: inout [NSView], othersSpacing: CGFloat, padding: CGFloat, alignment: NSLayoutConstraint.Attribute) { + func addContinuousOthersToSetViews(_ continuousOthers: inout [NSView], + views: inout [NSView], + othersSpacing: CGFloat, + horizontalPadding: CGFloat, + topPadding: CGFloat, + alignment: NSLayoutConstraint.Attribute) { if !continuousOthers.isEmpty { let stackView = NSStackView() stackView.orientation = .horizontal @@ -106,12 +148,16 @@ class TableGroupSetView: NSStackView { stackView.setViews(continuousOthers, in: .leading) continuousOthers.removeAll() addArrangedSubview(stackView) - setStackViewConstraints(stackView, isFirst: views.isEmpty, padding: padding) + setStackViewConstraints(stackView, isFirst: views.isEmpty, horizontalPadding: horizontalPadding, topPadding: topPadding) views.append(stackView) } } - func addToolsViewToSetViews(_ originalViews: [NSView], views: inout [NSView], padding: CGFloat, alignment: NSLayoutConstraint.Attribute) { + func addToolsViewToSetViews(_ originalViews: [NSView], + views: inout [NSView], + horizontalPadding: CGFloat, + topPadding: CGFloat, + alignment: NSLayoutConstraint.Attribute) { if !originalViews.isEmpty { let stackView = NSStackView() stackView.orientation = .horizontal @@ -119,18 +165,18 @@ class TableGroupSetView: NSStackView { stackView.alignment = alignment stackView.setViews(originalViews, in: .leading) addArrangedSubview(stackView) - setStackViewConstraints(stackView, isFirst: views.isEmpty, padding: padding) + setStackViewConstraints(stackView, isFirst: views.isEmpty, horizontalPadding: horizontalPadding, topPadding: topPadding) views.append(stackView) } } - func setStackViewConstraints(_ stackView: NSStackView, isFirst: Bool, padding: CGFloat) { + func setStackViewConstraints(_ stackView: NSStackView, isFirst: Bool, horizontalPadding: CGFloat, topPadding: CGFloat) { stackView.translatesAutoresizingMaskIntoConstraints = false if isFirst { - stackView.topAnchor.constraint(equalTo: topAnchor, constant: padding).isActive = true + stackView.topAnchor.constraint(equalTo: topAnchor, constant: topPadding).isActive = true } - stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding).isActive = true - stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -padding).isActive = true + stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalPadding).isActive = true + stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalPadding).isActive = true } } @@ -230,7 +276,7 @@ class TableGroupView: ClickHoverStackView { titleStackView.spacing = TableGroupView.rowIntraSpacing if let title { titleLabel.stringValue = title - titleLabel.font = NSFont.boldSystemFont(ofSize: 13) + titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium) titleLabel.alignment = .natural titleLabel.lineBreakMode = .byWordWrapping titleLabel.maximumNumberOfLines = 0 @@ -396,7 +442,7 @@ class TableGroupView: ClickHoverStackView { static func makeText(_ leftText: String?, bold: Bool = false) -> NSTextField { let leftLabel = NSTextField(labelWithString: leftText ?? "") if bold { - leftLabel.font = NSFont.boldSystemFont(ofSize: 13) + leftLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium) } leftLabel.alignment = .left leftLabel.lineBreakMode = .byWordWrapping diff --git a/src/ui/generic-components/TableView.swift b/src/ui/generic-components/TableView.swift index 1a474123..93c5333c 100644 --- a/src/ui/generic-components/TableView.swift +++ b/src/ui/generic-components/TableView.swift @@ -1,14 +1,45 @@ import Cocoa class BlacklistView: NSScrollView { - convenience init() { + override class var isCompatibleWithResponsiveScrolling: Bool { true } + + convenience init(width: CGFloat = 500, height: CGFloat = 378) { self.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false borderType = .bezelBorder hasHorizontalScroller = false hasVerticalScroller = true + verticalScrollElasticity = .none + usesPredominantAxisScrolling = true documentView = TableView(nil) - fit(500, 378) + fit(width, height) + } + + override func scrollWheel(with event: NSEvent) { + guard shouldHandleVerticalScroll(event) else { + super.scrollWheel(with: event) + return + } + let y = contentView.bounds.origin.y + super.scrollWheel(with: event) + if abs(contentView.bounds.origin.y - y) < 0.5 { + parentScrollView()?.scrollWheel(with: event) + } + } + + private func shouldHandleVerticalScroll(_ event: NSEvent) -> Bool { + abs(event.scrollingDeltaY) > abs(event.scrollingDeltaX) && abs(event.scrollingDeltaY) > 0.1 + } + + private func parentScrollView() -> NSScrollView? { + var parent = superview + while let view = parent { + if let scrollView = view as? NSScrollView { + return scrollView + } + parent = view.superview + } + return nil } } diff --git a/src/ui/main-window/TilesView.swift b/src/ui/main-window/TilesView.swift index ffa10b9f..ff237271 100644 --- a/src/ui/main-window/TilesView.swift +++ b/src/ui/main-window/TilesView.swift @@ -125,7 +125,7 @@ class TilesView: NSObject { } private func configureSearchField() { - searchField.placeholderString = NSLocalizedString("Search windows", comment: "") + searchField.placeholderString = NSLocalizedString("Search", comment: "") searchField.sendsSearchStringImmediately = true searchField.sendsWholeSearchString = true searchField.bezelStyle = .roundedBezel diff --git a/src/ui/preferences-window/PreferencesWindow.swift b/src/ui/preferences-window/PreferencesWindow.swift deleted file mode 100644 index a16e18e8..00000000 --- a/src/ui/preferences-window/PreferencesWindow.swift +++ /dev/null @@ -1,126 +0,0 @@ -import Cocoa - -class PreferencesWindow: NSWindow { - static let width = CGFloat(520) - var toolbarItems = [NSToolbarItem.Identifier: (Int, NSToolbarItem, NSView)]() - var canBecomeKey_ = true - override var canBecomeKey: Bool { canBecomeKey_ } - var largestTabWidth: CGFloat! - - convenience init() { - self.init(contentRect: .zero, styleMask: [.titled, .miniaturizable, .closable], backing: .buffered, defer: false) - setupWindow() - setupView() - } - - private func setupWindow() { - isReleasedWhenClosed = false - hidesOnDeactivate = false - addQuitButton() - } - - private func addQuitButton() { - let quitButton = NSButton(title: NSLocalizedString("Quit", comment: ""), target: nil, action: #selector(NSApplication.terminate(_:))) - let titleBarView = standardWindowButton(.closeButton)!.superview! - titleBarView.addSubview(quitButton) - quitButton.translatesAutoresizingMaskIntoConstraints = false - quitButton.topAnchor.constraint(equalTo: titleBarView.topAnchor, constant: 5).isActive = true - if windowTitlebarLayoutDirection == .rightToLeft { - quitButton.leftAnchor.constraint(equalTo: titleBarView.leftAnchor, constant: 8).isActive = true - } else { - quitButton.rightAnchor.constraint(equalTo: titleBarView.rightAnchor, constant: -8).isActive = true - } - } - - private func setupView() { - toolbar = NSToolbar(identifier: "1") - toolbar!.delegate = self - // toolbar breaks with the new default style on macOS 11; we force the classic style (see #914) - if #available(macOS 11.0, *) { toolbarStyle = .preference } - toolbar!.displayMode = .iconAndLabel - toolbar!.showsBaselineSeparator = true - [ - (0, NSLocalizedString("General", comment: ""), "general", "switch.2", GeneralTab.initTab()), - (1, NSLocalizedString("Controls", comment: ""), "controls", "command", ControlsTab.initTab()), - (2, NSLocalizedString("Appearance", comment: ""), "appearance", "paintpalette", AppearanceTab.initTab()), - (3, NSLocalizedString("Policies", comment: ""), "policies", "antenna.radiowaves.left.and.right", PoliciesTab.initTab()), - (4, NSLocalizedString("Blacklists", comment: ""), "blacklists", "hand.raised", BlacklistsTab.initTab()), - (5, NSLocalizedString("About", comment: ""), "about", "info.circle", AboutTab.initTab()), - (6, NSLocalizedString("Acknowledgments", comment: ""), "acknowledgments", "hand.thumbsup", AcknowledgmentsTab.initTab()), - ].forEach { makeToolbarItem($0.0, $0.1, $0.2, $0.3, $0.4) } - largestTabWidth = Array(toolbarItems.values).reduce(CGFloat(0)) { max($0, $1.2.subviews[0].fittingSize.width) } - Array(toolbarItems.values).forEach { - $0.2.fit(largestTabWidth, $0.2.subviews[0].fittingSize.height) - } - refreshControlsFromPreferences() - selectTab("general") - } - - private func refreshControlsFromPreferences() { - GeneralTab.refreshControlsFromPreferences() - PoliciesTab.refreshControlsFromPreferences() - } - - func selectTab(_ id: String) { - toolbar!.selectedItemIdentifier = NSToolbarItem.Identifier(rawValue: id) - tabItemClicked(toolbarItems[toolbar!.selectedItemIdentifier!]!.1) - } - - func makeToolbarItem(_ index: Int, _ label: String, _ id: String, _ image: String, _ view: NSView) { - let identifier = NSToolbarItem.Identifier(rawValue: id) - let item = NSToolbarItem(itemIdentifier: identifier) - item.label = label - if #available(macOS 11.0, *) { - // each systemSymbolName has a different minimum macOS requirements; we need to make sure it exists - if let image = NSImage(systemSymbolName: image, accessibilityDescription: nil) { - item.image = image - } - } - // it seems that new instances of NSToolbarItems have their image set to a 1x1 image - if item.image == nil || item.image!.size == NSSize(width: 1, height: 1) { - item.image = NSImage.initCopy(id) - item.image!.isTemplate = true - item.maxSize = NSSize(width: 22, height: 22) - } - item.target = self - item.action = #selector(tabItemClicked) - let wrapView = NSView(frame: .zero) - wrapView.translatesAutoresizingMaskIntoConstraints = false - wrapView.subviews = [view] - view.centerXAnchor.constraint(equalTo: wrapView.centerXAnchor).isActive = true - toolbarItems[identifier] = (index, item, wrapView) - toolbar!.insertItem(withItemIdentifier: identifier, at: index) - } - - @objc func tabItemClicked(_ item: NSToolbarItem) { - let item = toolbarItems[item.itemIdentifier]! - contentView = item.2 - title = item.1.label - // Reset focused ring - makeFirstResponder(contentView?.subviews[0]) - recalculateKeyViewLoop() - } - - override func close() { - hideAppIfLastWindowIsClosed() - super.close() - } -} - -extension PreferencesWindow: NSToolbarDelegate { - func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { - return toolbarItems[itemIdentifier]!.1 - } - - func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - return Array(toolbarItems.keys) - } - - func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - return Array(toolbarItems.keys) - } - - func toolbarSelectableItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - return Array(toolbarItems.keys) - } -} diff --git a/src/ui/preferences-window/tabs/AboutTab.swift b/src/ui/preferences-window/tabs/AboutTab.swift deleted file mode 100644 index 6a01c91d..00000000 --- a/src/ui/preferences-window/tabs/AboutTab.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Cocoa - -class AboutTab { - static func initTab() -> NSView { - let appIcon = LightImageView() - appIcon.translatesAutoresizingMaskIntoConstraints = false - appIcon.updateContents(.cgImage(App.appIcon), NSSize(width: 256, height: 256)) - appIcon.fit(256, 256) - let appText = StackView([ - BoldLabel(App.name), - NSTextField(wrappingLabelWithString: NSLocalizedString("Version", comment: "") + " " + App.version), - NSTextField(wrappingLabelWithString: App.licence), - HyperlinkLabel(NSLocalizedString("Source code repository", comment: ""), App.repository), - HyperlinkLabel(NSLocalizedString("Latest releases", comment: ""), App.repository + "/releases"), - ], .vertical) - appText.spacing = GridView.interPadding / 2 - let rowToSeparate = 3 - appText.views[rowToSeparate].topAnchor.constraint(equalTo: appText.views[rowToSeparate - 1].bottomAnchor, constant: GridView.interPadding).isActive = true - let appInfo = NSStackView(views: [appIcon, appText]) - appIcon.translatesAutoresizingMaskIntoConstraints = false - appInfo.spacing = GridView.interPadding - appInfo.alignment = .centerY - let sendFeedback = makeButtonWithIcon(NSLocalizedString("Send feedback…", comment: ""), #selector(App.app.showFeedbackPanel), "text.bubble") - let supportProject = makeButtonWithIcon(NSLocalizedString("Support this project", comment: ""), #selector(App.app.supportProject), "heart.fill", .red) - let grid = GridView([ - [appInfo], - [sendFeedback], - [supportProject], - ]) - let sendFeedbackCell = grid.cell(atColumnIndex: 0, rowIndex: 1) - sendFeedbackCell.xPlacement = .center - sendFeedbackCell.row!.topPadding = GridView.interPadding - let supportProjectCell = grid.cell(atColumnIndex: 0, rowIndex: 2) - supportProjectCell.xPlacement = .center - grid.fit() - return grid - } - - private static func makeButtonWithIcon(_ title: String, _ selector: Selector, _ symbolName: String?, _ color: NSColor? = nil) -> NSButton { - let button = NSButton(title: title, target: nil, action: selector) - if #available(macOS 26.0, *), let symbolName { - button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) - button.imagePosition = .imageLeading - if let color { - button.image = button.image?.withSymbolConfiguration(.init(paletteColors: [color])) - } - } - return button - } -} diff --git a/src/ui/preferences-window/tabs/AcknowledgmentsTab.swift b/src/ui/preferences-window/tabs/AcknowledgmentsTab.swift deleted file mode 100644 index 1c53e94b..00000000 --- a/src/ui/preferences-window/tabs/AcknowledgmentsTab.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Cocoa - -class AcknowledgmentsTab { - // going taller than this will crop the view on small screens or low scaling modes on retina displays - static let maxTabHeight = CGFloat(450) - - static func initTab() -> NSView { - let textViews: [NSTextView] = ["Contributors", "Acknowledgments"].map { - let markdownFileUrl = Bundle.main.url(forResource: $0, withExtension: "md")! - var content = try! String(contentsOf: markdownFileUrl, encoding: .utf8) - if content.last == "\n" { - content.removeLast() - } - let attributedString = Markdown.toAttributedString(content) - let textView = NSTextView() - textView.textContainer!.widthTracksTextView = true - textView.translatesAutoresizingMaskIntoConstraints = false - textView.drawsBackground = false - textView.isSelectable = true - textView.isEditable = false - textView.enabledTextCheckingTypes = 0 - textView.frame.size.width = 240 - textView.textStorage!.setAttributedString(attributedString) - textView.layoutManager!.ensureLayout(for: textView.textContainer!) - textView.frame = textView.layoutManager!.usedRect(for: textView.textContainer!) - textView.fit(textView.frame.width, textView.frame.height) - return textView - } - let subGrid = GridView([textViews]) - subGrid.fit() - let scrollView = NSScrollView() - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.drawsBackground = false - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = false - scrollView.documentView = FlippedView(frame: .zero) - scrollView.documentView!.translatesAutoresizingMaskIntoConstraints = false - scrollView.documentView!.subviews = [subGrid] - let totalWidth = subGrid.fittingSize.width - scrollView.frame.size = NSSize(width: totalWidth, height: maxTabHeight) - scrollView.contentView.frame.size = scrollView.frame.size - scrollView.documentView!.frame.size = subGrid.fittingSize - scrollView.fit(totalWidth, maxTabHeight) - return scrollView - } -} diff --git a/src/ui/preferences-window/LabelAndControl.swift b/src/ui/settings-window/LabelAndControl.swift similarity index 96% rename from src/ui/preferences-window/LabelAndControl.swift rename to src/ui/settings-window/LabelAndControl.swift index 46ce9c88..9211af57 100644 --- a/src/ui/preferences-window/LabelAndControl.swift +++ b/src/ui/settings-window/LabelAndControl.swift @@ -34,6 +34,7 @@ class MouseHoverView: NSView { class ClickHoverImageView: MouseHoverView { var infoCircle: NSView! var onClick: EventClosure? + var searchableStrings = [String]() init(infoCircle: NSView) { super.init(frame: .zero) @@ -92,9 +93,16 @@ class LabelAndControl: NSObject { } let view = NSStackView(views: buttonViews) view.orientation = .horizontal + view.distribution = .fillEqually view.spacing = buttonSpacing view.alignment = .centerY view.translatesAutoresizingMaskIntoConstraints = false + view.setContentHuggingPriority(.defaultLow, for: .horizontal) + view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + buttonViews.forEach { + $0.setContentHuggingPriority(.defaultLow, for: .horizontal) + $0.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } return view } @@ -130,12 +138,18 @@ class LabelAndControl: NSObject { } static func makeInfoButton(size: CGFloat = 16, + searchableTooltipTexts: [String] = [], onClick: EventClosure? = nil, onMouseEntered: EventClosure? = nil, onMouseExited: EventClosure? = nil) -> ClickHoverImageView { let imageView = TileFontIconView(symbol: .circledInfo, size: size, color: .labelColor) imageView.translatesAutoresizingMaskIntoConstraints = false let view = ClickHoverImageView(infoCircle: imageView) + view.searchableStrings = Array(Set(searchableTooltipTexts.map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + }.filter { + !$0.isEmpty + })) view.onClick = onClick view.onMouseEntered = onMouseEntered view.onMouseExited = onMouseExited diff --git a/src/ui/settings-window/SettingsWindow.swift b/src/ui/settings-window/SettingsWindow.swift new file mode 100644 index 00000000..d55e4d31 --- /dev/null +++ b/src/ui/settings-window/SettingsWindow.swift @@ -0,0 +1,1330 @@ +import Cocoa + +private struct SettingsSearchToken { + let normalized: [Character] + let normalizedToOriginal: [Int] +} + +private struct SettingsSearchResult { + let score: Double + let ranges: [Range] +} + +private enum SettingsSearch { + private struct TokenMatch { + let score: Double + let ranges: [Range] + } + + static func isQueryEmpty(_ query: String) -> Bool { + tokens(query).flatMap { $0.normalized }.isEmpty + } + + static func match(_ query: String, in text: String) -> SettingsSearchResult? { + let mergeAcrossSeparators = hasInterTermSeparator(query) + let queryTokens = tokens(query).map(\.normalized) + guard !queryTokens.isEmpty else { return nil } + let textTokens = tokens(text) + guard !textTokens.isEmpty else { return nil } + var tokenScores = [Double]() + var matchedRanges = [Range]() + for queryToken in queryTokens { + guard let bestMatch = bestMatch(for: queryToken, in: textTokens) else { return nil } + tokenScores.append(bestMatch.score) + matchedRanges.append(contentsOf: bestMatch.ranges) + } + let averageScore = tokenScores.reduce(0, +) / Double(tokenScores.count) + guard averageScore >= minimumScore(queryTokens.map { $0.count }.max() ?? 0) else { return nil } + if mergeAcrossSeparators && matchedRanges.count > 1 { + return SettingsSearchResult(score: averageScore, ranges: mergeRangesAcrossSeparators(matchedRanges, in: Array(text))) + } + return SettingsSearchResult(score: averageScore, ranges: mergeRanges(matchedRanges)) + } + + private static func bestMatch(for queryToken: [Character], in textTokens: [SettingsSearchToken]) -> TokenMatch? { + var best: TokenMatch? + textTokens.forEach { token in + guard let candidate = scoreTokenMatch(queryToken, token) else { return } + if best == nil || candidate.score > best!.score { + best = candidate + } + } + return best + } + + private static func scoreTokenMatch(_ query: [Character], _ token: SettingsSearchToken) -> TokenMatch? { + let tokenChars = token.normalized + let queryLength = query.count + let tokenLength = tokenChars.count + guard queryLength > 0, tokenLength > 0 else { return nil } + if queryLength <= 2 { + guard let exactRange = firstExactRange(of: query, in: tokenChars) else { return nil } + return TokenMatch(score: 1, ranges: [originalRange(from: exactRange, using: token)]) + } + let maxLength = max(queryLength, tokenLength) + let minLength = min(queryLength, tokenLength) + let distance = damerauLevenshteinDistance(query, tokenChars) + let distanceScore = 1 - Double(distance) / Double(maxLength) + let prefixLength = commonPrefixLength(query, tokenChars) + let prefixScore = Double(prefixLength) / Double(minLength) + let lcsIndexes = lcsTokenIndexes(query, tokenChars) + let coverageScore = Double(lcsIndexes.count) / Double(queryLength) + var score = distanceScore * 0.64 + prefixScore * 0.23 + coverageScore * 0.13 + if tokenChars.starts(with: query) { + score = max(score, 0.92 - Double(max(0, tokenLength - queryLength)) * 0.015) + } + if let exactRange = firstExactRange(of: query, in: tokenChars) { + score = max(score, 0.86 - Double(max(0, tokenLength - queryLength)) * 0.01) + guard score >= minimumScore(queryLength) else { return nil } + return TokenMatch(score: score, ranges: [originalRange(from: exactRange, using: token)]) + } + guard lcsIndexes.count >= minimumLcsLength(queryLength) else { return nil } + guard score >= minimumScore(queryLength) else { return nil } + let ranges = originalRanges(from: lcsIndexes, using: token) + guard !ranges.isEmpty else { return nil } + return TokenMatch(score: score, ranges: ranges) + } + + private static func minimumScore(_ queryLength: Int) -> Double { + switch queryLength { + case 0...2: return 1 + case 3: return 0.74 + case 4: return 0.68 + case 5: return 0.64 + case 6...7: return 0.60 + default: return 0.56 + } + } + + private static func minimumLcsLength(_ queryLength: Int) -> Int { + if queryLength <= 2 { return queryLength } + if queryLength == 3 { return 2 } + if queryLength <= 5 { return Int(ceil(Double(queryLength) * 0.6)) } + return Int(ceil(Double(queryLength) * 0.55)) + } + + private static func mergeRanges(_ ranges: [Range]) -> [Range] { + mergeRanges(ranges) { _, _ in false } + } + + private static func mergeRangesAcrossSeparators(_ ranges: [Range], in textCharacters: [Character]) -> [Range] { + mergeRanges(ranges) { + onlySeparatorsBetween($0, $1, in: textCharacters) + } + } + + private static func mergeRanges(_ ranges: [Range], _ shouldMergeGap: (Int, Int) -> Bool) -> [Range] { + if ranges.isEmpty { return [] } + let sorted = ranges.sorted { + if $0.lowerBound == $1.lowerBound { + return $0.upperBound < $1.upperBound + } + return $0.lowerBound < $1.lowerBound + } + var merged = [sorted[0]] + sorted.dropFirst().forEach { range in + let lastIndex = merged.count - 1 + let lastRange = merged[lastIndex] + if range.lowerBound <= lastRange.upperBound || shouldMergeGap(lastRange.upperBound, range.lowerBound) { + merged[lastIndex] = lastRange.lowerBound.. Bool { + guard start < end else { return true } + guard start >= 0, end <= textCharacters.count else { return false } + for index in start.. Range? { + if query.isEmpty || query.count > token.count { return nil } + for start in 0...(token.count - query.count) { + if Array(token[start..<(start + query.count)]) == query { + return start..<(start + query.count) + } + } + return nil + } + + private static func commonPrefixLength(_ lhs: [Character], _ rhs: [Character]) -> Int { + let commonLength = min(lhs.count, rhs.count) + if commonLength == 0 { return 0 } + for i in 0.. Int { + let n = lhs.count + let m = rhs.count + if n == 0 { return m } + if m == 0 { return n } + var matrix = Array(repeating: Array(repeating: 0, count: m + 1), count: n + 1) + for i in 0...n { matrix[i][0] = i } + for j in 0...m { matrix[0][j] = j } + if n == 0 || m == 0 { return matrix[n][m] } + for i in 1...n { + for j in 1...m { + let cost = lhs[i - 1] == rhs[j - 1] ? 0 : 1 + let deletion = matrix[i - 1][j] + 1 + let insertion = matrix[i][j - 1] + 1 + let substitution = matrix[i - 1][j - 1] + cost + var value = min(deletion, insertion, substitution) + if i > 1, j > 1, lhs[i - 1] == rhs[j - 2], lhs[i - 2] == rhs[j - 1] { + value = min(value, matrix[i - 2][j - 2] + 1) + } + matrix[i][j] = value + } + } + return matrix[n][m] + } + + private static func lcsTokenIndexes(_ query: [Character], _ token: [Character]) -> [Int] { + let n = query.count + let m = token.count + if n == 0 || m == 0 { return [] } + var dp = Array(repeating: Array(repeating: 0, count: m + 1), count: n + 1) + for i in stride(from: n - 1, through: 0, by: -1) { + for j in stride(from: m - 1, through: 0, by: -1) { + if query[i] == token[j] { + dp[i][j] = 1 + dp[i + 1][j + 1] + } else { + dp[i][j] = max(dp[i + 1][j], dp[i][j + 1]) + } + } + } + var i = 0 + var j = 0 + var matchedIndexes = [Int]() + while i < n && j < m { + if query[i] == token[j] { + matchedIndexes.append(j) + i += 1 + j += 1 + } else if dp[i + 1][j] >= dp[i][j + 1] { + i += 1 + } else { + j += 1 + } + } + return matchedIndexes + } + + private static func originalRange(from normalizedRange: Range, using token: SettingsSearchToken) -> Range { + let start = token.normalizedToOriginal[normalizedRange.lowerBound] + let end = token.normalizedToOriginal[normalizedRange.upperBound - 1] + 1 + return start.. [Range] { + if normalizedIndexes.isEmpty { return [] } + var ranges = [Range]() + var runStart = normalizedIndexes[0] + var runEnd = normalizedIndexes[0] + normalizedIndexes.dropFirst().forEach { index in + if index == runEnd + 1 { + runEnd = index + } else { + ranges.append(originalRange(from: runStart..<(runEnd + 1), using: token)) + runStart = index + runEnd = index + } + } + ranges.append(originalRange(from: runStart..<(runEnd + 1), using: token)) + return mergeRanges(ranges) + } + + private static func tokens(_ text: String) -> [SettingsSearchToken] { + let characters = Array(text) + var tokens = [SettingsSearchToken]() + var normalized = [Character]() + var normalizedToOriginal = [Int]() + func flushCurrentToken() { + guard !normalized.isEmpty else { return } + tokens.append(SettingsSearchToken(normalized: normalized, normalizedToOriginal: normalizedToOriginal)) + normalized.removeAll(keepingCapacity: true) + normalizedToOriginal.removeAll(keepingCapacity: true) + } + for (originalIndex, character) in characters.enumerated() { + let normalizedChars = normalizedCharacters(character) + if normalizedChars.isEmpty { + flushCurrentToken() + continue + } + normalizedChars.forEach { + normalized.append($0) + normalizedToOriginal.append(originalIndex) + } + } + flushCurrentToken() + return tokens + } + + private static func hasInterTermSeparator(_ query: String) -> Bool { + var sawSearchCharacter = false + var sawSeparatorAfterSearchCharacter = false + for character in query { + if normalizedCharacters(character).isEmpty { + if sawSearchCharacter { + sawSeparatorAfterSearchCharacter = true + } + continue + } + if sawSearchCharacter && sawSeparatorAfterSearchCharacter { + return true + } + sawSearchCharacter = true + sawSeparatorAfterSearchCharacter = false + } + return false + } + + private static func normalizedCharacters(_ character: Character) -> [Character] { + let folded = String(character).folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: nil).lowercased() + var chars = [Character]() + folded.unicodeScalars.forEach { + if isSearchScalar($0) { + chars.append(Character(String($0))) + } + } + return chars + } + + private static func isSearchScalar(_ scalar: UnicodeScalar) -> Bool { + if CharacterSet.whitespacesAndNewlines.contains(scalar) { return false } + if CharacterSet.punctuationCharacters.contains(scalar) { return false } + if CharacterSet.symbols.contains(scalar) { return false } + return true + } +} + +private struct SettingsSectionDefinition { + let id: String + let title: String + let imageName: String + let systemSymbolName: String + let view: NSView +} + +private final class SettingsSearchHighlightTarget { + private let matchRanges: (String) -> [Range] + private let applyHighlight: ([Range]) -> Void + private let clearHighlight: () -> Void + + init(_ matchRanges: @escaping (String) -> [Range], _ applyHighlight: @escaping ([Range]) -> Void, _ clearHighlight: @escaping () -> Void) { + self.matchRanges = matchRanges + self.applyHighlight = applyHighlight + self.clearHighlight = clearHighlight + } + + convenience init(_ hasMatch: @escaping (String) -> Bool, _ applyHighlight: @escaping () -> Void, _ clearHighlight: @escaping () -> Void) { + self.init({ query in + hasMatch(query) ? [0..<1] : [] + }, { _ in + applyHighlight() + }, clearHighlight) + } + + func hasMatch(_ query: String) -> Bool { + !matchRanges(query).isEmpty + } + + func updateHighlight(_ query: String) { + let ranges = matchRanges(query) + if ranges.isEmpty { + clearHighlight() + } else { + applyHighlight(ranges) + } + } + + func clear() { + clearHighlight() + } +} + +private final class SettingsSection { + let id: String + let title: String + let icon: NSImage + let container: NSView + let anchor: NSView + let searchableStrings: [String] + let highlightTargets: [SettingsSearchHighlightTarget] + let interSectionSpacingConstraint: NSLayoutConstraint + let bottomSpacingConstraint: NSLayoutConstraint + let titleTopConstraint: NSLayoutConstraint + + init(_ id: String, + _ title: String, + _ icon: NSImage, + _ container: NSView, + _ anchor: NSView, + _ searchableStrings: [String], + _ highlightTargets: [SettingsSearchHighlightTarget], + _ interSectionSpacingConstraint: NSLayoutConstraint, + _ bottomSpacingConstraint: NSLayoutConstraint, + _ titleTopConstraint: NSLayoutConstraint) { + self.id = id + self.title = title + self.icon = icon + self.container = container + self.anchor = anchor + self.searchableStrings = searchableStrings + self.highlightTargets = highlightTargets + self.interSectionSpacingConstraint = interSectionSpacingConstraint + self.bottomSpacingConstraint = bottomSpacingConstraint + self.titleTopConstraint = titleTopConstraint + } + + func matches(_ query: String) -> Bool { + if SettingsSearch.isQueryEmpty(query) { return true } + if searchableStrings.contains(where: { SettingsSearch.match(query, in: $0) != nil }) { return true } + return highlightTargets.contains { $0.hasMatch(query) } + } + + func highlightMatches(_ query: String) { + highlightTargets.forEach { $0.updateHighlight(query) } + } + + func clearHighlights() { + highlightTargets.forEach { $0.clear() } + } +} + +private final class SettingsSidebarCellView: NSTableCellView { + static let identifier = NSUserInterfaceItemIdentifier(rawValue: "SettingsSidebarCell") + private let iconView = NSImageView() + private let titleLabel = NSTextField(labelWithString: "") + + override var backgroundStyle: NSView.BackgroundStyle { + didSet { refreshStyle() } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("Class only supports programmatic initialization") + } + + private func setupView() { + iconView.imageScaling = .scaleProportionallyDown + iconView.setContentHuggingPriority(.required, for: .horizontal) + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + iconView.translatesAutoresizingMaskIntoConstraints = false + titleLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(iconView) + addSubview(titleLabel) + NSLayoutConstraint.activate([ + iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + iconView.centerYAnchor.constraint(equalTo: centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 16), + iconView.heightAnchor.constraint(equalToConstant: 16), + titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 9), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -10), + titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + refreshStyle() + } + + func configure(_ section: SettingsSection) { + titleLabel.stringValue = section.title + iconView.image = section.icon + iconView.image?.isTemplate = true + refreshStyle() + } + + private func refreshStyle() { + let selected = backgroundStyle == .emphasized + titleLabel.font = NSFont.systemFont(ofSize: 13.5, weight: .medium) + titleLabel.textColor = selected ? .white : .labelColor + if #available(macOS 10.14, *) { + iconView.contentTintColor = selected ? .white : .secondaryLabelColor + } + } +} + +class SettingsWindow: NSWindow { + static let contentWidth = CGFloat(620) + static let width = contentWidth + static let sidebarActionButtonHeight: CGFloat = { + let button = NSButton(title: " ", target: nil, action: nil) + button.bezelStyle = .rounded + return button.fittingSize.height + }() + private static let sidebarWidth = CGFloat(210) + private static let contentHorizontalPadding = CGFloat(20) + private static let contentTopPadding = CGFloat(0) + private static let topSectionTitlePadding = CGFloat(20) + private static let contentBottomPadding = CGFloat(20) + private static let sectionTitleSpacing = CGFloat(-5) + private static let sectionInterSectionSpacing = CGFloat(15) + private static let sectionBottomSpacing = CGFloat(30) - sectionInterSectionSpacing + private static let sectionScrollTopPadding = CGFloat(20) + private static let minWindowHeight = CGFloat(500) + private static let sidebarTopInset = CGFloat(40) + private static let sidebarHorizontalPadding = CGFloat(10) + private static let roundedHighlightLayerName = "settingsSearchRoundedHighlight" + private static let roundedHighlightCornerRadius = CGFloat(4) + private static let roundedHighlightHorizontalInset = CGFloat(1.5) + private static let roundedHighlightVerticalInset = CGFloat(0.8) + private static let roundedHighlightLeadingTrim = CGFloat(1.4) + private static let controlHighlightLayerName = "settingsSearchControlHighlight" + private static let segmentedControlHighlightLayerName = "settingsSearchSegmentHighlight" + private static let controlHighlightInset = CGFloat(1) + private static let controlHighlightMinCornerRadius = CGFloat(4) + private static let controlHighlightMaxCornerRadius = CGFloat(9) + + var canBecomeKey_ = true + override var canBecomeKey: Bool { canBecomeKey_ } + + private let splitView = NSSplitView() + private let sidebarContainer = NSVisualEffectView() + private let contentContainer = NSView() + private let searchField = NSSearchField(frame: .zero) + private let sidebarScrollView = NSScrollView() + private let sidebarTableView = NSTableView() + private let rightScrollView = NSScrollView() + private let sectionsDocumentView = FlippedView(frame: .zero) + private let sectionsStack = NSStackView() + private let supportButton = AboutTab.makeSupportProjectButton() + private let resetButton = NSButton(title: NSLocalizedString("Reset settings and restart…", comment: ""), target: nil, action: nil) + private let quitButton = NSButton(title: NSLocalizedString("Quit %@", comment: ""), target: nil, action: #selector(NSApplication.terminate(_:))) + private var sections = [SettingsSection]() + private var visibleSections = [SettingsSection]() + private var selectedSectionId: String? + private var sheetHighlightTargets = [ObjectIdentifier: [SettingsSearchHighlightTarget]]() + private var liveResizeOriginX: CGFloat? + + convenience init() { + let windowWidth = Self.sidebarWidth + Self.contentWidth + 3 * Self.contentHorizontalPadding + self.init(contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: 605), + styleMask: [.titled, .miniaturizable, .closable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + minSize = NSSize(width: windowWidth, height: Self.minWindowHeight) + maxSize = NSSize(width: windowWidth, height: CGFloat.greatestFiniteMagnitude) + setupWindow() + setupView() + } + + private func setupWindow() { + delegate = self + isReleasedWhenClosed = false + hidesOnDeactivate = false + titleVisibility = .hidden + titlebarAppearsTransparent = true + isMovableByWindowBackground = true + if #available(macOS 11.0, *) { + titlebarSeparatorStyle = .none + } + } + + private func setupView() { + setupSplitView() + setupSidebar() + setupContentPane() + sectionDefinitions().forEach { addSection($0) } + refreshControlsFromSettings() + applySearch("") + } + + private func setupSplitView() { + guard let windowContentView = contentView else { return } + splitView.translatesAutoresizingMaskIntoConstraints = false + splitView.isVertical = true + splitView.dividerStyle = .thin + sidebarContainer.translatesAutoresizingMaskIntoConstraints = false + sidebarContainer.material = .sidebar + sidebarContainer.blendingMode = .withinWindow + sidebarContainer.state = .active + contentContainer.translatesAutoresizingMaskIntoConstraints = false + splitView.addArrangedSubview(sidebarContainer) + splitView.addArrangedSubview(contentContainer) + windowContentView.addSubview(splitView) + let sidebarWidthConstraint = sidebarContainer.widthAnchor.constraint(equalToConstant: Self.sidebarWidth) + sidebarWidthConstraint.priority = .required + NSLayoutConstraint.activate([ + splitView.leadingAnchor.constraint(equalTo: windowContentView.leadingAnchor), + splitView.trailingAnchor.constraint(equalTo: windowContentView.trailingAnchor), + splitView.topAnchor.constraint(equalTo: windowContentView.topAnchor), + splitView.bottomAnchor.constraint(equalTo: windowContentView.bottomAnchor), + sidebarWidthConstraint, + ]) + sidebarContainer.setContentCompressionResistancePriority(.required, for: .horizontal) + sidebarContainer.setContentHuggingPriority(.required, for: .horizontal) + contentContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + contentContainer.setContentHuggingPriority(.defaultLow, for: .horizontal) + } + + private func setupSidebar() { + setupSearchField(sidebarContainer) + setupQuitButton(sidebarContainer) + setupResetButton(sidebarContainer) + setupSupportButton(sidebarContainer) + setupSidebarTable(sidebarContainer) + } + + private func setupContentPane() { + rightScrollView.drawsBackground = false + rightScrollView.hasVerticalScroller = true + rightScrollView.hasHorizontalScroller = false + rightScrollView.scrollerStyle = .overlay + if #available(macOS 11.0, *) { + rightScrollView.automaticallyAdjustsContentInsets = false + } + rightScrollView.contentInsets = NSEdgeInsetsZero + rightScrollView.scrollerInsets = NSEdgeInsetsZero + rightScrollView.translatesAutoresizingMaskIntoConstraints = false + contentContainer.addSubview(rightScrollView) + sectionsDocumentView.translatesAutoresizingMaskIntoConstraints = false + rightScrollView.documentView = sectionsDocumentView + sectionsStack.orientation = .vertical + sectionsStack.spacing = 0 + sectionsStack.alignment = .leading + sectionsStack.translatesAutoresizingMaskIntoConstraints = false + sectionsDocumentView.addSubview(sectionsStack) + NSLayoutConstraint.activate([ + rightScrollView.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor), + rightScrollView.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor), + rightScrollView.topAnchor.constraint(equalTo: contentContainer.topAnchor), + rightScrollView.bottomAnchor.constraint(equalTo: contentContainer.bottomAnchor), + sectionsStack.topAnchor.constraint(equalTo: sectionsDocumentView.topAnchor, constant: Self.contentTopPadding), + sectionsStack.leadingAnchor.constraint(equalTo: sectionsDocumentView.leadingAnchor, constant: Self.contentHorizontalPadding), + sectionsStack.trailingAnchor.constraint(equalTo: sectionsDocumentView.trailingAnchor, constant: -Self.contentHorizontalPadding), + sectionsStack.bottomAnchor.constraint(equalTo: sectionsDocumentView.bottomAnchor, constant: -Self.contentBottomPadding), + sectionsDocumentView.widthAnchor.constraint(equalTo: rightScrollView.contentView.widthAnchor), + ]) + } + + private func setupSearchField(_ parent: NSView) { + searchField.delegate = self + searchField.placeholderString = NSLocalizedString("Search", comment: "") + searchField.sendsSearchStringImmediately = true + searchField.sendsWholeSearchString = true + searchField.bezelStyle = .roundedBezel + if #available(macOS 26.0, *) { + searchField.controlSize = .extraLarge + } else if #available(macOS 13.0, *) { + searchField.controlSize = .large + } + searchField.translatesAutoresizingMaskIntoConstraints = false + parent.addSubview(searchField) + NSLayoutConstraint.activate([ + searchField.topAnchor.constraint(equalTo: parent.topAnchor, constant: Self.sidebarTopInset), + searchField.leadingAnchor.constraint(equalTo: parent.leadingAnchor, constant: Self.sidebarHorizontalPadding), + searchField.trailingAnchor.constraint(equalTo: parent.trailingAnchor, constant: -Self.sidebarHorizontalPadding), + ]) + } + + private func setupSidebarTable(_ parent: NSView) { + sidebarScrollView.drawsBackground = false + sidebarScrollView.hasVerticalScroller = true + sidebarScrollView.hasHorizontalScroller = false + sidebarScrollView.scrollerStyle = .overlay + sidebarTableView.headerView = nil + sidebarTableView.intercellSpacing = NSSize(width: 0, height: 2) + sidebarTableView.rowHeight = 30 + sidebarTableView.selectionHighlightStyle = .sourceList + sidebarTableView.backgroundColor = .clear + sidebarTableView.focusRingType = .none + sidebarTableView.usesAlternatingRowBackgroundColors = false + if #available(macOS 11.0, *) { + sidebarTableView.style = .sourceList + } + sidebarTableView.delegate = self + sidebarTableView.dataSource = self + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(rawValue: "SettingsSidebarColumn")) + column.resizingMask = .autoresizingMask + sidebarTableView.addTableColumn(column) + sidebarScrollView.documentView = sidebarTableView + sidebarScrollView.translatesAutoresizingMaskIntoConstraints = false + parent.addSubview(sidebarScrollView) + NSLayoutConstraint.activate([ + sidebarScrollView.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 12), + sidebarScrollView.leadingAnchor.constraint(equalTo: parent.leadingAnchor, constant: Self.sidebarHorizontalPadding - 2), + sidebarScrollView.trailingAnchor.constraint(equalTo: parent.trailingAnchor, constant: -(Self.sidebarHorizontalPadding - 2)), + sidebarScrollView.bottomAnchor.constraint(equalTo: supportButton.topAnchor, constant: -10), + ]) + } + + private func setupSupportButton(_ parent: NSView) { + supportButton.translatesAutoresizingMaskIntoConstraints = false + parent.addSubview(supportButton) + NSLayoutConstraint.activate([ + supportButton.centerXAnchor.constraint(equalTo: parent.centerXAnchor), + supportButton.bottomAnchor.constraint(equalTo: resetButton.topAnchor, constant: -20), + ]) + } + + @objc private func resetPreferences() { + GeneralTab.resetPreferences() + } + + private func setupResetButton(_ parent: NSView) { + resetButton.bezelStyle = .rounded + if #available(macOS 11.0, *) { resetButton.hasDestructiveAction = true } + resetButton.target = self + resetButton.action = #selector(resetPreferences) + resetButton.translatesAutoresizingMaskIntoConstraints = false + parent.addSubview(resetButton) + NSLayoutConstraint.activate([ + resetButton.centerXAnchor.constraint(equalTo: parent.centerXAnchor), + resetButton.bottomAnchor.constraint(equalTo: quitButton.topAnchor, constant: -20), + resetButton.heightAnchor.constraint(equalToConstant: Self.sidebarActionButtonHeight), + ]) + } + + private func setupQuitButton(_ parent: NSView) { + quitButton.bezelStyle = .rounded + quitButton.translatesAutoresizingMaskIntoConstraints = false + parent.addSubview(quitButton) + NSLayoutConstraint.activate([ + quitButton.centerXAnchor.constraint(equalTo: parent.centerXAnchor), + quitButton.bottomAnchor.constraint(equalTo: parent.bottomAnchor, constant: -10), + quitButton.heightAnchor.constraint(equalToConstant: Self.sidebarActionButtonHeight), + ]) + } + + private func sectionDefinitions() -> [SettingsSectionDefinition] { + [ + SettingsSectionDefinition(id: "appearance", title: NSLocalizedString("Appearance", comment: ""), imageName: "appearance", systemSymbolName: "paintpalette", view: AppearanceTab.initTab()), + SettingsSectionDefinition(id: "controls", title: NSLocalizedString("Controls", comment: ""), imageName: "controls", systemSymbolName: "command", view: ControlsTab.initTab()), + SettingsSectionDefinition(id: "general", title: NSLocalizedString("General", comment: ""), imageName: "general", systemSymbolName: "gearshape", view: GeneralTab.initTab()), + SettingsSectionDefinition(id: "policies", title: NSLocalizedString("Policies", comment: ""), imageName: "policies", systemSymbolName: "antenna.radiowaves.left.and.right", view: PoliciesTab.initTab()), + SettingsSectionDefinition(id: "blacklists", title: NSLocalizedString("Blacklists", comment: ""), imageName: "blacklists", systemSymbolName: "hand.raised", view: BlacklistsTab.initTab()), + ] + } + + private func sidebarImage(_ definition: SettingsSectionDefinition) -> NSImage { + if #available(macOS 11.0, *), let image = NSImage(systemSymbolName: definition.systemSymbolName, accessibilityDescription: nil) { + let config = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium) + let configured = image.withSymbolConfiguration(config) ?? image + configured.isTemplate = true + return configured + } + let image = NSImage.initCopy(definition.imageName) + image.isTemplate = true + return image + } + + private func addSection(_ definition: SettingsSectionDefinition) { + let sectionTitle = TableGroupView.makeText(definition.title, bold: true) + sectionTitle.font = NSFont.systemFont(ofSize: 15, weight: .medium) + sectionTitle.lineBreakMode = .byWordWrapping + sectionTitle.maximumNumberOfLines = 0 + let container = NSView() + let spacer = NSView() + container.addSubview(sectionTitle) + container.addSubview(definition.view) + container.addSubview(spacer) + sectionTitle.translatesAutoresizingMaskIntoConstraints = false + definition.view.translatesAutoresizingMaskIntoConstraints = false + spacer.translatesAutoresizingMaskIntoConstraints = false + container.translatesAutoresizingMaskIntoConstraints = false + let titleTopConstraint = sectionTitle.topAnchor.constraint(equalTo: container.topAnchor) + let interSectionSpacingConstraint = spacer.topAnchor.constraint(equalTo: definition.view.bottomAnchor, constant: Self.sectionInterSectionSpacing) + let spacerHeightConstraint = spacer.heightAnchor.constraint(equalToConstant: Self.sectionBottomSpacing) + NSLayoutConstraint.activate([ + titleTopConstraint, + sectionTitle.leadingAnchor.constraint(equalTo: container.leadingAnchor), + sectionTitle.trailingAnchor.constraint(equalTo: container.trailingAnchor), + definition.view.topAnchor.constraint(equalTo: sectionTitle.bottomAnchor, constant: Self.sectionTitleSpacing), + definition.view.leadingAnchor.constraint(equalTo: container.leadingAnchor), + definition.view.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor), + interSectionSpacingConstraint, + spacer.leadingAnchor.constraint(equalTo: container.leadingAnchor), + spacer.trailingAnchor.constraint(equalTo: container.trailingAnchor), + spacer.bottomAnchor.constraint(equalTo: container.bottomAnchor), + spacerHeightConstraint, + ]) + sectionsStack.addArrangedSubview(container) + container.widthAnchor.constraint(equalTo: sectionsStack.widthAnchor).isActive = true + let (searchableStrings, highlightTargets) = collectSearchContent(sectionTitle, definition.view) + let section = SettingsSection(definition.id, + definition.title, + sidebarImage(definition), + container, + sectionTitle, + searchableStrings, + highlightTargets, + interSectionSpacingConstraint, + spacerHeightConstraint, + titleTopConstraint) + sections.append(section) + } + + private func updateVisibleSectionsSpacing() { + visibleSections.enumerated().forEach { index, section in + let isFirst = index == 0 + let isLast = index == visibleSections.count - 1 + section.titleTopConstraint.constant = isFirst ? Self.topSectionTitlePadding : 0 + section.interSectionSpacingConstraint.constant = isLast ? 0 : Self.sectionInterSectionSpacing + section.bottomSpacingConstraint.constant = isLast ? 0 : Self.sectionBottomSpacing + } + } + + private func collectSearchContent(_ sectionTitle: NSTextField, _ root: NSView) -> ([String], [SettingsSearchHighlightTarget]) { + var textValues = [String]() + var highlightTargets = [SettingsSearchHighlightTarget]() + textValues.append(sectionTitle.stringValue) + if let target = highlightTarget(sectionTitle) { + highlightTargets.append(target) + } + collectSearchContent(root, &textValues, &highlightTargets) + return (Array(Set(textValues)), highlightTargets) + } + + private func collectSearchContent(_ root: NSView, + _ textValues: inout [String], + _ highlightTargets: inout [SettingsSearchHighlightTarget]) { + if let textField = root as? NSTextField { + let value = textField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + textValues.append(value) + if let target = highlightTarget(textField) { + highlightTargets.append(target) + } + } + } else if let popUpButton = root as? NSPopUpButton { + let value = popUpButton.title.trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + textValues.append(value) + } + popUpButton.itemTitles.forEach { + let value = $0.trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + textValues.append(value) + } + } + if let target = highlightTarget(popUpButton) { + highlightTargets.append(target) + } + } else if let tableView = root as? TableView { + SettingsWindow.searchStrings(tableView).forEach { + textValues.append($0) + } + if let target = highlightTarget(tableView) { + highlightTargets.append(target) + } + } else if let button = root as? NSButton { + let value = button.title.trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + textValues.append(value) + } + if let target = highlightTarget(button) { + highlightTargets.append(target) + } + } else if let segmentedControl = root as? NSSegmentedControl { + (0.. SettingsSearchHighlightTarget? { + let text = textField.stringValue + guard !text.isEmpty else { return nil } + let baseAttributedString = textField.attributedStringValue + return SettingsSearchHighlightTarget({ query in + SettingsSearch.match(query, in: text)?.ranges ?? [] + }, { ranges in + let mutable = NSMutableAttributedString(attributedString: baseAttributedString) + let nsRanges = ranges.compactMap { SettingsWindow.characterRangeToNSRange($0, in: text) } + nsRanges.forEach { + mutable.addAttribute(.foregroundColor, value: Appearance.searchMatchForegroundColor, range: $0) + } + textField.attributedStringValue = mutable + SettingsWindow.applyRoundedHighlights(to: textField, attributedString: mutable, ranges: nsRanges) + }, { + textField.attributedStringValue = baseAttributedString + SettingsWindow.clearRoundedHighlights(from: textField) + }) + } + + private func highlightTarget(_ popUpButton: NSPopUpButton) -> SettingsSearchHighlightTarget? { + controlHighlightTarget(popUpButton) { + SettingsWindow.searchStrings(popUpButton) + } + } + + private func highlightTarget(_ tableView: TableView) -> SettingsSearchHighlightTarget? { + let targetView = tableView.enclosingScrollView ?? tableView + return controlHighlightTarget(targetView) { + SettingsWindow.searchStrings(tableView) + } + } + + private func highlightTarget(_ button: NSButton) -> SettingsSearchHighlightTarget? { + guard SettingsWindow.sheet(forSearchButton: button) != nil else { return nil } + return controlHighlightTarget(button) { + var values = [String]() + SettingsWindow.appendTrimmed(button.title, &values) + if let sheet = SettingsWindow.sheet(forSearchButton: button) { + values.append(contentsOf: SettingsWindow.sheetSearchStrings(sheet)) + } + return Array(Set(values)) + } + } + + private func highlightTarget(_ segmentedControl: NSSegmentedControl) -> SettingsSearchHighlightTarget? { + let segmentLabels = (0.. SettingsSearchHighlightTarget? { + controlHighlightTarget(infoButton) { + SettingsWindow.searchStrings(infoButton) + } + } + + private func controlHighlightTarget(_ control: NSView, _ searchableStrings: @escaping () -> [String]) -> SettingsSearchHighlightTarget? { + if searchableStrings().isEmpty { return nil } + return SettingsSearchHighlightTarget({ query in + searchableStrings().contains { + SettingsSearch.match(query, in: $0) != nil + } + }, { + SettingsWindow.applyControlHighlight(to: control) + }, { + SettingsWindow.clearControlHighlight(from: control) + }) + } + + private static func characterRangeToNSRange(_ range: Range, in text: String) -> NSRange? { + if range.lowerBound < 0 || range.upperBound > text.count || range.isEmpty { return nil } + let start = text.index(text.startIndex, offsetBy: range.lowerBound) + let end = text.index(text.startIndex, offsetBy: range.upperBound) + return NSRange(start.. 0, rect.height > 0 else { return } + let layer = noAnimation { CAShapeLayer() } + layer.name = segmentedControlHighlightLayerName + layer.fillColor = Appearance.searchMatchHighlightColor.cgColor + let cornerRadius = min(max(rect.height * 0.3, 4), 7) + layer.path = CGPath(roundedRect: rect, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) + control.layer?.insertSublayer(layer, at: 0) + } + } + + private static func segmentedControlSegmentRects(_ control: NSSegmentedControl) -> [CGRect] { + let segmentCount = control.segmentCount + if segmentCount <= 0 { return [] } + let widths = (0.. 0 ? $1 : 0) + } + let autoSegmentCount = widths.filter { $0 == 0 }.count + let autoSegmentWidth = autoSegmentCount > 0 ? max(control.bounds.width - explicitWidthTotal, 0) / CGFloat(autoSegmentCount) : 0 + var currentX = control.bounds.minX + return widths.enumerated().map { index, width in + let resolvedWidth = width > 0 ? width : autoSegmentWidth + let isLastSegment = index == segmentCount - 1 + let segmentWidth = isLastSegment ? max(control.bounds.maxX - currentX, 0) : resolvedWidth + defer { currentX += segmentWidth } + return CGRect(x: currentX, y: control.bounds.minY, width: segmentWidth, height: control.bounds.height) + } + } + + private static func applyControlHighlight(to view: NSView) { + clearControlHighlight(from: view) + view.layoutSubtreeIfNeeded() + guard view.bounds.width > 0, view.bounds.height > 0 else { return } + view.wantsLayer = true + let layer = noAnimation { CAShapeLayer() } + layer.name = controlHighlightLayerName + layer.fillColor = Appearance.searchMatchHighlightColor.cgColor + let rect = view.bounds.insetBy(dx: -controlHighlightInset, dy: -controlHighlightInset) + let cornerRadius = min(max(rect.height * 0.35, controlHighlightMinCornerRadius), controlHighlightMaxCornerRadius) + layer.path = CGPath(roundedRect: rect, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) + view.layer?.insertSublayer(layer, at: 0) + } + + private static func applyRoundedHighlights(to textField: NSTextField, + attributedString: NSAttributedString, + ranges: [NSRange]) { + clearRoundedHighlights(from: textField) + guard !ranges.isEmpty else { return } + textField.layoutSubtreeIfNeeded() + let textRect = textDrawingRect(textField) + guard textRect.width > 0, textRect.height > 0 else { return } + textField.wantsLayer = true + let textStorage = NSTextStorage(attributedString: attributedString) + let layoutManager = NSLayoutManager() + let textContainer = NSTextContainer(size: NSSize(width: textRect.width, height: CGFloat.greatestFiniteMagnitude)) + textContainer.lineFragmentPadding = 0 + textContainer.maximumNumberOfLines = textField.maximumNumberOfLines + textContainer.lineBreakMode = textField.lineBreakMode + layoutManager.addTextContainer(textContainer) + textStorage.addLayoutManager(layoutManager) + layoutManager.ensureLayout(for: textContainer) + let usedRect = layoutManager.usedRect(for: textContainer) + let horizontalOffset = textRect.minX + let verticalOffset = textRect.minY + max(0, (textRect.height - usedRect.height) / 2) + ranges.forEach { range in + let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) + guard glyphRange.length > 0 else { return } + layoutManager.enumerateEnclosingRects(forGlyphRange: glyphRange, withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), in: textContainer) { rect, _ in + var highlightRect = rect.offsetBy(dx: horizontalOffset, dy: verticalOffset) + highlightRect = highlightRect.insetBy(dx: -roundedHighlightHorizontalInset, dy: -roundedHighlightVerticalInset) + highlightRect = leadingTrimmedHighlightRect(highlightRect, textField) + let layer = noAnimation { CAShapeLayer() } + layer.name = roundedHighlightLayerName + layer.fillColor = Appearance.searchMatchHighlightColor.cgColor + layer.path = CGPath(roundedRect: highlightRect, cornerWidth: roundedHighlightCornerRadius, cornerHeight: roundedHighlightCornerRadius, transform: nil) + textField.layer?.insertSublayer(layer, at: 0) + } + } + } + + private static func leadingTrimmedHighlightRect(_ rect: CGRect, _ textField: NSTextField) -> CGRect { + let trimmedWidth = max(rect.width - roundedHighlightLeadingTrim, 0.5) + if textField.userInterfaceLayoutDirection == .rightToLeft { + return CGRect(x: rect.minX, y: rect.minY, width: trimmedWidth, height: rect.height) + } + return CGRect(x: rect.minX + roundedHighlightLeadingTrim, y: rect.minY, width: trimmedWidth, height: rect.height) + } + + private static func textDrawingRect(_ textField: NSTextField) -> CGRect { + textField.cell?.drawingRect(forBounds: textField.bounds) ?? textField.bounds + } + + private static func appendTrimmed(_ text: String, _ values: inout [String]) { + let value = trimmedText(text) + if !value.isEmpty { + values.append(value) + } + } + + private static func trimmedText(_ text: String) -> String { + text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func searchStrings(_ popUpButton: NSPopUpButton) -> [String] { + var values = [String]() + appendTrimmed(popUpButton.title, &values) + popUpButton.itemTitles.forEach { + appendTrimmed($0, &values) + } + return Array(Set(values)) + } + + private static func searchStrings(_ segmentedControl: NSSegmentedControl) -> [String] { + var values = [String]() + (0.. [String] { + var values = [String]() + infoButton.searchableStrings.forEach { + appendTrimmed($0, &values) + } + return Array(Set(values)) + } + + private static func searchStrings(_ tableView: TableView) -> [String] { + var values = [String]() + tableView.tableColumns.forEach { + appendTrimmed($0.headerCell.stringValue, &values) + appendTrimmed($0.headerToolTip ?? "", &values) + } + tableView.items.forEach { + appendTrimmed($0.bundleIdentifier, &values) + appendTrimmed($0.hide.localizedString, &values) + appendTrimmed($0.ignore.localizedString, &values) + } + return Array(Set(values)) + } + + private static func sheet(forSearchButton button: NSButton) -> SheetWindow? { + guard let action = button.action else { return nil } + if action == #selector(AppearanceTab.showCustomizeStyleSheet) { return AppearanceTab.customizeStyleSheet } + if action == #selector(AppearanceTab.showAnimationsSheet) { return AppearanceTab.animationsSheet } + if action == #selector(ControlsTab.showShortcutsSettings) { return ControlsTab.shortcutsWhenActiveSheet } + if action == #selector(ControlsTab.showAdditionalControlsSettings) { return ControlsTab.additionalControlsSheet } + return nil + } + + private static func sheetSearchStrings(_ sheet: SheetWindow) -> [String] { + guard let contentView = sheet.contentView else { return [] } + var values = [String]() + collectSearchStrings(contentView, &values) + return Array(Set(values)) + } + + private static func collectSearchStrings(_ root: NSView, _ values: inout [String]) { + if let textField = root as? NSTextField { + appendTrimmed(textField.stringValue, &values) + } else if let popUpButton = root as? NSPopUpButton { + appendTrimmed(popUpButton.title, &values) + popUpButton.itemTitles.forEach { + appendTrimmed($0, &values) + } + } else if let tableView = root as? TableView { + searchStrings(tableView).forEach { + appendTrimmed($0, &values) + } + } else if let button = root as? NSButton { + appendTrimmed(button.title, &values) + } else if let segmentedControl = root as? NSSegmentedControl { + (0.. [SettingsSearchHighlightTarget] { + let key = ObjectIdentifier(sheet) + if let targets = sheetHighlightTargets[key] { + return targets + } + guard let contentView = sheet.contentView else { return [] } + var targets = [SettingsSearchHighlightTarget]() + collectSheetHighlightTargets(contentView, &targets) + sheetHighlightTargets[key] = targets + return targets + } + + private func collectSheetHighlightTargets(_ root: NSView, _ targets: inout [SettingsSearchHighlightTarget]) { + if let textField = root as? NSTextField { + if let target = highlightTarget(textField) { + targets.append(target) + } + } else if let popUpButton = root as? NSPopUpButton { + if let target = highlightTarget(popUpButton) { + targets.append(target) + } + } else if let segmentedControl = root as? NSSegmentedControl { + if let target = highlightTarget(segmentedControl) { + targets.append(target) + } + } else if let infoButton = root as? ClickHoverImageView { + if let target = highlightTarget(infoButton) { + targets.append(target) + } + } + root.subviews.forEach { + collectSheetHighlightTargets($0, &targets) + } + } + + private func clearSheetHighlights(_ sheet: SheetWindow) { + let key = ObjectIdentifier(sheet) + guard let targets = sheetHighlightTargets[key] else { return } + targets.forEach { $0.clear() } + } + + private func selectSection(_ section: SettingsSection, scroll: Bool, selectInSidebar: Bool = true) { + selectedSectionId = section.id + if selectInSidebar, let row = visibleSections.firstIndex(where: { $0.id == section.id }), sidebarTableView.selectedRow != row { + sidebarTableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + } + guard scroll else { return } + scrollToSection(section) + } + + private func scrollToSection(_ section: SettingsSection) { + sectionsDocumentView.layoutSubtreeIfNeeded() + let anchorFrame = section.anchor.convert(section.anchor.bounds, to: sectionsDocumentView) + let targetY = max(anchorFrame.minY - Self.sectionScrollTopPadding, 0) + rightScrollView.contentView.scroll(to: NSPoint(x: 0, y: targetY)) + rightScrollView.reflectScrolledClipView(rightScrollView.contentView) + } + + private func applySearch(_ query: String) { + Popover.shared.updateSearchContext(query) { query, text in + SettingsSearch.match(query, in: text)?.ranges ?? [] + } + let hasQuery = !SettingsSearch.isQueryEmpty(query) + let matchingSections = sections.filter { !hasQuery || $0.matches(query) } + visibleSections = matchingSections + let visibleSectionIds = Set(visibleSections.map(\.id)) + sections.forEach { + let isVisible = visibleSectionIds.contains($0.id) + $0.container.isHidden = !isVisible + if isVisible && hasQuery { + $0.highlightMatches(query) + } else { + $0.clearHighlights() + } + } + applySearchToVisibleSheets(query) + updateVisibleSectionsSpacing() + sidebarTableView.reloadData() + guard !visibleSections.isEmpty else { + selectedSectionId = nil + sidebarTableView.deselectAll(nil) + return + } + if let selectedSectionId, let row = visibleSections.firstIndex(where: { $0.id == selectedSectionId }) { + selectSection(visibleSections[row], scroll: true) + return + } + selectSection(visibleSections[0], scroll: true) + } + + override func close() { + hideAppIfLastWindowIsClosed() + super.close() + } +} + +extension SettingsWindow: NSWindowDelegate { + func windowWillStartLiveResize(_ notification: Notification) { + liveResizeOriginX = frame.origin.x + } + + func windowDidResize(_ notification: Notification) { + guard inLiveResize, let liveResizeOriginX else { return } + guard abs(frame.origin.x - liveResizeOriginX) > 0 else { return } + setFrameOrigin(NSPoint(x: liveResizeOriginX, y: frame.origin.y)) + } + + func windowDidEndLiveResize(_ notification: Notification) { + liveResizeOriginX = nil + } +} + +extension SettingsWindow: NSSearchFieldDelegate { + func controlTextDidChange(_ notification: Notification) { + applySearch(searchField.stringValue) + } +} + +extension SettingsWindow: NSTableViewDataSource, NSTableViewDelegate { + func numberOfRows(in tableView: NSTableView) -> Int { + visibleSections.count + } + + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + 30 + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let section = visibleSections[row] + let cell = tableView.makeView(withIdentifier: SettingsSidebarCellView.identifier, owner: self) as? SettingsSidebarCellView ?? { + let view = SettingsSidebarCellView() + view.identifier = SettingsSidebarCellView.identifier + return view + }() + cell.configure(section) + return cell + } + + func tableViewSelectionDidChange(_ notification: Notification) { + let row = sidebarTableView.selectedRow + guard row >= 0, row < visibleSections.count else { return } + let section = visibleSections[row] + if selectedSectionId == section.id { return } + selectSection(section, scroll: true, selectInSidebar: false) + } +} diff --git a/src/ui/settings-window/tabs/AboutTab.swift b/src/ui/settings-window/tabs/AboutTab.swift new file mode 100644 index 00000000..79889a9a --- /dev/null +++ b/src/ui/settings-window/tabs/AboutTab.swift @@ -0,0 +1,124 @@ +import Cocoa + +class AboutTab { + static func initTab() -> NSView { + makeContentView() + } + + static func makeContentView(_ fitToContent: Bool = true, _ showFeedbackButton: Bool = true, _ centerHero: Bool = false) -> NSView { + let appIcon = LightImageView() + appIcon.translatesAutoresizingMaskIntoConstraints = false + appIcon.updateContents(.cgImage(App.appIcon), NSSize(width: 128, height: 128)) + appIcon.fit(128, 128) + let appText = StackView([ + BoldLabel(App.name), + NSTextField(wrappingLabelWithString: NSLocalizedString("Version", comment: "") + " " + App.version), + NSTextField(wrappingLabelWithString: App.licence), + HyperlinkLabel(NSLocalizedString("Source code repository", comment: ""), App.repository), + HyperlinkLabel(NSLocalizedString("Latest releases", comment: ""), App.repository + "/releases"), + ], .vertical) + appText.spacing = GridView.interPadding / 2 + let rowToSeparate = 3 + appText.views[rowToSeparate].topAnchor.constraint(equalTo: appText.views[rowToSeparate - 1].bottomAnchor, constant: GridView.interPadding).isActive = true + let appInfo = NSStackView(views: [appIcon, appText]) + appIcon.translatesAutoresizingMaskIntoConstraints = false + appInfo.spacing = GridView.interPadding + appInfo.alignment = .centerY + let supportProject = makeSupportProjectButton() + let rows = [[appInfo], [supportProject]] + let grid = GridView(rows, 0) + if centerHero { + grid.cell(atColumnIndex: 0, rowIndex: 0).xPlacement = .center + } + let supportProjectCell = grid.cell(atColumnIndex: 0, rowIndex: showFeedbackButton ? 2 : 1) + supportProjectCell.xPlacement = .center + if fitToContent { + grid.fit() + } + return grid + } + + static func makeSupportProjectButton() -> NSButton { + let button = makeButtonWithIcon(NSLocalizedString("Support this project", comment: ""), App.supportProjectAction, "heart.fill", .red) + styleSupportProjectButton(button) + return button + } + + private static func styleSupportProjectButton(_ button: NSButton) { + button.bezelStyle = .rounded + button.translatesAutoresizingMaskIntoConstraints = false + button.heightAnchor.constraint(equalToConstant: SettingsWindow.sidebarActionButtonHeight).isActive = true + } + + private static func makeButtonWithIcon(_ title: String, _ selector: Selector, _ symbolName: String?, _ color: NSColor? = nil) -> NSButton { + let button = NSButton(title: title, target: nil, action: selector) + if #available(macOS 26.0, *), let symbolName { + button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) + button.imagePosition = .imageLeading + if let color { + button.image = button.image?.withSymbolConfiguration(.init(paletteColors: [color])) + } + } + return button + } +} + +class AboutWindow: NSPanel { + private static let contentPadding = CGFloat(24) + + var canBecomeKey_ = true + override var canBecomeKey: Bool { canBecomeKey_ } + + convenience init() { + self.init(contentRect: NSRect(x: 0, y: 0, width: 600, height: 450), styleMask: [.utilityWindow, .titled, .closable], backing: .buffered, defer: false) + setupWindow() + setupView() + } + + private func setupWindow() { + isReleasedWhenClosed = false + hidesOnDeactivate = false + title = String(format: NSLocalizedString("About %@", comment: ""), App.name) + } + + private func setupView() { + let scrollView = NSScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.scrollerStyle = .overlay + let documentView = FlippedView(frame: .zero) + documentView.translatesAutoresizingMaskIntoConstraints = false + scrollView.documentView = documentView + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 30 + stack.translatesAutoresizingMaskIntoConstraints = false + let aboutView = AboutTab.makeContentView(false, false, true) + let acknowledgmentsColumnWidth = frame.width - 2 * Self.contentPadding + let acknowledgmentsView = AcknowledgmentsTab.makeContentView(columnWidth: acknowledgmentsColumnWidth, shouldFit: false, verticallyStacked: true) + acknowledgmentsView.translatesAutoresizingMaskIntoConstraints = false + stack.addArrangedSubview(aboutView) + stack.addArrangedSubview(acknowledgmentsView) + documentView.addSubview(stack) + contentView = scrollView + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: Self.contentPadding), + stack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: Self.contentPadding), + stack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -Self.contentPadding), + stack.bottomAnchor.constraint(equalTo: documentView.bottomAnchor, constant: -Self.contentPadding), + documentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor), + aboutView.leadingAnchor.constraint(equalTo: stack.leadingAnchor), + aboutView.trailingAnchor.constraint(equalTo: stack.trailingAnchor), + acknowledgmentsView.leadingAnchor.constraint(equalTo: stack.leadingAnchor), + acknowledgmentsView.trailingAnchor.constraint(equalTo: stack.trailingAnchor), + ]) + } + + override func close() { + hideAppIfLastWindowIsClosed() + super.close() + } +} diff --git a/src/ui/settings-window/tabs/AcknowledgmentsTab.swift b/src/ui/settings-window/tabs/AcknowledgmentsTab.swift new file mode 100644 index 00000000..0a1f8971 --- /dev/null +++ b/src/ui/settings-window/tabs/AcknowledgmentsTab.swift @@ -0,0 +1,69 @@ +import Cocoa + +class AcknowledgmentsTab { + // going taller than this will crop the view on small screens or low scaling modes on retina displays + static let maxTabHeight = CGFloat(450) + + static func initTab() -> NSView { + makeScrollableView(makeContentView(), maxTabHeight) + } + + static func makeContentView(columnWidth: CGFloat = 240, shouldFit: Bool = true, verticallyStacked: Bool = false) -> NSView { + let sectionNames = ["Contributors", "Acknowledgments"] + let textViews: [NSTextView] = sectionNames.map { makeTextView($0, columnWidth) } + let content: NSView + if verticallyStacked { + let stack = NSStackView(views: textViews) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = GridView.interPadding + stack.translatesAutoresizingMaskIntoConstraints = false + content = stack + } else { + content = GridView([textViews]) + } + if shouldFit { + content.fit() + } + return content + } + + private static func makeTextView(_ sectionName: String, _ columnWidth: CGFloat) -> NSTextView { + let markdownFileUrl = Bundle.main.url(forResource: sectionName, withExtension: "md")! + var content = try! String(contentsOf: markdownFileUrl, encoding: .utf8) + if content.last == "\n" { + content.removeLast() + } + let attributedString = Markdown.toAttributedString(content) + let textView = NSTextView() + textView.textContainer!.widthTracksTextView = true + textView.translatesAutoresizingMaskIntoConstraints = false + textView.drawsBackground = false + textView.isSelectable = true + textView.isEditable = false + textView.enabledTextCheckingTypes = 0 + textView.frame.size.width = columnWidth + textView.textStorage!.setAttributedString(attributedString) + textView.layoutManager!.ensureLayout(for: textView.textContainer!) + textView.frame = textView.layoutManager!.usedRect(for: textView.textContainer!) + textView.fit(textView.frame.width, textView.frame.height) + return textView + } + + private static func makeScrollableView(_ content: NSView, _ maxHeight: CGFloat) -> NSView { + let scrollView = NSScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.documentView = FlippedView(frame: .zero) + scrollView.documentView!.translatesAutoresizingMaskIntoConstraints = false + scrollView.documentView!.subviews = [content] + let totalWidth = content.fittingSize.width + scrollView.frame.size = NSSize(width: totalWidth, height: maxHeight) + scrollView.contentView.frame.size = scrollView.frame.size + scrollView.documentView!.frame.size = content.fittingSize + scrollView.fit(totalWidth, maxHeight) + return scrollView + } +} diff --git a/src/ui/preferences-window/tabs/BlacklistsTab.swift b/src/ui/settings-window/tabs/BlacklistsTab.swift similarity index 72% rename from src/ui/preferences-window/tabs/BlacklistsTab.swift rename to src/ui/settings-window/tabs/BlacklistsTab.swift index f481c4d7..1acc6922 100644 --- a/src/ui/preferences-window/tabs/BlacklistsTab.swift +++ b/src/ui/settings-window/tabs/BlacklistsTab.swift @@ -3,7 +3,7 @@ import Sparkle class BlacklistsTab { static func initTab() -> NSView { - let blacklist = BlacklistView() + let blacklist = BlacklistView(width: SettingsWindow.contentWidth - 2 * TableGroupView.padding) let add = NSSegmentedControl(images: [NSImage(named: NSImage.addTemplateName)!, NSImage(named: NSImage.removeTemplateName)!], trackingMode: .momentary, target: nil, action: nil) add.onAction = { let tableView = blacklist.documentView as! TableView @@ -12,7 +12,7 @@ class BlacklistsTab { dialog.allowsMultipleSelection = false dialog.allowedFileTypes = ["app"] dialog.canChooseDirectories = false - dialog.beginSheetModal(for: App.app.preferencesWindow) { + dialog.beginSheetModal(for: App.app.settingsWindow) { if $0 == .OK, let url = dialog.url, let bundleId = Bundle(url: url)?.bundleIdentifier { tableView.insertRow(bundleId) } @@ -21,11 +21,9 @@ class BlacklistsTab { tableView.removeSelectedRows() } } - let table = TableGroupView(width: PreferencesWindow.width) + let table = TableGroupView(width: SettingsWindow.contentWidth) _ = table.addRow(leftViews: [blacklist], secondaryViews: [add]) - let view = TableGroupSetView(originalViews: [table]) - view.translatesAutoresizingMaskIntoConstraints = false - view.widthAnchor.constraint(equalToConstant: view.fittingSize.width).isActive = true + let view = TableGroupSetView(originalViews: [table], bottomPadding: 0) return view } } diff --git a/src/ui/preferences-window/tabs/GeneralTab.swift b/src/ui/settings-window/tabs/GeneralTab.swift similarity index 86% rename from src/ui/preferences-window/tabs/GeneralTab.swift rename to src/ui/settings-window/tabs/GeneralTab.swift index e79aff8b..2d1ae850 100644 --- a/src/ui/preferences-window/tabs/GeneralTab.swift +++ b/src/ui/settings-window/tabs/GeneralTab.swift @@ -16,8 +16,6 @@ class GeneralTab { ]) let language = TableGroupView.Row(leftTitle: NSLocalizedString("Language", comment: ""), rightViews: [LabelAndControl.makeDropdown("language", LanguagePreference.allCases, extraAction: setLanguageCallback)]) - let resetPreferences = NSButton(title: NSLocalizedString("Reset settings and restart…", comment: ""), target: self, action: #selector(GeneralTab.resetPreferences)) - if #available(macOS 11.0, *) { resetPreferences.hasDestructiveAction = true } for i in 0.. NSView { PoliciesTab.updatesPolicyDropdown = LabelAndControl.makeDropdown("updatePolicy", UpdatePolicyPreference.allCases) PoliciesTab.crashPolicyDropdown = LabelAndControl.makeDropdown("crashPolicy", CrashPolicyPreference.allCases) - let table = TableGroupView(width: PreferencesWindow.width) + let table = TableGroupView(width: SettingsWindow.contentWidth) table.addRow(leftText: NSLocalizedString("Updates policy", comment: ""), rightViews: [PoliciesTab.updatesPolicyDropdown]) table.addRow(leftText: NSLocalizedString("Crash reports policy", comment: ""), rightViews: [PoliciesTab.crashPolicyDropdown]) - table.fit() let checkForUpdates = NSButton(title: NSLocalizedString("Check for updates now…", comment: ""), target: nil, action: #selector(PoliciesTab.checkForUpdatesNow)) - let view = TableGroupSetView(originalViews: [table, checkForUpdates]) - view.translatesAutoresizingMaskIntoConstraints = false - view.widthAnchor.constraint(equalToConstant: view.fittingSize.width).isActive = true + let view = TableGroupSetView(originalViews: [table, checkForUpdates], bottomPadding: 0) return view } diff --git a/src/ui/preferences-window/tabs/appearance/AnimationsSheet.swift b/src/ui/settings-window/tabs/appearance/AnimationsSheet.swift similarity index 98% rename from src/ui/preferences-window/tabs/appearance/AnimationsSheet.swift rename to src/ui/settings-window/tabs/appearance/AnimationsSheet.swift index b05e1840..571ab2df 100644 --- a/src/ui/preferences-window/tabs/appearance/AnimationsSheet.swift +++ b/src/ui/settings-window/tabs/appearance/AnimationsSheet.swift @@ -14,7 +14,6 @@ class AnimationsSheet: SheetWindow { rightViews: LabelAndControl.makeSwitch("fadeOutAnimation")) table.addRow(leftText: NSLocalizedString("Fade in animation of Preview", comment: ""), rightViews: LabelAndControl.makeSwitch("previewFadeInAnimation")) - table.fit() return table } } diff --git a/src/ui/preferences-window/tabs/appearance/AppearanceTab.swift b/src/ui/settings-window/tabs/appearance/AppearanceTab.swift similarity index 80% rename from src/ui/preferences-window/tabs/appearance/AppearanceTab.swift rename to src/ui/settings-window/tabs/appearance/AppearanceTab.swift index 7bc88c8c..e425bb18 100644 --- a/src/ui/preferences-window/tabs/appearance/AppearanceTab.swift +++ b/src/ui/settings-window/tabs/appearance/AppearanceTab.swift @@ -135,7 +135,6 @@ class ShowHideIllustratedView { table.onMouseExited = { event, view in IllustratedImageThemeView.resetImage(self.illustratedImageView, event, view) } - table.fit() let view = TableGroupSetView(originalViews: [table], padding: 0) return view } @@ -158,7 +157,7 @@ class ShowHideIllustratedView { hideStatusIcons.supportedStyles = [.thumbnails, .titles] hideStatusIcons.leftViews = [TableGroupView.makeText(NSLocalizedString("Hide status icons", comment: ""))] hideStatusIcons.subTitle = NSLocalizedString("AltTab will show if the window is currently minimized or fullscreen with a status icon.", comment: "") - hideStatusIcons.rightViews.append(LabelAndControl.makeInfoButton(onMouseEntered: { event, view in + hideStatusIcons.rightViews.append(LabelAndControl.makeInfoButton(searchableTooltipTexts: [hideStatusIcons.subTitle!], onMouseEntered: { event, view in Popover.shared.show(event: event, positioningView: view, message: hideStatusIcons.subTitle!) }, onMouseExited: { event, view in Popover.shared.hide() @@ -195,7 +194,7 @@ class ShowHideIllustratedView { showTabsAsWindows.supportedStyles = [.thumbnails, .appIcons, .titles] showTabsAsWindows.leftViews = [TableGroupView.makeText(NSLocalizedString("Show standard tabs as windows", comment: ""))] showTabsAsWindows.subTitle = NSLocalizedString("Some apps like Finder or Preview use standard tabs which act like independent windows. Some other apps like web browsers use custom tabs which act in unique ways and are not actual windows. AltTab can't list those separately.", comment: "") - showTabsAsWindows.rightViews.append(LabelAndControl.makeInfoButton(onMouseEntered: { event, view in + showTabsAsWindows.rightViews.append(LabelAndControl.makeInfoButton(searchableTooltipTexts: [featureUnavailable, showTabsAsWindows.subTitle!], onMouseEntered: { event, view in if ShowHideIllustratedView.isDisabledOnApplications(showTabsAsWindows) { Popover.shared.show(event: event, positioningView: view, message: featureUnavailable) } else { @@ -210,32 +209,10 @@ class ShowHideIllustratedView { } })) showHideRows.append(showTabsAsWindows) - var previewSelectedWindow = ShowHideRowInfo() - previewSelectedWindow.rowId = "previewFocusedWindow" - previewSelectedWindow.uncheckedImage = "hide_preview_focused_window" - previewSelectedWindow.checkedImage = "show_preview_focused_window" - previewSelectedWindow.supportedStyles = [.thumbnails, .appIcons, .titles] - previewSelectedWindow.leftViews = [TableGroupView.makeText(NSLocalizedString("Preview selected window", comment: ""))] - previewSelectedWindow.subTitle = NSLocalizedString("Preview the selected window.", comment: "") - previewSelectedWindow.rightViews.append(LabelAndControl.makeInfoButton(onMouseEntered: { event, view in - if ShowHideIllustratedView.isDisabledOnApplications(previewSelectedWindow) { - Popover.shared.show(event: event, positioningView: view, message: featureUnavailable) - } else { - Popover.shared.show(event: event, positioningView: view, message: previewSelectedWindow.subTitle!) - } - }, onMouseExited: { event, view in - Popover.shared.hide() - })) - previewSelectedWindow.rightViews.append(LabelAndControl.makeSwitch(previewSelectedWindow.rowId, extraAction: { sender in - if !ShowHideIllustratedView.isDisabledOnApplications(previewSelectedWindow) { - self.onCheckboxClicked(sender: sender, rowId: previewSelectedWindow.rowId) - } - })) - showHideRows.append(previewSelectedWindow) } static func isDisabledOnApplications(_ row: ShowHideRowInfo) -> Bool { - let contains = ["showTabsAsWindows", "previewFocusedWindow"].contains(where: { $0 == row.rowId }) + let contains = ["showTabsAsWindows"].contains(where: { $0 == row.rowId }) return contains && Preferences.onlyShowApplications() } @@ -312,6 +289,10 @@ class ShowHideIllustratedView { class Popover: NSPopover { static let shared = Popover() private var hidingInitiated = true + private var searchQuery = "" + private var searchMatchRanges: ((String, String) -> [Range])? + private var currentMessage = "" + private weak var currentMessageLabel: NSTextField? override init() { super.init() @@ -328,6 +309,12 @@ class Popover: NSPopover { performClose(nil) } + func updateSearchContext(_ query: String, _ searchMatchRanges: @escaping (String, String) -> [Range]) { + searchQuery = query + self.searchMatchRanges = searchMatchRanges + applySearchHighlightToCurrentMessage() + } + func show(event: NSEvent, positioningView: NSView, message: String, extraView: NSView? = nil) { if !hidingInitiated { return } hidingInitiated = false @@ -348,6 +335,9 @@ class Popover: NSPopover { actualView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10), actualView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10), ]) + currentMessage = message + currentMessageLabel = label + applySearchHighlightToCurrentMessage() contentViewController?.view = view // Convert the mouse location to the positioning view's coordinate system let locationInWindow = event.locationInWindow @@ -355,11 +345,45 @@ class Popover: NSPopover { let rect = CGRect(origin: locationInPositioningView, size: .zero) show(relativeTo: rect, of: positioningView, preferredEdge: .minX) } + + private func applySearchHighlightToCurrentMessage() { + guard let label = currentMessageLabel else { return } + let attributes: [NSAttributedString.Key: Any] = [ + .font: label.font ?? NSFont.systemFont(ofSize: 12), + .foregroundColor: label.textColor ?? NSColor.labelColor + ] + let attributed = NSMutableAttributedString(string: currentMessage, attributes: attributes) + guard let searchMatchRanges, !currentMessage.isEmpty else { + label.attributedStringValue = attributed + return + } + let ranges = searchMatchRanges(searchQuery, currentMessage) + guard !ranges.isEmpty else { + label.attributedStringValue = attributed + return + } + ranges.compactMap { + characterRangeToNSRange($0, in: currentMessage) + }.forEach { + attributed.addAttribute(.backgroundColor, value: NSColor.systemYellow.withAlphaComponent(0.5), range: $0) + attributed.addAttribute(.foregroundColor, value: NSColor(calibratedWhite: 0.12, alpha: 1), range: $0) + } + label.attributedStringValue = attributed + } + + private func characterRangeToNSRange(_ range: Range, in text: String) -> NSRange? { + if range.lowerBound < 0 || range.upperBound > text.count || range.isEmpty { return nil } + let start = text.index(text.startIndex, offsetBy: range.lowerBound) + let end = text.index(text.startIndex, offsetBy: range.upperBound) + return NSRange(start.. NSView { customizeStyleButton = NSButton(title: getCustomizeStyleButtonTitle(), target: self, action: #selector(showCustomizeStyleSheet)) @@ -380,33 +405,63 @@ class AppearanceTab: NSObject { private static func makeView() -> NSStackView { let appearanceView = makeAppearanceView() let multipleScreensView = makeMultipleScreensView() - let view = TableGroupSetView(originalViews: [appearanceView, multipleScreensView, animationsButton]) + let view = TableGroupSetView(originalViews: [appearanceView, multipleScreensView, animationsButton], titleTableGroupSpacing: 15, bottomPadding: 0) view.translatesAutoresizingMaskIntoConstraints = false view.widthAnchor.constraint(equalToConstant: view.fittingSize.width).isActive = true return view } private static func makeAppearanceView() -> NSView { - let table = TableGroupView(title: NSLocalizedString("Appearance", comment: ""), - subTitle: NSLocalizedString("Switch between 3 different styles. You can customize them.", comment: ""), - width: PreferencesWindow.width) + let table = TableGroupView(subTitle: NSLocalizedString("Switch between 3 different styles. You can customize them.", comment: ""), + width: SettingsWindow.contentWidth) table.addRow(secondaryViews: [LabelAndControl.makeImageRadioButtons("appearanceStyle", AppearanceStylePreference.allCases, extraAction: { _ in toggleCustomizeStyleButton() + updatePreviewSelectedWindowState() }, buttonSpacing: 10)], secondaryViewsAlignment: .centerX) table.addRow(leftText: NSLocalizedString("Size", comment: ""), rightViews: [LabelAndControl.makeSegmentedControl("appearanceSize", AppearanceSizePreference.allCases, segmentWidth: 100)]) table.addRow(leftText: NSLocalizedString("Theme", comment: ""), rightViews: [LabelAndControl.makeSegmentedControl("appearanceTheme", AppearanceThemePreference.allCases, segmentWidth: 100)]) + addAfterKeysReleasedRow(table) + addPreviewSelectedWindowRow(table) table.addRow(rightViews: customizeStyleButton) - table.fit() return table } + private static func addAfterKeysReleasedRow(_ table: TableGroupView) { + table.addRow(leftText: NSLocalizedString("After keys are released", comment: ""), + rightViews: [LabelAndControl.makeDropdown("shortcutStyle", ShortcutStylePreference.allCases)]) + } + + private static func addPreviewSelectedWindowRow(_ table: TableGroupView) { + previewSelectedWindowRowInfo = table.addRow(leftText: NSLocalizedString("Preview selected window", comment: ""), + rightViews: [LabelAndControl.makeSwitch("previewFocusedWindow")]) + updatePreviewSelectedWindowState() + } + + static func updatePreviewSelectedWindowState() { + guard let rowInfo = previewSelectedWindowRowInfo else { return } + let isEnabled = !isPreviewSelectedWindowDisabled() + rowInfo.leftViews?.forEach { view in + if let textField = view as? NSTextField { + textField.textColor = isEnabled ? .textColor : .gray + } + } + rowInfo.rightViews?.forEach { view in + if let switchControl = view as? Switch { + switchControl.isEnabled = isEnabled + } + } + } + + private static func isPreviewSelectedWindowDisabled() -> Bool { + return Preferences.onlyShowApplications() + } + private static func makeMultipleScreensView() -> NSView { - let table = TableGroupView(title: NSLocalizedString("Multiple screens", comment: ""), width: PreferencesWindow.width) + let table = TableGroupView(title: NSLocalizedString("Multiple screens", comment: ""), width: SettingsWindow.contentWidth) _ = table.addRow(leftText: NSLocalizedString("Show on", comment: ""), rightViews: LabelAndControl.makeDropdown("showOnScreen", ShowOnScreenPreference.allCases)) - table.fit() return table } @@ -430,10 +485,10 @@ class AppearanceTab: NSObject { } @objc static func showCustomizeStyleSheet() { - App.app.preferencesWindow.beginSheet(customizeStyleSheet) + App.app.settingsWindow.beginSheetWithSearchHighlight(customizeStyleSheet) } @objc static func showAnimationsSheet() { - App.app.preferencesWindow.beginSheet(animationsSheet) + App.app.settingsWindow.beginSheetWithSearchHighlight(animationsSheet) } } diff --git a/src/ui/preferences-window/tabs/appearance/CustomizeStyleSheet.swift b/src/ui/settings-window/tabs/appearance/CustomizeStyleSheet.swift similarity index 94% rename from src/ui/preferences-window/tabs/appearance/CustomizeStyleSheet.swift rename to src/ui/settings-window/tabs/appearance/CustomizeStyleSheet.swift index 15a78785..c9a49247 100644 --- a/src/ui/preferences-window/tabs/appearance/CustomizeStyleSheet.swift +++ b/src/ui/settings-window/tabs/appearance/CustomizeStyleSheet.swift @@ -53,15 +53,17 @@ class CustomizeStyleSheet: SheetWindow { })) titleTruncation = TableGroupView.Row(leftTitle: NSLocalizedString("Title truncation", comment: ""), rightViews: LabelAndControl.makeRadioButtons("titleTruncation", TitleTruncationPreference.allCases)) - let showAppWindowsInfo = LabelAndControl.makeInfoButton(onMouseEntered: { (event, view) in + let showAppWindowsTooltip = NSLocalizedString("Show an item in the switcher for each window, or for each application. Windows will be focused, whereas applications will be activated.", comment: "") + let showAppWindowsInfo = LabelAndControl.makeInfoButton(searchableTooltipTexts: [showAppWindowsTooltip], onMouseEntered: { (event, view) in Popover.shared.show(event: event, positioningView: view, - message: NSLocalizedString("Show an item in the switcher for each window, or for each application. Windows will be focused, whereas applications will be activated.", comment: "")) + message: showAppWindowsTooltip) }, onMouseExited: { (event, view) in Popover.shared.hide() }) showAppsOrWindows = TableGroupView.Row(leftTitle: NSLocalizedString("Show in switcher", comment: ""), rightViews: LabelAndControl.makeRadioButtons("showAppsOrWindows", ShowAppsOrWindowsPreference.allCases, extraAction: { _ in self.showHideIllustratedView.setStateOnApplications() + AppearanceTab.updatePreviewSelectedWindowState() self.toggleAppNamesWindowTitles() self.showAppsOrWindowsIllustratedImage() }) + [showAppWindowsInfo]) @@ -87,7 +89,6 @@ class CustomizeStyleSheet: SheetWindow { table.onMouseExited = { event, view in IllustratedImageThemeView.resetImage(self.illustratedImageView, event, view) } - table.fit() let view = TableGroupSetView(originalViews: [table], padding: 0) return view } @@ -101,7 +102,6 @@ class CustomizeStyleSheet: SheetWindow { table.onMouseExited = { event, view in IllustratedImageThemeView.resetImage(self.illustratedImageView, event, view) } - table.fit() let view = TableGroupSetView(originalViews: [table], padding: 0) toggleAppNamesWindowTitles() return view @@ -111,7 +111,6 @@ class CustomizeStyleSheet: SheetWindow { let table = makeAppWindowTableGroupView() table.addNewTable() table.addRow(titleTruncation) - table.fit() let view = TableGroupSetView(originalViews: [table], padding: 0) toggleAppNamesWindowTitles() return view diff --git a/src/ui/preferences-window/tabs/controls/AdditionalControlsSheet.swift b/src/ui/settings-window/tabs/controls/AdditionalControlsSheet.swift similarity index 95% rename from src/ui/preferences-window/tabs/controls/AdditionalControlsSheet.swift rename to src/ui/settings-window/tabs/controls/AdditionalControlsSheet.swift index 5a308d31..fb554870 100644 --- a/src/ui/preferences-window/tabs/controls/AdditionalControlsSheet.swift +++ b/src/ui/settings-window/tabs/controls/AdditionalControlsSheet.swift @@ -17,12 +17,12 @@ class AdditionalControlsSheet: SheetWindow { ControlsTab.arrowKeysEnabledCallback(ControlsTab.arrowKeysCheckbox) ControlsTab.vimKeysEnabledCallback(ControlsTab.vimKeysCheckbox) let table1 = TableGroupView(title: NSLocalizedString("Additional controls", comment: ""), - width: PreferencesWindow.width) + width: SheetWindow.width) _ = table1.addRow(enableArrows) _ = table1.addRow(enableVimKeys) _ = table1.addRow(enableMouse) let table2 = TableGroupView(title: NSLocalizedString("Miscellaneous", comment: ""), - width: PreferencesWindow.width) + width: SheetWindow.width) _ = table2.addRow(enableCursorFollowFocus) _ = table2.addRow(enableTrackpadHapticFeedback) let view = TableGroupSetView(originalViews: [table1, table2], padding: 0) diff --git a/src/ui/preferences-window/tabs/controls/ControlsTab.swift b/src/ui/settings-window/tabs/controls/ControlsTab.swift similarity index 94% rename from src/ui/preferences-window/tabs/controls/ControlsTab.swift rename to src/ui/settings-window/tabs/controls/ControlsTab.swift index 113298da..1b2c910b 100644 --- a/src/ui/preferences-window/tabs/controls/ControlsTab.swift +++ b/src/ui/settings-window/tabs/controls/ControlsTab.swift @@ -76,10 +76,7 @@ class ControlsTab { // trigger shortcutChanged for these shortcuts to trigger .restrictModifiers [holdShortcut, holdShortcut2, holdShortcut3].forEach { ControlsTab.shortcutChangedCallback($0[1] as! NSControl) } [nextWindowShortcut, nextWindowShortcut2, nextWindowShortcut3].forEach { ControlsTab.shortcutChangedCallback($0[0] as! NSControl) } - let tabs = StackView(tableGroupViews, .vertical) - tabs.translatesAutoresizingMaskIntoConstraints = false - tabs.fit() - let table = TableGroupView(hasHeader: true, width: PreferencesWindow.width) + let table = TableGroupView(hasHeader: true, width: SettingsWindow.contentWidth) let tab = NSSegmentedControl(labels: [ NSLocalizedString("Shortcut 1", comment: ""), NSLocalizedString("Shortcut 2", comment: ""), @@ -88,17 +85,15 @@ class ControlsTab { ], trackingMode: .selectOne, target: self, action: #selector(switchTab(_:))) tab.selectedSegment = 0 tab.segmentStyle = .automatic - tab.widthAnchor.constraint(equalToConstant: PreferencesWindow.width).isActive = true + tab.widthAnchor.constraint(equalToConstant: SettingsWindow.contentWidth).isActive = true table.addHeader(views: [tab]) let additionalControlsButton = NSButton(title: NSLocalizedString("Additional controls…", comment: ""), target: self, action: #selector(ControlsTab.showAdditionalControlsSettings)) let shortcutsButton = NSButton(title: NSLocalizedString("Shortcuts when active…", comment: ""), target: self, action: #selector(ControlsTab.showShortcutsSettings)) let tools = StackView([additionalControlsButton, shortcutsButton], .horizontal) - let view = TableGroupSetView(originalViews: [table, tab1View, tab2View, tab3View, tab6View], toolsViews: [tools], toolsAlignment: .trailing) - view.translatesAutoresizingMaskIntoConstraints = false + let view = TableGroupSetView(originalViews: [table, tab1View, tab2View, tab3View, tab6View], toolsViews: [tools], bottomPadding: 0, toolsAlignment: .trailing) shortcutsWhenActiveSheet = ShortcutsWhenActiveSheet() additionalControlsSheet = AdditionalControlsSheet() ControlsTab.switchIndexTab(0) - view.fit() return view } @@ -113,7 +108,7 @@ class ControlsTab { private static func gestureTab(_ index: Int) -> TableGroupView { let label = NSLocalizedString("You may need to disable some conflicting system gestures", comment: "") let button = NSButton(title: NSLocalizedString("Open Trackpad Settings…", comment: ""), target: self, action: #selector(openSystemGestures(_:))) - let infoBtn = LabelAndControl.makeInfoButton(onMouseEntered: { event, view in + let infoBtn = LabelAndControl.makeInfoButton(searchableTooltipTexts: [label], onMouseEntered: { event, view in Popover.shared.show(event: event, positioningView: view, message: label, extraView: button) }) let gesture = LabelAndControl.makeDropdown("nextWindowGesture", GesturePreference.allCases) @@ -138,10 +133,8 @@ class ControlsTab { let showFullscreenWindows = LabelAndControl.makeDropdown(Preferences.indexToName("showFullscreenWindows", index), ShowHowPreference.allCases.filter { $0 != .showAtTheEnd }) // this filter is ok for serialization because the filtered value is last in the enum let showWindowlessApps = LabelAndControl.makeDropdown(Preferences.indexToName("showWindowlessApps", index), ShowHowPreference.allCases) let windowOrder = LabelAndControl.makeDropdown(Preferences.indexToName("windowOrder", index), WindowOrderPreference.allCases) - let shortcutStyle = LabelAndControl.makeDropdown(Preferences.indexToName("shortcutStyle", index), ShortcutStylePreference.allCases) - let table = TableGroupView(width: PreferencesWindow.width) + let table = TableGroupView(width: SettingsWindow.contentWidth) table.addRow(TableGroupView.Row(leftTitle: NSLocalizedString("Trigger shortcut", comment: ""), rightViews: trigger)) - table.addRow(TableGroupView.Row(leftTitle: NSLocalizedString("After release", comment: ""), rightViews: [shortcutStyle])) table.addNewTable() table.addRow(leftViews: [TableGroupView.makeText(NSLocalizedString("Show windows from applications", comment: ""))], rightViews: [appsToShow]) table.addRow(leftViews: [TableGroupView.makeText(NSLocalizedString("Show windows from Spaces", comment: ""))], rightViews: [spacesToShow]) @@ -151,7 +144,6 @@ class ControlsTab { table.addRow(TableGroupView.Row(leftTitle: NSLocalizedString("Show fullscreen windows", comment: ""), rightViews: [showFullscreenWindows])) table.addRow(TableGroupView.Row(leftTitle: NSLocalizedString("Show apps with no open window", comment: ""), rightViews: [showWindowlessApps])) table.addRow(TableGroupView.Row(leftTitle: NSLocalizedString("Order windows by", comment: ""), rightViews: [windowOrder])) - table.fit() return table } @@ -171,11 +163,11 @@ class ControlsTab { } @objc static func showShortcutsSettings() { - App.app.preferencesWindow.beginSheet(shortcutsWhenActiveSheet) + App.app.settingsWindow.beginSheetWithSearchHighlight(shortcutsWhenActiveSheet) } @objc static func showAdditionalControlsSettings() { - App.app.preferencesWindow.beginSheet(additionalControlsSheet) + App.app.settingsWindow.beginSheetWithSearchHighlight(additionalControlsSheet) } private static func addShortcut(_ triggerPhase: ShortcutTriggerPhase, _ scope: ShortcutScope, _ shortcut: Shortcut, _ controlId: String, _ index: Int?) { @@ -278,9 +270,9 @@ class ControlsTab { } } if !conflicts.isEmpty { - // if the app is still launching (App.app.preferencesWindow == nil) and we have a conflict + // if the app is still launching (App.app.settingsWindow == nil) and we have a conflict // then we don't show the user a dialog, and simply disable vim keys - if App.app.preferencesWindow == nil || !shouldClearConflictingShortcuts(conflicts.map { $0.value }) { + if App.app.settingsWindow == nil || !shouldClearConflictingShortcuts(conflicts.map { $0.value }) { return false } conflicts.forEach { diff --git a/src/ui/preferences-window/tabs/controls/ShortcutsWhenActiveSheet.swift b/src/ui/settings-window/tabs/controls/ShortcutsWhenActiveSheet.swift similarity index 100% rename from src/ui/preferences-window/tabs/controls/ShortcutsWhenActiveSheet.swift rename to src/ui/settings-window/tabs/controls/ShortcutsWhenActiveSheet.swift