perf: improve performance of the settings window
This commit is contained in:
1 parent
5bd746d342
commit
3059dc2356
22 files changed
+2257
-941
No files matched your search
@@ -39,6 +39,7 @@
|
||||
5E1EC110000000000000C002 /* SelectionResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1EC110000000000000C001 /* SelectionResolver.swift */; };
|
||||
5E1EC110000000000000C003 /* SelectionResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1EC110000000000000C001 /* SelectionResolver.swift */; };
|
||||
5E1EC110000000000000C102 /* SelectionResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1EC110000000000000C101 /* SelectionResolverTests.swift */; };
|
||||
D04BAE2200000000ED004T11 /* SettingsSearchHighlightTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE2200000000ED004T12 /* SettingsSearchHighlightTests.swift */; };
|
||||
5F04852E2F09AC9700235D4A /* WindowCaptureEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F04852D2F09AC9700235D4A /* WindowCaptureEvents.swift */; };
|
||||
5F0A63032F82D97C00AE4C38 /* LicenseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F0A63012F82D97C00AE4C38 /* LicenseManager.swift */; };
|
||||
5F0A63042F82D97C00AE4C38 /* LicenseState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F0A63022F82D97C00AE4C38 /* LicenseState.swift */; };
|
||||
@@ -106,6 +107,7 @@
|
||||
5FDF9EB12FB3584300703843 /* Sparkle in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 5FDF9EA52FB3575B00703843 /* Sparkle */; };
|
||||
64C6A0192E08311D01D96E4E /* menubar-1.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 126D5AA615CEE89F73C22DA7 /* menubar-1.pdf */; };
|
||||
6D42BE46F595D567EDBF559A /* DynamicColorTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA0000000000000DCFFF1 /* DynamicColorTextField.swift */; };
|
||||
D04BAE2100000000ED003001 /* LightLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE2100000000ED003002 /* LightLabel.swift */; };
|
||||
7D589F3A0C8DF93BA9E094C5 /* menubar-2.pdf in Resources */ = {isa = PBXBuildFile; fileRef = BC07818D6FCBEFC756C64296 /* menubar-2.pdf */; };
|
||||
8B4A0B173EEB0648424219B5 /* ProBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F0A63072F82D97C00AE4C3A /* ProBadgeView.swift */; };
|
||||
A053BAD6681629878E72B1F7 /* ProBadgeViewSegmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25966EE691FC6871588D796B /* ProBadgeViewSegmentTests.swift */; };
|
||||
@@ -184,6 +186,7 @@
|
||||
BF0CBBBB0000000000001001 /* OnActionExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0CBBBB0000000000000001 /* OnActionExtensionTests.swift */; };
|
||||
C79742CA78D3CC74B6920437 /* menubar-0.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 02540420CE27E6C973652426 /* menubar-0.pdf */; };
|
||||
D04BA0000000000000DCFFF2 /* DynamicColorTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA0000000000000DCFFF1 /* DynamicColorTextField.swift */; };
|
||||
D04BAE2100000000ED003003 /* LightLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE2100000000ED003002 /* LightLabel.swift */; };
|
||||
D04BA00000000000C0DE0002 /* SettingsSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA00000000000C0DE0001 /* SettingsSearch.swift */; };
|
||||
D04BA00000000000C0DE0012 /* ShortcutAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA00000000000C0DE0010 /* ShortcutAction.swift */; };
|
||||
D04BA00000000000C0DE0021 /* SwitcherSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA00000000000C0DE0020 /* SwitcherSession.swift */; };
|
||||
@@ -243,6 +246,12 @@
|
||||
D04BA9B7A964C5FABFEB9DA2 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA053A0E0157E817B2E04 /* Localizable.strings */; };
|
||||
D04BA9D63A88AF2C20A1E656 /* SF-Pro-Text-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = D04BA60A6D08C041F5BF8CC9 /* SF-Pro-Text-Regular.otf */; };
|
||||
D04BAA16F21AC41A23DDA63D /* ControlsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA1B438BF3E65044D1138 /* ControlsTab.swift */; };
|
||||
D04BAE1F00000000ED001001 /* ShortcutEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE1F00000000ED001002 /* ShortcutEditor.swift */; };
|
||||
D04BAE2000000000ED002001 /* SettingsSearchIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE2000000000ED002002 /* SettingsSearchIndex.swift */; };
|
||||
D04BAE2200000000ED004001 /* SettingsSearchHighlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE2200000000ED004002 /* SettingsSearchHighlight.swift */; };
|
||||
D04BAE2200000000ED004003 /* SettingsSearchHighlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE2200000000ED004002 /* SettingsSearchHighlight.swift */; };
|
||||
D04BAE2200000000ED004T01 /* TableGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C961FB3DCF4906FEEF4A23D /* TableGroupView.swift */; };
|
||||
D04BAE2200000000ED004T03 /* SettingsSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA00000000000C0DE0001 /* SettingsSearch.swift */; };
|
||||
D04BAAAF5CFA991D3B078DB8 /* KeyboardEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA4A621A8A15660F973C1 /* KeyboardEvents.swift */; };
|
||||
D04BAAB5C4E78AE375EA7D3C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BAA56B4EEAF17F767C7FD /* Localizable.strings */; };
|
||||
D04BAABE804F3769CE22BEB6 /* HyperlinkLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA49E45BFFF3D9FC60E43 /* HyperlinkLabel.swift */; };
|
||||
@@ -341,6 +350,7 @@
|
||||
5883F0A729A5182C0071DB65 /* TrackpadEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadEvents.swift; sourceTree = "<group>"; };
|
||||
5E1EC110000000000000C001 /* SelectionResolver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectionResolver.swift; sourceTree = "<group>"; };
|
||||
5E1EC110000000000000C101 /* SelectionResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionResolverTests.swift; sourceTree = "<group>"; };
|
||||
D04BAE2200000000ED004T12 /* SettingsSearchHighlightTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchHighlightTests.swift; sourceTree = "<group>"; };
|
||||
5F04852D2F09AC9700235D4A /* WindowCaptureEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCaptureEvents.swift; sourceTree = "<group>"; };
|
||||
5F05D0E62F31441A004A95F9 /* AGENTS.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = AGENTS.md; sourceTree = "<group>"; };
|
||||
5F05D0E72F31441A004A95F9 /* CLAUDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CLAUDE.md; sourceTree = "<group>"; };
|
||||
@@ -500,6 +510,7 @@
|
||||
BF0CAAAA0000000000000001 /* SearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
|
||||
BF0CBBBB0000000000000001 /* OnActionExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnActionExtensionTests.swift; sourceTree = "<group>"; };
|
||||
D04BA0000000000000DCFFF1 /* DynamicColorTextField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DynamicColorTextField.swift; sourceTree = "<group>"; };
|
||||
D04BAE2100000000ED003002 /* LightLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LightLabel.swift; sourceTree = "<group>"; };
|
||||
D04BA00000000000C0DE0001 /* SettingsSearch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsSearch.swift; sourceTree = "<group>"; };
|
||||
D04BA00000000000C0DE0010 /* ShortcutAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShortcutAction.swift; sourceTree = "<group>"; };
|
||||
D04BA00000000000C0DE0020 /* SwitcherSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwitcherSession.swift; sourceTree = "<group>"; };
|
||||
@@ -518,6 +529,9 @@
|
||||
D04BA1232AFEEFE90D5CC827 /* debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = debug.xcconfig; sourceTree = "<group>"; };
|
||||
D04BA18B55AA6C9EE41985D0 /* contributing.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = contributing.md; sourceTree = "<group>"; };
|
||||
D04BA1B438BF3E65044D1138 /* ControlsTab.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ControlsTab.swift; sourceTree = "<group>"; };
|
||||
D04BAE1F00000000ED001002 /* ShortcutEditor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShortcutEditor.swift; sourceTree = "<group>"; };
|
||||
D04BAE2000000000ED002002 /* SettingsSearchIndex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsSearchIndex.swift; sourceTree = "<group>"; };
|
||||
D04BAE2200000000ED004002 /* SettingsSearchHighlight.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsSearchHighlight.swift; sourceTree = "<group>"; };
|
||||
D04BA1D80F4EEF2A91BAD29C /* release.config.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = release.config.js; sourceTree = "<group>"; };
|
||||
D04BA1DF8CAB2FAB7FE9244B /* TileFontIconView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TileFontIconView.swift; sourceTree = "<group>"; };
|
||||
D04BA2526DC6726E0F7ACF7C /* LabelAndControl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LabelAndControl.swift; sourceTree = "<group>"; };
|
||||
@@ -787,6 +801,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D04BA1B438BF3E65044D1138 /* ControlsTab.swift */,
|
||||
D04BAE1F00000000ED001002 /* ShortcutEditor.swift */,
|
||||
69A2E38AF41E866D3CDE71BF /* OverrideClickResolver.swift */,
|
||||
5F21C9DF2C6E94240091F72F /* ShortcutsWhenActiveSheet.swift */,
|
||||
5F21C9E12C6E94410091F72F /* AdditionalControlsSheet.swift */,
|
||||
@@ -912,6 +927,7 @@
|
||||
25966EE691FC6871588D796B /* ProBadgeViewSegmentTests.swift */,
|
||||
4B4409E4B3D98C491503CE5D /* OverrideClickResolverTests.swift */,
|
||||
5E1EC110000000000000C101 /* SelectionResolverTests.swift */,
|
||||
D04BAE2200000000ED004T12 /* SettingsSearchHighlightTests.swift */,
|
||||
BF0C829EAA93A4C4ADB42CF8 /* Mocks.swift */,
|
||||
BF0C8C6810FF50908E637A48 /* ConcurrentScreenshots.swift */,
|
||||
BF0C855CD9DBCAEFE48F9EA5 /* AXUIElementTests.swift */,
|
||||
@@ -1425,6 +1441,7 @@
|
||||
children = (
|
||||
D04BACEE8D430B8CAAD8C4CD /* BoldLabel.swift */,
|
||||
D04BA0000000000000DCFFF1 /* DynamicColorTextField.swift */,
|
||||
D04BAE2100000000ED003002 /* LightLabel.swift */,
|
||||
D04BA49E45BFFF3D9FC60E43 /* HyperlinkLabel.swift */,
|
||||
D04BA7C836A8CE8C0B8D128B /* TextArea.swift */,
|
||||
D04BA5BD11C365C19461458C /* TextField.swift */,
|
||||
@@ -1467,6 +1484,8 @@
|
||||
D04BA3FA52AA40B827A72F10 /* tabs */,
|
||||
D04BA099FA155FB4D517D66B /* SettingsWindow.swift */,
|
||||
D04BA00000000000C0DE0001 /* SettingsSearch.swift */,
|
||||
D04BAE2000000000ED002002 /* SettingsSearchIndex.swift */,
|
||||
D04BAE2200000000ED004002 /* SettingsSearchHighlight.swift */,
|
||||
D04BAFF80A98CF287F85DA23 /* SidebarList.swift */,
|
||||
1C961FB3DCF4906FEEF4A23D /* TableGroupView.swift */,
|
||||
);
|
||||
@@ -1757,8 +1776,13 @@
|
||||
DC06402457ECCE6C902FEAE4 /* OverrideClickResolver.swift in Sources */,
|
||||
5E1EC110000000000000C003 /* SelectionResolver.swift in Sources */,
|
||||
5E1EC110000000000000C102 /* SelectionResolverTests.swift in Sources */,
|
||||
D04BAE2200000000ED004T11 /* SettingsSearchHighlightTests.swift in Sources */,
|
||||
D04BAE2200000000ED004003 /* SettingsSearchHighlight.swift in Sources */,
|
||||
D04BAE2200000000ED004T01 /* TableGroupView.swift in Sources */,
|
||||
D04BAE2200000000ED004T03 /* SettingsSearch.swift in Sources */,
|
||||
8B4A0B173EEB0648424219B5 /* ProBadgeView.swift in Sources */,
|
||||
6D42BE46F595D567EDBF559A /* DynamicColorTextField.swift in Sources */,
|
||||
D04BAE2100000000ED003001 /* LightLabel.swift in Sources */,
|
||||
BF0C8C5FA4B401A8C0F75B7A /* KeyboardEventsTestable.swift in Sources */,
|
||||
F00DCAFE0000000000001103 /* SearchTestable.swift in Sources */,
|
||||
BF0C875A28B98904F8F4A66A /* ATShortcut.swift in Sources */,
|
||||
@@ -1828,6 +1852,7 @@
|
||||
D04BA76A74267B1346D23687 /* GridView.swift in Sources */,
|
||||
D04BAB4EB890853B5B9B2C61 /* BoldLabel.swift in Sources */,
|
||||
D04BA0000000000000DCFFF2 /* DynamicColorTextField.swift in Sources */,
|
||||
D04BAE2100000000ED003003 /* LightLabel.swift in Sources */,
|
||||
D04BAABE804F3769CE22BEB6 /* HyperlinkLabel.swift in Sources */,
|
||||
5F7B6AFC2F3BAB98003E25E8 /* StatusIconsView.swift in Sources */,
|
||||
D04BAD2A7F2E8BF64EE982E9 /* TextArea.swift in Sources */,
|
||||
@@ -1899,8 +1924,11 @@
|
||||
D04BADA3F2DAB820D6FC6E75 /* AppCenterApplication.m in Sources */,
|
||||
6E800CF06EE430DCCF9CE47B /* ObjCExceptionCatcher.m in Sources */,
|
||||
D04BAA16F21AC41A23DDA63D /* ControlsTab.swift in Sources */,
|
||||
D04BAE1F00000000ED001001 /* ShortcutEditor.swift in Sources */,
|
||||
DD6771EE8ACE714C2599FEB7 /* OverrideClickResolver.swift in Sources */,
|
||||
D04BADE4DDF7597749FB13BD /* SettingsWindow.swift in Sources */,
|
||||
D04BAE2000000000ED002001 /* SettingsSearchIndex.swift in Sources */,
|
||||
D04BAE2200000000ED004001 /* SettingsSearchHighlight.swift in Sources */,
|
||||
BF0C8CC5057406014FD612CC /* ATShortcut.swift in Sources */,
|
||||
D04BA00000000000C0DE0012 /* ShortcutAction.swift in Sources */,
|
||||
D04BA00000000000C0DE0021 /* SwitcherSession.swift in Sources */,
|
||||
@@ -2343,7 +2371,6 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
@@ -2408,7 +2435,6 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
SWIFT_VERSION = 5.8
|
||||
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.13
|
||||
CODE_SIGNING_REQUIRED = NO // no need for codesign
|
||||
CODE_SIGNING_ALLOWED = NO // no need for codesign
|
||||
ONLY_ACTIVE_ARCH = YES // only need to run on the host machine
|
||||
|
||||
@@ -1,34 +1,63 @@
|
||||
import Cocoa
|
||||
|
||||
/// `NSPopUpButton` configured to look like System Settings popups and — critically — to size
|
||||
/// itself to the *currently-selected* item rather than the widest one (the AppKit default).
|
||||
///
|
||||
/// There is no public API to opt into "size to current item" — `NSPopUpButton` is hardcoded to
|
||||
/// reserve room for its widest item so the chrome doesn't visually resize when the user picks a
|
||||
/// different selection. To get pixel-perfect bezel + arrow + padding metrics, the only viable
|
||||
/// path is to override `intrinsicContentSize` and have AppKit measure a *probe* button that
|
||||
/// happens to contain just the current title.
|
||||
///
|
||||
/// The previous version did that probe measurement on *every* `intrinsicContentSize` query.
|
||||
/// AppKit queries the property many times per layout cycle, and the `_windowChangedKeyState`
|
||||
/// cascade fires a fresh layout cycle on every focus change — so we were running `sizeToFit`
|
||||
/// (which triggers TextKit2 layout on Tahoe) ~50 times per cascade × ~25 popups in Settings.
|
||||
/// Roughly 50ms of pure overhead per focus switch from this one method.
|
||||
///
|
||||
/// Fix: keep the probe technique (preserves pixel-identical sizing), cache the result keyed by
|
||||
/// the displayed title. The cache is invalidated automatically next time `title` changes (which
|
||||
/// happens on every selection mutation), so we don't have to override every individual mutator
|
||||
/// path. AppKit's repeated queries hit the cache in O(1).
|
||||
class PopupButtonLikeSystemSettings: NSPopUpButton {
|
||||
private var cachedIntrinsicSize: NSSize?
|
||||
private var cachedIntrinsicSizeTitle: String?
|
||||
|
||||
convenience init() {
|
||||
self.init(frame: .zero)
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
// showsBorderOnlyWhileMouseInside = true
|
||||
// isBordered = false
|
||||
// setButtonType(.switch)
|
||||
// let cell = cell! as! NSPopUpButtonCell
|
||||
// onAction = { _ in self.sizeToFit() }
|
||||
// cell.bezelStyle = .regularSquare
|
||||
// cell.arrowPosition = .arrowAtBottom
|
||||
// cell.imagePosition = .imageOverlaps
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: NSSize {
|
||||
if let selectedItem {
|
||||
let fakePopUpButton = NSPopUpButton()
|
||||
fakePopUpButton.addItem(withTitle: title)
|
||||
fakePopUpButton.item(at: 0)!.image = selectedItem.image
|
||||
let fakeCell = fakePopUpButton.cell! as! NSPopUpButtonCell
|
||||
let currentCell = cell! as! NSPopUpButtonCell
|
||||
fakeCell.bezelStyle = currentCell.bezelStyle
|
||||
fakeCell.arrowPosition = currentCell.arrowPosition
|
||||
fakeCell.imagePosition = currentCell.imagePosition
|
||||
fakePopUpButton.showsBorderOnlyWhileMouseInside = showsBorderOnlyWhileMouseInside
|
||||
fakePopUpButton.sizeToFit()
|
||||
return fakePopUpButton.intrinsicContentSize
|
||||
} else {
|
||||
return super.intrinsicContentSize
|
||||
let currentTitle = title
|
||||
if let cached = cachedIntrinsicSize, cachedIntrinsicSizeTitle == currentTitle {
|
||||
return cached
|
||||
}
|
||||
let size: NSSize
|
||||
if let selectedItem {
|
||||
size = measureSize(for: currentTitle, image: selectedItem.image)
|
||||
} else {
|
||||
size = super.intrinsicContentSize
|
||||
}
|
||||
cachedIntrinsicSize = size
|
||||
cachedIntrinsicSizeTitle = currentTitle
|
||||
return size
|
||||
}
|
||||
|
||||
/// Probe measurement — pixel-identical to what AppKit would compute for a popup whose menu
|
||||
/// has a single item with this title. Heavy (full TextKit layout via `sizeToFit`), hence
|
||||
/// the caller caches the result.
|
||||
private func measureSize(for titleText: String, image: NSImage?) -> NSSize {
|
||||
let probe = NSPopUpButton()
|
||||
probe.addItem(withTitle: titleText)
|
||||
probe.item(at: 0)!.image = image
|
||||
let probeCell = probe.cell! as! NSPopUpButtonCell
|
||||
let selfCell = cell! as! NSPopUpButtonCell
|
||||
probeCell.bezelStyle = selfCell.bezelStyle
|
||||
probeCell.arrowPosition = selfCell.arrowPosition
|
||||
probeCell.imagePosition = selfCell.imagePosition
|
||||
probe.showsBorderOnlyWhileMouseInside = showsBorderOnlyWhileMouseInside
|
||||
probe.sizeToFit()
|
||||
return probe.intrinsicContentSize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import Cocoa
|
||||
|
||||
/// A lightweight, read-only label drawn directly via `NSAttributedString.draw(in:)` — bypasses
|
||||
/// `NSTextField` / `NSCell` / TextKit2 / SwiftUI rendering paths.
|
||||
///
|
||||
/// `NSTextField(labelWithString:)` is a Swiss-army knife: editable, selectable, IME-aware,
|
||||
/// focusable, find-and-replace-capable, accessibility-integrated, dynamic-type-scaling, etc. For
|
||||
/// each of those features it pays a per-redraw cost. With hundreds of labels in a window and a
|
||||
/// `_windowChangedKeyState` cascade firing on every focus change, those costs compound into a
|
||||
/// perceptible hang.
|
||||
///
|
||||
/// `LightLabel` skips that machinery: it's an `NSView` whose `draw(_:)` renders the text via
|
||||
/// `NSAttributedString.draw(in:)` (which goes through CoreText, not TextKit2). Per-redraw cost
|
||||
/// is a fraction of `NSTextField`'s. Functionality is intentionally limited to "show some text":
|
||||
/// no editing, no selection, no focus ring, no IME. Accessibility is registered as `.staticText`.
|
||||
///
|
||||
/// Drop-in compatible with the subset of `NSTextField` properties our row-label sites use:
|
||||
/// `stringValue`, `attributedStringValue`, `textColor`, `font`, `alignment`, `lineBreakMode`,
|
||||
/// `maximumNumberOfLines`. Search-highlight is built in via `applyHighlight` / `clearHighlight`.
|
||||
final class LightLabel: NSView {
|
||||
/// The plain-text content. Setting this clears any explicit `attributedStringValue` override.
|
||||
var stringValue: String = "" {
|
||||
didSet {
|
||||
guard stringValue != oldValue else { return }
|
||||
attributedOverride = nil
|
||||
invalidateContentCache()
|
||||
setAccessibilityLabel(stringValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional rich-text override. When non-nil, takes precedence over `stringValue` + the
|
||||
/// font/color/alignment properties for rendering. Used by callers that need attribute runs
|
||||
/// (e.g. mixed gradient text). Setting `stringValue` clears this back to nil.
|
||||
var attributedStringValue: NSAttributedString? {
|
||||
get { attributedOverride }
|
||||
set {
|
||||
attributedOverride = newValue
|
||||
if let newValue { stringValueBacking = newValue.string }
|
||||
invalidateContentCache()
|
||||
}
|
||||
}
|
||||
|
||||
var textColor: NSColor = .labelColor {
|
||||
didSet { if textColor != oldValue { needsDisplay = true } }
|
||||
}
|
||||
var font: NSFont = NSFont.systemFont(ofSize: NSFont.systemFontSize) {
|
||||
didSet {
|
||||
guard font != oldValue else { return }
|
||||
invalidateContentCache()
|
||||
}
|
||||
}
|
||||
var alignment: NSTextAlignment = .left {
|
||||
didSet { if alignment != oldValue { needsDisplay = true } }
|
||||
}
|
||||
var lineBreakMode: NSLineBreakMode = .byTruncatingTail {
|
||||
didSet {
|
||||
guard lineBreakMode != oldValue else { return }
|
||||
invalidateContentCache()
|
||||
}
|
||||
}
|
||||
var maximumNumberOfLines: Int = 1 {
|
||||
didSet {
|
||||
guard maximumNumberOfLines != oldValue else { return }
|
||||
invalidateContentCache()
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional foreground tint applied to `highlightRanges` (typically the search-match color
|
||||
/// rendered on top of the yellow rounded background). When `highlightRanges` is empty, this
|
||||
/// has no effect.
|
||||
var highlightForegroundColor: NSColor = .labelColor
|
||||
private(set) var highlightRanges: [NSRange] = []
|
||||
|
||||
// Internal — the renderer reaches through these. We keep `stringValueBacking` separate from
|
||||
// `stringValue`'s storage so an attributedStringValue assignment can still expose the right
|
||||
// string to `searchableStrings`-style consumers without losing the original setter semantics.
|
||||
private var stringValueBacking: String { get { stringValue } set { stringValueRaw = newValue } }
|
||||
private var stringValueRaw: String {
|
||||
get { return stringValue }
|
||||
set { stringValue = newValue }
|
||||
}
|
||||
private var attributedOverride: NSAttributedString?
|
||||
private var cachedIntrinsicSize: NSSize?
|
||||
private var lastIntrinsicWidth: CGFloat = -1
|
||||
|
||||
init(_ string: String = "") {
|
||||
super.init(frame: .zero)
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
if !string.isEmpty {
|
||||
stringValue = string
|
||||
setAccessibilityLabel(string)
|
||||
}
|
||||
setAccessibilityRole(.staticText)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("Class only supports programmatic initialization")
|
||||
}
|
||||
|
||||
override var isFlipped: Bool { true }
|
||||
override var isOpaque: Bool { false }
|
||||
override func isAccessibilityElement() -> Bool { true }
|
||||
|
||||
/// Mark the given character ranges to render with `highlightForegroundColor` (typically the
|
||||
/// search-match color). Triggers a redraw. The yellow rounded background is applied
|
||||
/// separately via `SettingsWindow.applyRoundedHighlights`.
|
||||
func applyHighlight(ranges: [NSRange], foregroundColor: NSColor) {
|
||||
highlightRanges = ranges
|
||||
highlightForegroundColor = foregroundColor
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
func clearHighlight() {
|
||||
guard !highlightRanges.isEmpty else { return }
|
||||
highlightRanges = []
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
private func invalidateContentCache() {
|
||||
cachedIntrinsicSize = nil
|
||||
invalidateIntrinsicContentSize()
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
private func paragraphStyle() -> NSParagraphStyle {
|
||||
let p = NSMutableParagraphStyle()
|
||||
p.alignment = alignment
|
||||
p.lineBreakMode = lineBreakMode
|
||||
return p
|
||||
}
|
||||
|
||||
/// The attributed string actually rendered. Built from `attributedStringValue` if set,
|
||||
/// otherwise from `stringValue` + the textColor/font/alignment/lineBreakMode properties.
|
||||
/// Any `highlightRanges` are layered on top of the base.
|
||||
private func renderedAttributedString() -> NSAttributedString {
|
||||
let base: NSAttributedString
|
||||
if let attributedOverride {
|
||||
base = attributedOverride
|
||||
} else {
|
||||
base = NSAttributedString(string: stringValue, attributes: [
|
||||
.font: font,
|
||||
.foregroundColor: textColor,
|
||||
.paragraphStyle: paragraphStyle(),
|
||||
])
|
||||
}
|
||||
guard !highlightRanges.isEmpty else { return base }
|
||||
let mutable = NSMutableAttributedString(attributedString: base)
|
||||
for range in highlightRanges where range.location >= 0 && range.upperBound <= mutable.length {
|
||||
mutable.addAttribute(.foregroundColor, value: highlightForegroundColor, range: range)
|
||||
}
|
||||
return mutable
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: NSSize {
|
||||
if let cached = cachedIntrinsicSize, lastIntrinsicWidth == bounds.width {
|
||||
return cached
|
||||
}
|
||||
guard !stringValue.isEmpty || attributedOverride != nil else {
|
||||
let size = NSSize(width: 0, height: ceil(font.ascender - font.descender))
|
||||
cachedIntrinsicSize = size
|
||||
lastIntrinsicWidth = bounds.width
|
||||
return size
|
||||
}
|
||||
let attr = renderedAttributedString()
|
||||
let size: NSSize
|
||||
if maximumNumberOfLines == 1 {
|
||||
let measured = attr.size()
|
||||
size = NSSize(width: ceil(measured.width), height: ceil(measured.height))
|
||||
} else {
|
||||
let width = bounds.width > 0 ? bounds.width : 10000
|
||||
let rect = attr.boundingRect(
|
||||
with: NSSize(width: width, height: .greatestFiniteMagnitude),
|
||||
options: [.usesLineFragmentOrigin, .usesFontLeading]
|
||||
)
|
||||
size = NSSize(width: ceil(rect.width), height: ceil(rect.height))
|
||||
}
|
||||
cachedIntrinsicSize = size
|
||||
lastIntrinsicWidth = bounds.width
|
||||
return size
|
||||
}
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
guard !stringValue.isEmpty || attributedOverride != nil else { return }
|
||||
let attr = renderedAttributedString()
|
||||
let drawingRect: NSRect
|
||||
if maximumNumberOfLines == 1 {
|
||||
// Vertically center single-line text within the view bounds. NSAttributedString.draw
|
||||
// draws starting at the rect's top; with `isFlipped = true`, top is `y = 0`, so we
|
||||
// shift by half the leftover space.
|
||||
let textSize = attr.size()
|
||||
let y = max(0, (bounds.height - textSize.height) / 2)
|
||||
drawingRect = NSRect(x: 0, y: y, width: bounds.width, height: textSize.height)
|
||||
} else {
|
||||
drawingRect = bounds
|
||||
}
|
||||
// Yellow rounded backgrounds for search-match ranges sit *under* the text. We render them
|
||||
// inline (instead of as `CAShapeLayer` overlays the way `NSTextField` does) because we
|
||||
// own the entire draw path here and a single attributed string + glyph-range pass is
|
||||
// cheaper than juggling sublayers.
|
||||
if !highlightRanges.isEmpty {
|
||||
drawHighlightBackgrounds(attributedString: attr, in: drawingRect)
|
||||
}
|
||||
attr.draw(in: drawingRect)
|
||||
}
|
||||
|
||||
private func drawHighlightBackgrounds(attributedString: NSAttributedString, in rect: NSRect) {
|
||||
let textStorage = NSTextStorage(attributedString: attributedString)
|
||||
let layoutManager = NSLayoutManager()
|
||||
let textContainer = NSTextContainer(size: NSSize(width: rect.width, height: .greatestFiniteMagnitude))
|
||||
textContainer.lineFragmentPadding = 0
|
||||
textContainer.maximumNumberOfLines = maximumNumberOfLines
|
||||
textContainer.lineBreakMode = lineBreakMode
|
||||
layoutManager.addTextContainer(textContainer)
|
||||
textStorage.addLayoutManager(layoutManager)
|
||||
layoutManager.ensureLayout(for: textContainer)
|
||||
Appearance.searchMatchHighlightColor.setFill()
|
||||
let isRTL = userInterfaceLayoutDirection == .rightToLeft
|
||||
for range in highlightRanges where range.location >= 0 && range.upperBound <= attributedString.length {
|
||||
let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
|
||||
guard glyphRange.length > 0 else { continue }
|
||||
layoutManager.enumerateEnclosingRects(forGlyphRange: glyphRange, withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), in: textContainer) { glyphRect, _ in
|
||||
var bgRect = glyphRect.offsetBy(dx: rect.minX, dy: rect.minY)
|
||||
bgRect = bgRect.insetBy(dx: -2, dy: -1)
|
||||
// Trim a sliver off the leading edge for visual breathing room between adjacent
|
||||
// highlights of separate words. Mirror direction for RTL.
|
||||
let trim: CGFloat = 1
|
||||
if isRTL {
|
||||
bgRect = NSRect(x: bgRect.minX, y: bgRect.minY, width: max(bgRect.width - trim, 0.5), height: bgRect.height)
|
||||
} else {
|
||||
bgRect = NSRect(x: bgRect.minX + trim, y: bgRect.minY, width: max(bgRect.width - trim, 0.5), height: bgRect.height)
|
||||
}
|
||||
NSBezierPath(roundedRect: bgRect, xRadius: 3, yRadius: 3).fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ class LabelAndControl: NSObject {
|
||||
extraAction: ActionClosure? = nil,
|
||||
buttonSpacing: CGFloat = 15,
|
||||
proGatedIndices: Set<Int> = []) -> NSStackView {
|
||||
SettingsSearchIndex.registerStrings(macroPreferences.map { $0.localizedString })
|
||||
let buttonViews = macroPreferences.enumerated().map { (index, preference) -> ImageTextButtonView in
|
||||
let state: NSControl.StateValue = CachedUserDefaults.intFromMacroPref(rawName, macroPreferences) == index ? .on : .off
|
||||
let buttonView = ImageTextButtonView(title: preference.localizedString, rawName: rawName, image: preference.image, state: state)
|
||||
@@ -158,6 +159,8 @@ class LabelAndControl: NSObject {
|
||||
}.filter {
|
||||
!$0.isEmpty
|
||||
}))
|
||||
SettingsSearchIndex.registerStrings(view.searchableStrings)
|
||||
SettingsSearchIndex.registerTarget(SettingsWindow.highlightTarget(view))
|
||||
view.onClick = onClick
|
||||
view.onMouseEntered = onMouseEntered
|
||||
view.onMouseExited = onMouseExited
|
||||
@@ -217,6 +220,8 @@ class LabelAndControl: NSObject {
|
||||
|
||||
static func makeDropdown(_ rawName: String, _ macroPreferences: [MacroPreference], extraAction: ActionClosure? = nil) -> NSPopUpButton {
|
||||
let dropdown = dropdown_(rawName, macroPreferences)
|
||||
SettingsSearchIndex.registerStrings(macroPreferences.map { $0.localizedString })
|
||||
SettingsSearchIndex.registerTarget(SettingsWindow.highlightTarget(dropdown))
|
||||
return setupControl(dropdown, rawName, extraAction: extraAction) as! NSPopUpButton
|
||||
}
|
||||
|
||||
@@ -242,6 +247,7 @@ class LabelAndControl: NSObject {
|
||||
let button = NSButton(radioButtonWithTitle: $0.localizedString, target: nil, action: nil)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.state = CachedUserDefaults.intFromMacroPref(rawName, macroPreferences) == i ? .on : .off
|
||||
SettingsSearchIndex.registerString($0.localizedString)
|
||||
_ = setupControl(button, rawName, String(i), extraAction: extraAction)
|
||||
i += 1
|
||||
return button
|
||||
@@ -253,6 +259,8 @@ class LabelAndControl: NSObject {
|
||||
$0.localizedString
|
||||
}, trackingMode: .selectOne, target: nil, action: nil)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
SettingsSearchIndex.registerStrings(macroPreferences.map { $0.localizedString })
|
||||
SettingsSearchIndex.registerTarget(SettingsWindow.highlightTarget(button))
|
||||
applySystemSelectedSegmentStyle(button)
|
||||
for (i, preference) in macroPreferences.enumerated() {
|
||||
if segmentWidth > 0 {
|
||||
@@ -374,6 +382,8 @@ class LabelAndControl: NSObject {
|
||||
if shouldFit {
|
||||
label.fit()
|
||||
}
|
||||
SettingsSearchIndex.registerString(labelText)
|
||||
SettingsSearchIndex.registerTarget(SettingsSearchHighlight.highlightTarget(label))
|
||||
return label
|
||||
}
|
||||
|
||||
@@ -381,6 +391,8 @@ class LabelAndControl: NSObject {
|
||||
let suffix = NSTextField(labelWithString: text)
|
||||
suffix.textColor = .gray
|
||||
suffix.identifier = NSUserInterfaceItemIdentifier(controlName + ControlIdentifierDiscriminator.SUFFIX.rawValue)
|
||||
SettingsSearchIndex.registerString(text)
|
||||
SettingsSearchIndex.registerTarget(SettingsSearchHighlight.highlightTarget(suffix))
|
||||
return suffix
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import Cocoa
|
||||
|
||||
/// A closure-bag that knows how to test a search query against one specific control's text and
|
||||
/// apply / clear the yellow search highlight on it. Produced by the `SettingsSearchHighlight`
|
||||
/// factories below (and a couple still living on `SettingsWindow` for controls whose highlight is
|
||||
/// tied to the window's sheet lifecycle), collected per-section by `SettingsSearchIndex`, and
|
||||
/// driven by `SettingsWindow.applySearch`.
|
||||
final class SettingsSearchHighlightTarget {
|
||||
private let matchRanges: (String) -> [Range<Int>]
|
||||
private let applyHighlight: ([Range<Int>]) -> Void
|
||||
private let clearHighlight: () -> Void
|
||||
|
||||
init(_ matchRanges: @escaping (String) -> [Range<Int>], _ applyHighlight: @escaping ([Range<Int>]) -> 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()
|
||||
}
|
||||
}
|
||||
|
||||
/// Search-highlight factories + rendering for the *text label* controls (`NSTextField` and
|
||||
/// `LightLabel`). Extracted from `SettingsWindow` so the row/label widgets that use them
|
||||
/// (`TableGroupView.makeText`, `SidebarListRow`) don't transitively depend on the whole settings
|
||||
/// window — which is what lets them be unit-tested in isolation.
|
||||
///
|
||||
/// The other `highlightTarget` overloads (popups, buttons, segmented controls, info popovers)
|
||||
/// stay on `SettingsWindow`: their highlight is a `CAShapeLayer` pill keyed to the live sheet
|
||||
/// windows the settings window manages, so they're genuinely coupled to it.
|
||||
enum SettingsSearchHighlight {
|
||||
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)
|
||||
|
||||
static func highlightTarget(_ textField: NSTextField) -> SettingsSearchHighlightTarget? {
|
||||
guard !textField.stringValue.isEmpty else { return nil }
|
||||
// `attributedStringValue` takes precedence over the textField's `textColor` / `font` for
|
||||
// rendering. If we bake either into the attributed string at apply time, subsequent
|
||||
// changes to the textField's properties (e.g. a row selection toggle swapping textColor
|
||||
// white↔dark or font regular↔semibold) don't propagate to the displayed text.
|
||||
//
|
||||
// Strategy: only attach an attribute to the matched (highlighted) ranges. The rest of
|
||||
// the string has no foreground color and no font attribute, so AppKit's NSCell renders
|
||||
// those characters using the textField's own `textColor` and `font` — exactly the
|
||||
// properties `DynamicColorTextField` and `SidebarListRow.updateStyle` keep current.
|
||||
// The layout pass inside `applyRoundedHighlights` does need an explicit font to position
|
||||
// the yellow boxes accurately (NSLayoutManager defaults to plain system font otherwise),
|
||||
// so we hand it a separate "layout" attributed string with the current font filled in.
|
||||
// It only computes positions and never touches the textField, so no display freezing.
|
||||
var isHighlighted = false
|
||||
return SettingsSearchHighlightTarget({ query in
|
||||
SettingsSearch.match(query, in: textField.stringValue)?.ranges ?? []
|
||||
}, { ranges in
|
||||
let text = textField.stringValue
|
||||
let nsRanges = ranges.compactMap { characterRangeToNSRange($0, in: text) }
|
||||
let displayString = NSMutableAttributedString(string: text)
|
||||
nsRanges.forEach {
|
||||
displayString.addAttribute(.foregroundColor, value: Appearance.searchMatchForegroundColor, range: $0)
|
||||
}
|
||||
textField.attributedStringValue = displayString
|
||||
let baseFont = textField.font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize)
|
||||
let layoutString = NSMutableAttributedString(string: text, attributes: [.font: baseFont])
|
||||
nsRanges.forEach {
|
||||
layoutString.addAttribute(.foregroundColor, value: Appearance.searchMatchForegroundColor, range: $0)
|
||||
}
|
||||
applyRoundedHighlights(to: textField, attributedString: layoutString, ranges: nsRanges)
|
||||
isHighlighted = true
|
||||
}, {
|
||||
guard isHighlighted else { return }
|
||||
// Self-assigning stringValue clears the cell's attributedStringValue and switches the
|
||||
// textField back to plain rendering driven by `textColor` + `font`.
|
||||
textField.stringValue = textField.stringValue
|
||||
clearRoundedHighlights(from: textField)
|
||||
isHighlighted = false
|
||||
})
|
||||
}
|
||||
|
||||
/// `LightLabel` does its own draw via `NSAttributedString.draw(in:)`, so the search highlight
|
||||
/// is just a flag the label reads on its next draw pass — no `attributedStringValue` mutation,
|
||||
/// no `CAShapeLayer` overlay machinery (the label renders the yellow backgrounds inline).
|
||||
/// Selection-state changes that update `textColor` / `font` propagate naturally since the
|
||||
/// label re-evaluates its rendered attributed string on every redraw.
|
||||
static func highlightTarget(_ label: LightLabel) -> SettingsSearchHighlightTarget? {
|
||||
guard !label.stringValue.isEmpty else { return nil }
|
||||
var isHighlighted = false
|
||||
return SettingsSearchHighlightTarget({ query in
|
||||
SettingsSearch.match(query, in: label.stringValue)?.ranges ?? []
|
||||
}, { ranges in
|
||||
let text = label.stringValue
|
||||
let nsRanges = ranges.compactMap { characterRangeToNSRange($0, in: text) }
|
||||
label.applyHighlight(ranges: nsRanges, foregroundColor: Appearance.searchMatchForegroundColor)
|
||||
isHighlighted = true
|
||||
}, {
|
||||
guard isHighlighted else { return }
|
||||
label.clearHighlight()
|
||||
isHighlighted = false
|
||||
})
|
||||
}
|
||||
|
||||
static func characterRangeToNSRange(_ range: Range<Int>, 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..<end, in: text)
|
||||
}
|
||||
|
||||
static func clearRoundedHighlights(from view: NSView) {
|
||||
view.layer?.sublayers?.filter { $0.name == roundedHighlightLayerName }.forEach { $0.removeFromSuperlayer() }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Cocoa
|
||||
|
||||
/// Search index for the Settings UI.
|
||||
///
|
||||
/// Two responsibilities:
|
||||
///
|
||||
/// 1. **Inline registration during section construction.** Before Phase 3 the index was built by
|
||||
/// `SettingsWindow.collectSearchContent`, a post-construction recursive walk that visited every
|
||||
/// NSView in a section's tree and shape-matched its strings out of it (two traversals per
|
||||
/// section: construct, then harvest). The new model is push, not pull: as each widget factory
|
||||
/// (`LabelAndControl.makeDropdown`, `TableGroupView.makeText`, etc.) builds a control, it
|
||||
/// pushes the strings it would produce — plus a `SettingsSearchHighlightTarget` (a closure-bag
|
||||
/// that knows how to highlight that specific control) — into the currently-active `Builder`.
|
||||
/// `SettingsWindow.addSection` opens a builder before calling the section's view-builder
|
||||
/// closure and closes it after; the resulting `Builder.strings` and `Builder.targets` are
|
||||
/// exactly what the old walk produced, just collected during construction rather than after.
|
||||
/// A post-construction walk still runs as a safety net for any direct widget creation that
|
||||
/// bypasses the factories.
|
||||
///
|
||||
/// 2. **Pre-build sheet metadata.** Sheets are lazy (Phase 2) — their view trees don't exist
|
||||
/// until the user opens them once. Search's button-match check needs to know whether a sheet's
|
||||
/// content matches the query *before* the sheet has ever been built, so each sheet class
|
||||
/// declares its own `static let searchableStrings: [String]` and the lookup below maps the
|
||||
/// button's action selector to that static list. Once the sheet is built, the live-view walk
|
||||
/// in `SettingsWindow.highlightTarget(_ button:)` adds on top for in-sheet highlighting.
|
||||
enum SettingsSearchIndex {
|
||||
|
||||
// MARK: - Inline registration
|
||||
|
||||
/// The currently-active builder, or nil outside of `indexed(_:)`. Factories check this and
|
||||
/// silently no-op when nil so they can be called from any context.
|
||||
static var current: Builder?
|
||||
|
||||
final class Builder {
|
||||
var strings: [String] = []
|
||||
var targets: [SettingsSearchHighlightTarget] = []
|
||||
}
|
||||
|
||||
/// Run `build` with a fresh builder active, then return the built value alongside the
|
||||
/// accumulated index. Nested `indexed` calls restore the previous builder on exit.
|
||||
static func indexed<T>(_ build: () -> T) -> (result: T, builder: Builder) {
|
||||
let previous = current
|
||||
let builder = Builder()
|
||||
current = builder
|
||||
defer { current = previous }
|
||||
let result = build()
|
||||
return (result, builder)
|
||||
}
|
||||
|
||||
/// Push a string. No-op if the string is empty/whitespace-only or no builder is active.
|
||||
static func registerString(_ s: String?) {
|
||||
guard let current, let s else { return }
|
||||
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { current.strings.append(trimmed) }
|
||||
}
|
||||
|
||||
/// Push multiple strings — convenience for popups, segmented controls, info-button tooltips.
|
||||
static func registerStrings(_ strings: [String]) {
|
||||
guard let current else { return }
|
||||
for s in strings {
|
||||
let t = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !t.isEmpty { current.strings.append(t) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a highlight target. No-op if nil (factories may pass nil for non-highlightable cases).
|
||||
static func registerTarget(_ target: SettingsSearchHighlightTarget?) {
|
||||
guard let target, let current else { return }
|
||||
current.targets.append(target)
|
||||
}
|
||||
|
||||
// MARK: - Pre-build sheet metadata
|
||||
|
||||
/// Look up static searchable strings for a sheet by its opener-button action selector. Used
|
||||
/// by `SettingsWindow.highlightTarget(_ button:)` to match a query against an unbuilt sheet's
|
||||
/// contents. Each sheet class declares its own `static let searchableStrings: [String]` — keep
|
||||
/// those in sync with the sheet's `makeContentView` by convention.
|
||||
static func sheetSearchableStrings(forButtonAction action: Selector) -> [String]? {
|
||||
if action == #selector(AppearanceTab.showCustomizeStyleSheet) { return CustomizeStyleSheet.searchableStrings }
|
||||
if action == #selector(AppearanceTab.showAnimationsSheet) { return AnimationsSheet.searchableStrings }
|
||||
if action == #selector(ControlsTab.showShortcutsSettings) { return ShortcutsWhenActiveSheet.searchableStrings }
|
||||
if action == #selector(ControlsTab.showAdditionalControlsSettings) { return AdditionalControlsSheet.searchableStrings }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -4,44 +4,11 @@ private struct SettingsSectionDefinition {
|
||||
let id: String
|
||||
let title: String
|
||||
let symbol: Symbols
|
||||
let view: NSView
|
||||
}
|
||||
|
||||
private final class SettingsSearchHighlightTarget {
|
||||
private let matchRanges: (String) -> [Range<Int>]
|
||||
private let applyHighlight: ([Range<Int>]) -> Void
|
||||
private let clearHighlight: () -> Void
|
||||
|
||||
init(_ matchRanges: @escaping (String) -> [Range<Int>], _ applyHighlight: @escaping ([Range<Int>]) -> 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()
|
||||
}
|
||||
/// Deferred view builder so `addSection` can wrap construction in
|
||||
/// `SettingsSearchIndex.indexed { ... }` and harvest inline-registered search strings +
|
||||
/// highlight targets as the section's factories build their widgets. The closure is invoked
|
||||
/// exactly once, in `addSection`.
|
||||
let builder: () -> NSView
|
||||
}
|
||||
|
||||
private final class SettingsSection {
|
||||
@@ -303,11 +270,6 @@ class SettingsWindow: NSWindow {
|
||||
/// `NSTableView.style = .sourceList` already inserts the cell content into its rounded
|
||||
/// highlight pill, so we only add a small visual breathing-room here.
|
||||
static let sidebarItemContentInset = CGFloat(4)
|
||||
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)
|
||||
@@ -563,10 +525,10 @@ class SettingsWindow: NSWindow {
|
||||
|
||||
private func sectionDefinitions() -> [SettingsSectionDefinition] {
|
||||
[
|
||||
SettingsSectionDefinition(id: "appearance", title: NSLocalizedString("Appearance", comment: ""), symbol: .paintpalette, view: AppearanceTab.initTab()),
|
||||
SettingsSectionDefinition(id: "controls", title: NSLocalizedString("Controls", comment: ""), symbol: .command, view: ControlsTab.initTab()),
|
||||
SettingsSectionDefinition(id: "general", title: NSLocalizedString("General", comment: ""), symbol: .gearshape, view: GeneralTab.initTab()),
|
||||
SettingsSectionDefinition(id: "exceptions", title: NSLocalizedString("Exceptions", comment: ""), symbol: .handRaised, view: ExceptionsTab.initTab()),
|
||||
SettingsSectionDefinition(id: "appearance", title: NSLocalizedString("Appearance", comment: ""), symbol: .paintpalette, builder: AppearanceTab.initTab),
|
||||
SettingsSectionDefinition(id: "controls", title: NSLocalizedString("Controls", comment: ""), symbol: .command, builder: ControlsTab.initTab),
|
||||
SettingsSectionDefinition(id: "general", title: NSLocalizedString("General", comment: ""), symbol: .gearshape, builder: GeneralTab.initTab),
|
||||
SettingsSectionDefinition(id: "exceptions", title: NSLocalizedString("Exceptions", comment: ""), symbol: .handRaised, builder: ExceptionsTab.initTab),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -575,21 +537,35 @@ class SettingsWindow: NSWindow {
|
||||
}
|
||||
|
||||
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
|
||||
// Wrap the tab's view construction in an `indexed { ... }` scope so that every factory
|
||||
// call inside (`LabelAndControl.makeDropdown`, `TableGroupView.makeText`, `ProBadgeView`,
|
||||
// etc.) pushes its searchable strings and highlight targets into a per-section `Builder`.
|
||||
// The harvested results become the section's `searchableStrings` / `highlightTargets`
|
||||
// directly — no second-pass walk over the view tree. The section title is also produced
|
||||
// here so its label registration is captured by the same indexed scope.
|
||||
let sectionTitleBox: (title: LightLabel, view: NSView)
|
||||
let (built, builder) = SettingsSearchIndex.indexed { () -> (title: LightLabel, view: NSView) in
|
||||
let sectionTitle = TableGroupView.makeText(definition.title, bold: true)
|
||||
sectionTitle.font = NSFont.systemFont(ofSize: 15, weight: .medium)
|
||||
sectionTitle.lineBreakMode = .byWordWrapping
|
||||
sectionTitle.maximumNumberOfLines = 0
|
||||
let view = definition.builder()
|
||||
return (sectionTitle, view)
|
||||
}
|
||||
sectionTitleBox = built
|
||||
let sectionTitle = sectionTitleBox.title
|
||||
let sectionView = sectionTitleBox.view
|
||||
let container = NSView()
|
||||
let spacer = NSView()
|
||||
container.addSubview(sectionTitle)
|
||||
container.addSubview(definition.view)
|
||||
container.addSubview(sectionView)
|
||||
container.addSubview(spacer)
|
||||
sectionTitle.translatesAutoresizingMaskIntoConstraints = false
|
||||
definition.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
sectionView.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 interSectionSpacingConstraint = spacer.topAnchor.constraint(equalTo: sectionView.bottomAnchor, constant: Self.sectionInterSectionSpacing)
|
||||
let spacerHeightConstraint = spacer.heightAnchor.constraint(equalToConstant: Self.sectionBottomSpacing)
|
||||
NSLayoutConstraint.activate([
|
||||
titleTopConstraint,
|
||||
@@ -599,12 +575,12 @@ class SettingsWindow: NSWindow {
|
||||
// to also account for the TGV's internal `TableGroupView.padding` so they line up.
|
||||
sectionTitle.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Self.sectionContentHorizontalMargin + TableGroupView.padding),
|
||||
sectionTitle.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
definition.view.topAnchor.constraint(equalTo: sectionTitle.bottomAnchor, constant: Self.sectionTitleSpacing),
|
||||
sectionView.topAnchor.constraint(equalTo: sectionTitle.bottomAnchor, constant: Self.sectionTitleSpacing),
|
||||
// 15pt horizontal margin between the section's left/right and the TGV's rounded
|
||||
// background, so the gray-bg block floats inside the section instead of touching
|
||||
// the splitview divider / window edge.
|
||||
definition.view.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Self.sectionContentHorizontalMargin),
|
||||
definition.view.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -Self.sectionContentHorizontalMargin),
|
||||
sectionView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Self.sectionContentHorizontalMargin),
|
||||
sectionView.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -Self.sectionContentHorizontalMargin),
|
||||
interSectionSpacingConstraint,
|
||||
spacer.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
spacer.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
@@ -613,13 +589,20 @@ class SettingsWindow: NSWindow {
|
||||
])
|
||||
sectionsStack.addArrangedSubview(container)
|
||||
container.widthAnchor.constraint(equalTo: sectionsStack.widthAnchor).isActive = true
|
||||
let (searchableStrings, highlightTargets) = collectSearchContent(sectionTitle, definition.view)
|
||||
// Inline-registered strings/targets are the source of truth; `collectSearchContent` still
|
||||
// runs as a safety net to catch direct widget creation in tabs (NSTextField, NSButton
|
||||
// etc. created outside the LabelAndControl/TableGroupView factories) that hasn't been
|
||||
// migrated to inline registration yet. The walk's results get unioned in via `Set` below.
|
||||
var (searchableStrings, highlightTargets) = collectSearchContent(sectionTitle, sectionView)
|
||||
searchableStrings.append(contentsOf: builder.strings)
|
||||
highlightTargets.append(contentsOf: builder.targets)
|
||||
let uniqueStrings = Array(Set(searchableStrings))
|
||||
let section = SettingsSection(definition.id,
|
||||
definition.title,
|
||||
sidebarImage(definition),
|
||||
container,
|
||||
sectionTitle,
|
||||
searchableStrings,
|
||||
uniqueStrings,
|
||||
highlightTargets,
|
||||
interSectionSpacingConstraint,
|
||||
spacerHeightConstraint,
|
||||
@@ -637,11 +620,11 @@ class SettingsWindow: NSWindow {
|
||||
}
|
||||
}
|
||||
|
||||
private func collectSearchContent(_ sectionTitle: NSTextField, _ root: NSView) -> ([String], [SettingsSearchHighlightTarget]) {
|
||||
private func collectSearchContent(_ sectionTitle: LightLabel, _ root: NSView) -> ([String], [SettingsSearchHighlightTarget]) {
|
||||
var textValues = [String]()
|
||||
var highlightTargets = [SettingsSearchHighlightTarget]()
|
||||
textValues.append(sectionTitle.stringValue)
|
||||
if let target = highlightTarget(sectionTitle) {
|
||||
if let target = SettingsSearchHighlight.highlightTarget(sectionTitle) {
|
||||
highlightTargets.append(target)
|
||||
}
|
||||
collectSearchContent(root, &textValues, &highlightTargets)
|
||||
@@ -659,7 +642,7 @@ class SettingsWindow: NSWindow {
|
||||
let value = textField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty {
|
||||
textValues.append(value)
|
||||
if let target = highlightTarget(textField) {
|
||||
if let target = SettingsSearchHighlight.highlightTarget(textField) {
|
||||
highlightTargets.append(target)
|
||||
}
|
||||
}
|
||||
@@ -674,7 +657,7 @@ class SettingsWindow: NSWindow {
|
||||
textValues.append(value)
|
||||
}
|
||||
}
|
||||
if let target = highlightTarget(popUpButton) {
|
||||
if let target = SettingsWindow.highlightTarget(popUpButton) {
|
||||
highlightTargets.append(target)
|
||||
}
|
||||
} else if let button = root as? NSButton {
|
||||
@@ -682,7 +665,7 @@ class SettingsWindow: NSWindow {
|
||||
if !value.isEmpty {
|
||||
textValues.append(value)
|
||||
}
|
||||
if let target = highlightTarget(button) {
|
||||
if let target = SettingsWindow.highlightTarget(button) {
|
||||
highlightTargets.append(target)
|
||||
}
|
||||
} else if let segmentedControl = root as? NSSegmentedControl {
|
||||
@@ -692,14 +675,14 @@ class SettingsWindow: NSWindow {
|
||||
textValues.append(value)
|
||||
}
|
||||
}
|
||||
if let target = highlightTarget(segmentedControl) {
|
||||
if let target = SettingsWindow.highlightTarget(segmentedControl) {
|
||||
highlightTargets.append(target)
|
||||
}
|
||||
} else if let infoButton = root as? ClickHoverImageView {
|
||||
SettingsWindow.searchStrings(infoButton).forEach {
|
||||
textValues.append($0)
|
||||
}
|
||||
if let target = highlightTarget(infoButton) {
|
||||
if let target = SettingsWindow.highlightTarget(infoButton) {
|
||||
highlightTargets.append(target)
|
||||
}
|
||||
} else if let textView = root as? NSTextView {
|
||||
@@ -711,50 +694,30 @@ class SettingsWindow: NSWindow {
|
||||
root.subviews.forEach { collectSearchContent($0, &textValues, &highlightTargets) }
|
||||
}
|
||||
|
||||
private func highlightTarget(_ textField: NSTextField) -> SettingsSearchHighlightTarget? {
|
||||
guard !textField.stringValue.isEmpty else { return nil }
|
||||
var baseAttributedString: NSAttributedString?
|
||||
var highlightedText = ""
|
||||
var isHighlighted = false
|
||||
return SettingsSearchHighlightTarget({ query in
|
||||
SettingsSearch.match(query, in: textField.stringValue)?.ranges ?? []
|
||||
}, { ranges in
|
||||
let text = textField.stringValue
|
||||
if !isHighlighted || highlightedText != text {
|
||||
baseAttributedString = textField.attributedStringValue
|
||||
highlightedText = text
|
||||
}
|
||||
let mutable = NSMutableAttributedString(attributedString: baseAttributedString ?? textField.attributedStringValue)
|
||||
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)
|
||||
isHighlighted = true
|
||||
}, {
|
||||
guard isHighlighted else { return }
|
||||
if let baseAttributedString {
|
||||
textField.attributedStringValue = baseAttributedString
|
||||
}
|
||||
SettingsWindow.clearRoundedHighlights(from: textField)
|
||||
baseAttributedString = nil
|
||||
highlightedText = ""
|
||||
isHighlighted = false
|
||||
})
|
||||
}
|
||||
|
||||
private func highlightTarget(_ popUpButton: NSPopUpButton) -> SettingsSearchHighlightTarget? {
|
||||
static func highlightTarget(_ popUpButton: NSPopUpButton) -> SettingsSearchHighlightTarget? {
|
||||
controlHighlightTarget(popUpButton) {
|
||||
SettingsWindow.searchStrings(popUpButton)
|
||||
}
|
||||
}
|
||||
|
||||
private func highlightTarget(_ button: NSButton) -> SettingsSearchHighlightTarget? {
|
||||
guard SettingsWindow.sheet(forSearchButton: button) != nil else { return nil }
|
||||
static func highlightTarget(_ button: NSButton) -> SettingsSearchHighlightTarget? {
|
||||
// Buttons that open one of the known sheets are search targets. Pre-build path: consult
|
||||
// the sheet class's static `searchableStrings`. Post-build path: also walk the live view
|
||||
// tree so any string the static list doesn't cover (or is added later inside the sheet)
|
||||
// still matches. Either path makes the button itself the visual highlight target — we
|
||||
// don't try to dive into the sheet's controls for in-sheet highlighting from out here.
|
||||
let staticStrings: [String]?
|
||||
if let action = button.action {
|
||||
staticStrings = SettingsSearchIndex.sheetSearchableStrings(forButtonAction: action)
|
||||
} else {
|
||||
staticStrings = nil
|
||||
}
|
||||
let hasSheet = SettingsWindow.sheet(forSearchButton: button) != nil
|
||||
guard staticStrings != nil || hasSheet else { return nil }
|
||||
return controlHighlightTarget(button) {
|
||||
var values = [String]()
|
||||
SettingsWindow.appendTrimmed(button.title, &values)
|
||||
if let staticStrings { values.append(contentsOf: staticStrings) }
|
||||
if let sheet = SettingsWindow.sheet(forSearchButton: button) {
|
||||
values.append(contentsOf: SettingsWindow.sheetSearchStrings(sheet))
|
||||
}
|
||||
@@ -762,17 +725,18 @@ class SettingsWindow: NSWindow {
|
||||
}
|
||||
}
|
||||
|
||||
private func highlightTarget(_ segmentedControl: NSSegmentedControl) -> SettingsSearchHighlightTarget? {
|
||||
static func highlightTarget(_ segmentedControl: NSSegmentedControl) -> SettingsSearchHighlightTarget? {
|
||||
let segmentLabels = (0..<segmentedControl.segmentCount).map {
|
||||
SettingsWindow.trimmedText(segmentedControl.label(forSegment: $0) ?? "")
|
||||
}
|
||||
if segmentLabels.allSatisfy(\.isEmpty) { return nil }
|
||||
// For tab controls that surface different content panels per segment (the Filtering /
|
||||
// Appearance toggle in `ControlsTab`), register the per-segment content view in
|
||||
// `ControlsTab.tabSegmentSubtrees`. A query that matches anything inside a segment's
|
||||
// content view will then turn that segment yellow — same affordance as sheet buttons,
|
||||
// which highlight when their sheet contents match the query.
|
||||
let subtrees = ControlsTab.tabSegmentSubtrees[ObjectIdentifier(segmentedControl)]
|
||||
// For tab controls that surface different content per segment (the Filtering / Appearance
|
||||
// / Ordering toggle in ShortcutEditor — and the gesture editor's clone of the same), the
|
||||
// per-segment searchable strings are registered in `ControlsTab.tabSegmentSearchableStrings`.
|
||||
// A query that matches anything in a segment's content lights that segment up yellow —
|
||||
// including content for *unbuilt* panes (the Filtering/Appearance/Ordering panes are
|
||||
// constructed lazily, see `ShortcutEditor.ensurePaneBuilt`).
|
||||
let perSegmentStrings = ControlsTab.tabSegmentSearchableStrings[ObjectIdentifier(segmentedControl)]
|
||||
var matchingSegmentIndexes = [Int]()
|
||||
return SettingsSearchHighlightTarget({ query in
|
||||
matchingSegmentIndexes = []
|
||||
@@ -781,8 +745,8 @@ class SettingsWindow: NSWindow {
|
||||
matchingSegmentIndexes.append(index)
|
||||
return
|
||||
}
|
||||
if let subtrees, index < subtrees.count,
|
||||
SettingsWindow.subtreeContainsMatch(subtrees[index], query: query) {
|
||||
if let perSegmentStrings, index < perSegmentStrings.count,
|
||||
perSegmentStrings[index].contains(where: { SettingsSearch.match(query, in: $0) != nil }) {
|
||||
matchingSegmentIndexes.append(index)
|
||||
}
|
||||
}
|
||||
@@ -828,13 +792,13 @@ class SettingsWindow: NSWindow {
|
||||
return view.subviews.contains { subtreeContainsMatch($0, query: query) }
|
||||
}
|
||||
|
||||
private func highlightTarget(_ infoButton: ClickHoverImageView) -> SettingsSearchHighlightTarget? {
|
||||
static func highlightTarget(_ infoButton: ClickHoverImageView) -> SettingsSearchHighlightTarget? {
|
||||
controlHighlightTarget(infoButton) {
|
||||
SettingsWindow.searchStrings(infoButton)
|
||||
}
|
||||
}
|
||||
|
||||
private func controlHighlightTarget(_ control: NSView, _ searchableStrings: @escaping () -> [String]) -> SettingsSearchHighlightTarget? {
|
||||
static func controlHighlightTarget(_ control: NSView, _ searchableStrings: @escaping () -> [String]) -> SettingsSearchHighlightTarget? {
|
||||
if searchableStrings().isEmpty { return nil }
|
||||
return SettingsSearchHighlightTarget({ query in
|
||||
searchableStrings().contains {
|
||||
@@ -847,17 +811,6 @@ class SettingsWindow: NSWindow {
|
||||
})
|
||||
}
|
||||
|
||||
private static func characterRangeToNSRange(_ range: Range<Int>, 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..<end, in: text)
|
||||
}
|
||||
|
||||
private static func clearRoundedHighlights(from view: NSView) {
|
||||
view.layer?.sublayers?.filter { $0.name == roundedHighlightLayerName }.forEach { $0.removeFromSuperlayer() }
|
||||
}
|
||||
|
||||
private static func clearControlHighlight(from view: NSView) {
|
||||
view.layer?.sublayers?.filter { $0.name == controlHighlightLayerName }.forEach { $0.removeFromSuperlayer() }
|
||||
}
|
||||
@@ -922,55 +875,6 @@ class SettingsWindow: NSWindow {
|
||||
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 {
|
||||
@@ -1089,19 +993,19 @@ class SettingsWindow: NSWindow {
|
||||
|
||||
private func collectSheetHighlightTargets(_ root: NSView, _ targets: inout [SettingsSearchHighlightTarget]) {
|
||||
if let textField = root as? NSTextField {
|
||||
if let target = highlightTarget(textField) {
|
||||
if let target = SettingsSearchHighlight.highlightTarget(textField) {
|
||||
targets.append(target)
|
||||
}
|
||||
} else if let popUpButton = root as? NSPopUpButton {
|
||||
if let target = highlightTarget(popUpButton) {
|
||||
if let target = SettingsWindow.highlightTarget(popUpButton) {
|
||||
targets.append(target)
|
||||
}
|
||||
} else if let segmentedControl = root as? NSSegmentedControl {
|
||||
if let target = highlightTarget(segmentedControl) {
|
||||
if let target = SettingsWindow.highlightTarget(segmentedControl) {
|
||||
targets.append(target)
|
||||
}
|
||||
} else if let infoButton = root as? ClickHoverImageView {
|
||||
if let target = highlightTarget(infoButton) {
|
||||
if let target = SettingsWindow.highlightTarget(infoButton) {
|
||||
targets.append(target)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +181,23 @@ class SidebarListRow: ClickHoverStackView {
|
||||
toolTip = summary.isEmpty ? title : "\(title)\n\(summary)"
|
||||
}
|
||||
|
||||
/// Register the row's current title + summary text into the active `SettingsSearchIndex.Builder`
|
||||
/// (if any), along with highlight targets for both labels. Call this *after* `setContent` so the
|
||||
/// indexed strings match what the user sees. A no-op outside an `indexed { ... }` scope.
|
||||
///
|
||||
/// Why this exists: the fallback `SettingsWindow.collectSearchContent` walk *does* find these
|
||||
/// labels via the generic `NSTextField` branch — but ControlsTab's sidebar rows are populated
|
||||
/// by `refreshShortcutRows` (called from `initTab` *inside* the indexed scope, and again from
|
||||
/// `preferenceChanged` *outside* it). The fallback walk only runs once per section, so rows
|
||||
/// recreated after the section is added would silently drop out of the index without this
|
||||
/// explicit registration.
|
||||
func registerSearchContent() {
|
||||
SettingsSearchIndex.registerString(titleLabel.stringValue)
|
||||
SettingsSearchIndex.registerString(summaryLabel.stringValue)
|
||||
SettingsSearchIndex.registerTarget(SettingsSearchHighlight.highlightTarget(titleLabel))
|
||||
SettingsSearchIndex.registerTarget(SettingsSearchHighlight.highlightTarget(summaryLabel))
|
||||
}
|
||||
|
||||
/// Updates only the summary line, leaving title/icon untouched. Use when the underlying
|
||||
/// data changed in a way that only affects the summary (e.g. dropdown selection changed
|
||||
/// but app identity is the same).
|
||||
|
||||
@@ -440,21 +440,20 @@ class TableGroupView: ClickHoverStackView {
|
||||
return rowView
|
||||
}
|
||||
|
||||
static func makeText(_ leftText: String?, bold: Bool = false) -> NSTextField {
|
||||
let leftLabel = NSTextField(labelWithString: leftText ?? "")
|
||||
static func makeText(_ leftText: String?, bold: Bool = false) -> LightLabel {
|
||||
// Use `LightLabel` (custom NSView + `NSAttributedString.draw`) instead of `NSTextField`.
|
||||
// For static row titles, NSTextField pays a per-redraw cost on every key-state cascade
|
||||
// (NSCell setup, TextKit2 layout, the Tahoe SwiftUI-text bridge) that LightLabel avoids.
|
||||
// Same visible behavior; substantially cheaper at scale (hundreds of labels per window).
|
||||
let leftLabel = LightLabel(leftText ?? "")
|
||||
if bold {
|
||||
leftLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium)
|
||||
}
|
||||
leftLabel.alignment = .left
|
||||
// Truncate (with ellipsis) instead of wrap. The row is laid out with a fixed
|
||||
// `mainRow.fittingSize.height` from `setMainRow`, so a wrapped second line would have
|
||||
// nowhere to render and visually clip. `allowsExpansionToolTips` is the AppKit-native
|
||||
// "show the full text in a popup on hover when truncated" affordance — no need for us
|
||||
// to set a manual `toolTip`, AppKit only shows it when the label actually overflows.
|
||||
leftLabel.lineBreakMode = .byTruncatingTail
|
||||
leftLabel.maximumNumberOfLines = 1
|
||||
leftLabel.cell?.usesSingleLineMode = true
|
||||
leftLabel.allowsExpansionToolTips = true
|
||||
SettingsSearchIndex.registerString(leftText)
|
||||
SettingsSearchIndex.registerTarget(SettingsSearchHighlight.highlightTarget(leftLabel))
|
||||
return leftLabel
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Cocoa
|
||||
|
||||
class UpgradeTab {
|
||||
private static var statusLabel: NSTextField!
|
||||
private static var statusLabel: LightLabel!
|
||||
private static var usageHero: UsageStatHeroView!
|
||||
private static var heroButton: ProHeroButton!
|
||||
private static var guaranteeLabel: NSTextField!
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import Cocoa
|
||||
|
||||
class AnimationsSheet: SheetWindow {
|
||||
private static let title = NSLocalizedString("Animations", comment: "")
|
||||
private static let labelDelay = NSLocalizedString("Apparition delay of Switcher", comment: "")
|
||||
private static let labelFadeOut = NSLocalizedString("Fade out animation of Switcher", comment: "")
|
||||
private static let labelFadeIn = NSLocalizedString("Fade in animation of Preview", comment: "")
|
||||
|
||||
/// Pre-build search index for the open-button. See `SettingsSearchIndex.sheetSearchableStrings`.
|
||||
static let searchableStrings: [String] = [title, labelDelay, labelFadeOut, labelFadeIn]
|
||||
|
||||
override func makeContentView() -> NSView {
|
||||
let table = TableGroupView(title: NSLocalizedString("Animations", comment: ""), width: SheetWindow.width)
|
||||
let table = TableGroupView(title: Self.title, width: SheetWindow.width)
|
||||
let slider = LabelAndControl.makeLabelWithSlider("", "windowDisplayDelay", 0, 900, 19, true, "ms", width: 180)
|
||||
let rule = slider[1]
|
||||
let indicator = slider[2] as! NSTextField
|
||||
indicator.alignment = .right
|
||||
indicator.fit(56, indicator.fittingSize.height)
|
||||
table.addRow(leftText: NSLocalizedString("Apparition delay of Switcher", comment: ""),
|
||||
rightViews: [rule, indicator])
|
||||
table.addRow(leftText: NSLocalizedString("Fade out animation of Switcher", comment: ""),
|
||||
rightViews: LabelAndControl.makeSwitch("fadeOutAnimation"))
|
||||
table.addRow(leftText: NSLocalizedString("Fade in animation of Preview", comment: ""),
|
||||
rightViews: LabelAndControl.makeSwitch("previewFadeInAnimation"))
|
||||
table.addRow(leftText: Self.labelDelay, rightViews: [rule, indicator])
|
||||
table.addRow(leftText: Self.labelFadeOut, rightViews: LabelAndControl.makeSwitch("fadeOutAnimation"))
|
||||
table.addRow(leftText: Self.labelFadeIn, rightViews: LabelAndControl.makeSwitch("previewFadeInAnimation"))
|
||||
return table
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ struct ShowHideRowInfo {
|
||||
class IllustratedImageThemeView: ClickHoverImageView {
|
||||
override var acceptsFirstResponder: Bool { false }
|
||||
static let padding = CGFloat(4)
|
||||
/// Shown when the current Appearance style doesn't support a Show/Hide option (the illustration
|
||||
/// is for another style). Exposed so `CustomizeStyleSheet.searchableStrings` can reference it
|
||||
/// without re-`NSLocalizedString`-ing the same English text.
|
||||
static let placeholderLabelText = NSLocalizedString("Applies in other Appearances", comment: "")
|
||||
var style: AppearanceStylePreference!
|
||||
var theme: String!
|
||||
var imageName: String!
|
||||
@@ -62,7 +66,7 @@ class IllustratedImageThemeView: ClickHoverImageView {
|
||||
imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: IllustratedImageThemeView.padding),
|
||||
imageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -IllustratedImageThemeView.padding),
|
||||
])
|
||||
let placeholder = NSTextField(labelWithString: NSLocalizedString("Applies in other Appearances", comment: ""))
|
||||
let placeholder = NSTextField(labelWithString: IllustratedImageThemeView.placeholderLabelText)
|
||||
placeholder.font = NSFont.systemFont(ofSize: NSFont.systemFontSize + 3)
|
||||
placeholder.textColor = .secondaryLabelColor
|
||||
placeholder.alignment = .center
|
||||
@@ -146,6 +150,14 @@ class IllustratedImageThemeView: ClickHoverImageView {
|
||||
}
|
||||
|
||||
class ShowHideIllustratedView {
|
||||
// Row labels exposed so `CustomizeStyleSheet.searchableStrings` can reference them without
|
||||
// duplicating `NSLocalizedString` calls. Each is the single source of truth for that row's
|
||||
// localized text; `setupItems` below uses these constants when building the rows.
|
||||
static let hideStatusIconsLabel = NSLocalizedString("Hide status icons", comment: "")
|
||||
static let hideStatusIconsSubtitle = NSLocalizedString("AltTab will show if the window is currently minimized or fullscreen with a status icon.", comment: "")
|
||||
static let hideSpaceNumberLabelsLabel = NSLocalizedString("Hide Space number labels", comment: "")
|
||||
static let hideColoredCirclesLabel = NSLocalizedString("Hide colored circles on mouse hover", comment: "")
|
||||
|
||||
private let style: AppearanceStylePreference
|
||||
private var showHideRows = [ShowHideRowInfo]()
|
||||
var illustratedImageView: IllustratedImageThemeView!
|
||||
@@ -180,8 +192,8 @@ class ShowHideIllustratedView {
|
||||
hideStatusIcons.uncheckedImage = "show_status_icons"
|
||||
hideStatusIcons.checkedImage = "hide_status_icons"
|
||||
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.leftViews = [TableGroupView.makeText(ShowHideIllustratedView.hideStatusIconsLabel)]
|
||||
hideStatusIcons.subTitle = ShowHideIllustratedView.hideStatusIconsSubtitle
|
||||
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
|
||||
@@ -196,7 +208,7 @@ class ShowHideIllustratedView {
|
||||
hideSpaceNumberLabels.uncheckedImage = "show_space_number_labels"
|
||||
hideSpaceNumberLabels.checkedImage = "hide_space_number_labels"
|
||||
hideSpaceNumberLabels.supportedStyles = [.thumbnails, .titles]
|
||||
hideSpaceNumberLabels.leftViews = [TableGroupView.makeText(NSLocalizedString("Hide Space number labels", comment: ""))]
|
||||
hideSpaceNumberLabels.leftViews = [TableGroupView.makeText(ShowHideIllustratedView.hideSpaceNumberLabelsLabel)]
|
||||
hideSpaceNumberLabels.rightViews.append(LabelAndControl.makeSwitch(hideSpaceNumberLabels.rowId, extraAction: { sender in
|
||||
self.onCheckboxClicked(sender: sender, rowId: hideSpaceNumberLabels.rowId)
|
||||
}))
|
||||
@@ -206,7 +218,7 @@ class ShowHideIllustratedView {
|
||||
hideColoredCircles.uncheckedImage = "show_colored_circles"
|
||||
hideColoredCircles.checkedImage = "hide_colored_circles"
|
||||
hideColoredCircles.supportedStyles = [.thumbnails]
|
||||
hideColoredCircles.leftViews = [TableGroupView.makeText(NSLocalizedString("Hide colored circles on mouse hover", comment: ""))]
|
||||
hideColoredCircles.leftViews = [TableGroupView.makeText(ShowHideIllustratedView.hideColoredCirclesLabel)]
|
||||
hideColoredCircles.rightViews.append(LabelAndControl.makeSwitch(hideColoredCircles.rowId, extraAction: { sender in
|
||||
self.onCheckboxClicked(sender: sender, rowId: hideColoredCircles.rowId)
|
||||
}))
|
||||
@@ -366,6 +378,14 @@ extension Popover: NSPopoverDelegate {
|
||||
}
|
||||
|
||||
class AppearanceTab: NSObject {
|
||||
// Localized labels for the per-setting rows. Exposed so `ShortcutEditor.AppearancePane`
|
||||
// (the per-shortcut override pane) can reference the same string for each conceptual
|
||||
// setting instead of re-`NSLocalizedString`-ing them.
|
||||
static let labelSize = NSLocalizedString("Size", comment: "")
|
||||
static let labelTheme = NSLocalizedString("Theme", comment: "")
|
||||
static let labelShortcutStyle = NSLocalizedString("After keys are released", comment: "")
|
||||
static let labelPreviewSelectedWindow = NSLocalizedString("Preview selected window", comment: "")
|
||||
|
||||
static var customizeStyleButton: NSButton!
|
||||
static var animationsButton: NSButton!
|
||||
static var customizeStyleSheet: CustomizeStyleSheet!
|
||||
@@ -389,8 +409,9 @@ class AppearanceTab: NSObject {
|
||||
static func initTab() -> NSView {
|
||||
customizeStyleButton = NSButton(title: getCustomizeStyleButtonTitle(), target: self, action: #selector(showCustomizeStyleSheet))
|
||||
animationsButton = NSButton(title: NSLocalizedString("Animations…", comment: ""), target: self, action: #selector(showAnimationsSheet))
|
||||
customizeStyleSheet = CustomizeStyleSheet()
|
||||
animationsSheet = AnimationsSheet()
|
||||
// Sheets are constructed lazily on first show — see `showCustomizeStyleSheet` /
|
||||
// `showAnimationsSheet`. Pre-build search is satisfied by the sheets' static
|
||||
// `searchableStrings`, consulted through `SettingsSearchIndex`.
|
||||
if proLockObserver == nil {
|
||||
proLockObserver = NotificationCenter.default.addObserver(
|
||||
forName: ProTransitionManager.proLockStateDidChangeNotification,
|
||||
@@ -470,9 +491,9 @@ class AppearanceTab: NSObject {
|
||||
guard let sizeControl else { return }
|
||||
refreshAutoSegmentAppearance(sizeControl)
|
||||
}
|
||||
table.addRow(leftText: NSLocalizedString("Size", comment: ""),
|
||||
table.addRow(leftText: AppearanceTab.labelSize,
|
||||
rightViews: [sizeControl, makeOverrideIcon("appearanceSizeOverride")])
|
||||
table.addRow(leftText: NSLocalizedString("Theme", comment: ""),
|
||||
table.addRow(leftText: AppearanceTab.labelTheme,
|
||||
rightViews: [LabelAndControl.makeSegmentedControl("appearanceTheme", AppearanceThemePreference.allCases, segmentWidth: 105, extraAction: { _ in
|
||||
ControlsTab.syncOverrideControlsToGlobal()
|
||||
refreshAllOverrideInfoLabels()
|
||||
@@ -560,7 +581,7 @@ class AppearanceTab: NSObject {
|
||||
guard let control else { return }
|
||||
refreshShortcutStyleSegmentAppearance(control)
|
||||
}
|
||||
table.addRow(leftText: NSLocalizedString("After keys are released", comment: ""),
|
||||
table.addRow(leftText: AppearanceTab.labelShortcutStyle,
|
||||
rightViews: [control, makeOverrideIcon("shortcutStyleOverride")])
|
||||
}
|
||||
|
||||
@@ -601,7 +622,7 @@ class AppearanceTab: NSObject {
|
||||
ControlsTab.syncOverrideControlsToGlobal()
|
||||
refreshAllOverrideInfoLabels()
|
||||
})
|
||||
previewSelectedWindowRowInfo = table.addRow(leftText: NSLocalizedString("Preview selected window", comment: ""),
|
||||
previewSelectedWindowRowInfo = table.addRow(leftText: AppearanceTab.labelPreviewSelectedWindow,
|
||||
rightViews: [switchControl, makeOverrideIcon("previewFocusedWindowOverride")])
|
||||
updatePreviewSelectedWindowState()
|
||||
}
|
||||
@@ -637,14 +658,18 @@ class AppearanceTab: NSObject {
|
||||
}
|
||||
|
||||
@objc static func toggleCustomizeStyleButton() {
|
||||
// Recreate the sheet so the next open rebuilds with current style preference baked in.
|
||||
// (This is the "appearance style changed → reset the customize sheet" path.)
|
||||
customizeStyleSheet = CustomizeStyleSheet()
|
||||
}
|
||||
|
||||
@objc static func showCustomizeStyleSheet() {
|
||||
if customizeStyleSheet == nil { customizeStyleSheet = CustomizeStyleSheet() }
|
||||
SettingsWindow.shared.beginSheetWithSearchHighlight(customizeStyleSheet)
|
||||
}
|
||||
|
||||
@objc static func showAnimationsSheet() {
|
||||
if animationsSheet == nil { animationsSheet = AnimationsSheet() }
|
||||
SettingsWindow.shared.beginSheetWithSearchHighlight(animationsSheet)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import Cocoa
|
||||
|
||||
class CustomizeStyleSheet: SheetWindow {
|
||||
// Local labels (rows owned by this sheet). The Show/Hide rows below are sourced from
|
||||
// `ShowHideIllustratedView`'s static constants so each NSLocalizedString call lives in
|
||||
// exactly one place across the codebase.
|
||||
private static let labelShowTitles = NSLocalizedString("Show titles", comment: "")
|
||||
private static let labelTitleTruncation = NSLocalizedString("Title truncation", comment: "")
|
||||
|
||||
/// Pre-build search index for the open-button. See `SettingsSearchIndex.sheetSearchableStrings`.
|
||||
static let searchableStrings: [String] = [
|
||||
labelShowTitles,
|
||||
labelTitleTruncation,
|
||||
ShowHideIllustratedView.hideStatusIconsLabel,
|
||||
ShowHideIllustratedView.hideStatusIconsSubtitle,
|
||||
ShowHideIllustratedView.hideSpaceNumberLabelsLabel,
|
||||
ShowHideIllustratedView.hideColoredCirclesLabel,
|
||||
IllustratedImageThemeView.placeholderLabelText,
|
||||
] + ShowTitlesPreference.allCases.map { $0.localizedString }
|
||||
+ TitleTruncationPreference.allCases.map { $0.localizedString }
|
||||
|
||||
static let illustratedImageWidth = width
|
||||
|
||||
let style = Preferences.appearanceStyle
|
||||
@@ -17,7 +35,7 @@ class CustomizeStyleSheet: SheetWindow {
|
||||
showHideIllustratedView = ShowHideIllustratedView(style, illustratedImageView)
|
||||
let showHideView = showHideIllustratedView.makeView()
|
||||
let advancedTable = TableGroupView(width: CustomizeStyleSheet.width)
|
||||
let showTitles = TableGroupView.Row(leftTitle: NSLocalizedString("Show titles", comment: ""),
|
||||
let showTitles = TableGroupView.Row(leftTitle: Self.labelShowTitles,
|
||||
rightViews: [LabelAndControl.makeDropdown(
|
||||
"showTitles", ShowTitlesPreference.allCases, extraAction: { [weak self] _ in
|
||||
self?.showTitlesIllustratedImage()
|
||||
@@ -25,7 +43,7 @@ class CustomizeStyleSheet: SheetWindow {
|
||||
advancedTable.addRow(showTitles, onMouseEntered: { [weak self] _, _ in
|
||||
self?.showTitlesIllustratedImage()
|
||||
})
|
||||
let titleTruncation = TableGroupView.Row(leftTitle: NSLocalizedString("Title truncation", comment: ""),
|
||||
let titleTruncation = TableGroupView.Row(leftTitle: Self.labelTitleTruncation,
|
||||
rightViews: LabelAndControl.makeRadioButtons("titleTruncation", TitleTruncationPreference.allCases))
|
||||
advancedTable.addRow(titleTruncation)
|
||||
advancedTable.onMouseExited = { [weak self] event, view in
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
import Cocoa
|
||||
|
||||
class AdditionalControlsSheet: SheetWindow {
|
||||
// Localized labels live here once. `searchableStrings` and `makeContentView` both reference
|
||||
// these constants so changing a string takes one edit, and search can't silently miss a row
|
||||
// because of a typo divergence between the two paths.
|
||||
private static let title = NSLocalizedString("Additional controls", comment: "")
|
||||
private static let titleMiscellaneous = NSLocalizedString("Miscellaneous", comment: "")
|
||||
private static let labelArrows = NSLocalizedString("Select windows using arrow keys", comment: "")
|
||||
private static let labelVim = NSLocalizedString("Select windows using vim keys", comment: "")
|
||||
private static let labelMouse = NSLocalizedString("Select windows on mouse hover", comment: "")
|
||||
private static let labelCursorFollow = NSLocalizedString("Cursor follows focus", comment: "")
|
||||
private static let labelTrackpad = NSLocalizedString("Trackpad haptic feedback", comment: "")
|
||||
|
||||
/// Pre-build search index for the open-button. See `SettingsSearchIndex.sheetSearchableStrings`.
|
||||
static let searchableStrings: [String] = [
|
||||
title, titleMiscellaneous,
|
||||
labelArrows, labelVim, labelMouse,
|
||||
labelCursorFollow, labelTrackpad,
|
||||
] + CursorFollowFocus.allCases.map { $0.localizedString }
|
||||
|
||||
override func makeContentView() -> NSView {
|
||||
let enableArrows = TableGroupView.Row(leftTitle: NSLocalizedString("Select windows using arrow keys", comment: ""),
|
||||
let enableArrows = TableGroupView.Row(leftTitle: Self.labelArrows,
|
||||
rightViews: [LabelAndControl.makeSwitch("arrowKeysEnabled", extraAction: ControlsTab.arrowKeysEnabledCallback)])
|
||||
let enableVimKeys = TableGroupView.Row(leftTitle: NSLocalizedString("Select windows using vim keys", comment: ""),
|
||||
let enableVimKeys = TableGroupView.Row(leftTitle: Self.labelVim,
|
||||
rightViews: [LabelAndControl.makeSwitch("vimKeysEnabled", extraAction: ControlsTab.vimKeysEnabledCallback)])
|
||||
let enableMouse = TableGroupView.Row(leftTitle: NSLocalizedString("Select windows on mouse hover", comment: ""),
|
||||
let enableMouse = TableGroupView.Row(leftTitle: Self.labelMouse,
|
||||
rightViews: [LabelAndControl.makeSwitch("mouseHoverEnabled")])
|
||||
let enableCursorFollowFocus = TableGroupView.Row(leftTitle: NSLocalizedString("Cursor follows focus", comment: ""),
|
||||
let enableCursorFollowFocus = TableGroupView.Row(leftTitle: Self.labelCursorFollow,
|
||||
rightViews: [LabelAndControl.makeDropdown("cursorFollowFocus", CursorFollowFocus.allCases)])
|
||||
let enableTrackpadHapticFeedback = TableGroupView.Row(leftTitle: NSLocalizedString("Trackpad haptic feedback", comment: ""),
|
||||
let enableTrackpadHapticFeedback = TableGroupView.Row(leftTitle: Self.labelTrackpad,
|
||||
rightViews: [LabelAndControl.makeSwitch("trackpadHapticFeedbackEnabled")])
|
||||
ControlsTab.arrowKeysCheckbox = enableArrows.rightViews[0] as? Switch
|
||||
ControlsTab.vimKeysCheckbox = enableVimKeys.rightViews[0] as? Switch
|
||||
ControlsTab.arrowKeysEnabledCallback(ControlsTab.arrowKeysCheckbox)
|
||||
ControlsTab.vimKeysEnabledCallback(ControlsTab.vimKeysCheckbox)
|
||||
let table1 = TableGroupView(title: NSLocalizedString("Additional controls", comment: ""),
|
||||
width: SheetWindow.width)
|
||||
let table1 = TableGroupView(title: Self.title, width: SheetWindow.width)
|
||||
_ = table1.addRow(enableArrows)
|
||||
_ = table1.addRow(enableVimKeys)
|
||||
_ = table1.addRow(enableMouse)
|
||||
let table2 = TableGroupView(title: NSLocalizedString("Miscellaneous", comment: ""),
|
||||
width: SheetWindow.width)
|
||||
let table2 = TableGroupView(title: Self.titleMiscellaneous, width: SheetWindow.width)
|
||||
_ = table2.addRow(enableCursorFollowFocus)
|
||||
_ = table2.addRow(enableTrackpadHapticFeedback)
|
||||
let view = TableGroupSetView(originalViews: [table1, table2], padding: 0)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,828 @@
|
||||
import Cocoa
|
||||
import ShortcutRecorder
|
||||
|
||||
/// A single recycled editor for the per-shortcut settings. Built once at ControlsTab init.
|
||||
///
|
||||
/// The user can configure N shortcuts (each its own modifier combo, filter rules, appearance
|
||||
/// overrides, ordering rules). Previously the UI built one full editor view per shortcut and
|
||||
/// toggled `isHidden` between them; that meant N × (~50 NSViews) resident at once, which made
|
||||
/// every AppKit layout / key-state walk expensive — especially on macOS Tahoe where AppKit's
|
||||
/// per-control work routes through SwiftUI internals.
|
||||
///
|
||||
/// The recycled design follows the same philosophy as `ExceptionEditorView` and the switcher's
|
||||
/// `ThumbnailsView.recycledViews` pool: one editor exists, `bind(toShortcut:)` re-aims each of
|
||||
/// its bound controls at a different preference key (and refreshes the displayed value). View
|
||||
/// identity and tree shape stay constant across shortcut switches.
|
||||
final class ShortcutEditor {
|
||||
let view: NSView
|
||||
|
||||
private let trigger: TriggerBinding
|
||||
/// Panes are constructed lazily on first selection — only one is visible at a time, so
|
||||
/// building the other two upfront is pure cost (~30 controls each in the cascade walk).
|
||||
/// `bind(toShortcut:)` only re-binds panes that exist; the search index is populated with
|
||||
/// every pane's static `searchableStrings` at `init` time so cross-pane search still works
|
||||
/// before a pane is built.
|
||||
private var filteringPane: FilteringPane?
|
||||
private var appearancePane: AppearancePane?
|
||||
private var orderingPane: OrderingPane?
|
||||
private let tabControl: NSSegmentedControl
|
||||
private let outerContainer: NSStackView
|
||||
/// Flexible NSView pinned at the bottom of `outerContainer`. Absorbs the slack between the
|
||||
/// natural content height and `contentMinHeight` so the gap stays at the bottom (invisible)
|
||||
/// rather than between gravity areas (visible).
|
||||
private var bottomSpacer: NSView!
|
||||
private let editorWidth: CGFloat
|
||||
private(set) var currentIndex: Int = 0
|
||||
|
||||
/// Currently selected tab segment (Filtering = 0, Appearance = 1, Ordering = 2). Shared
|
||||
/// across editor instances and persisted on the type so that re-opening Settings keeps the
|
||||
/// last-viewed segment.
|
||||
static var selectedTabSegment: Int = 0
|
||||
/// Minimum height for an editor's content block — anchored roughly to the Filtering pane
|
||||
/// (the tallest of the three tabs) so switching to a short tab like Ordering doesn't make
|
||||
/// the surrounding rounded section visibly snap up. The bottom just gets whitespace instead.
|
||||
private static let contentMinHeight: CGFloat = 400
|
||||
/// Fixed height for the Trigger row content (recorder + labels). Pinned rather than derived
|
||||
/// from `mainRow.fittingSize.height` because `RecorderControl`'s intrinsic height isn't
|
||||
/// guaranteed to be set when `TableGroupView.setMainRow` snapshots it, which previously
|
||||
/// produced inconsistent Trigger row heights across shortcuts.
|
||||
static let triggerRowContentHeight: CGFloat = 22
|
||||
|
||||
/// Localized labels shared between the recycled shortcut editor and the fixed-bind gesture
|
||||
/// editor in `ControlsTab.makeGestureEditor`. Both build the same Trigger row + tab control
|
||||
/// shape, so exposing the strings here keeps a single source of truth for each one.
|
||||
static let triggerLabel = NSLocalizedString("Trigger", comment: "")
|
||||
static let tabLabelFiltering = NSLocalizedString("Filtering", comment: "")
|
||||
static let tabLabelAppearance = NSLocalizedString("Appearance", comment: "")
|
||||
static let tabLabelOrdering = NSLocalizedString("Ordering & Grouping", comment: "")
|
||||
|
||||
init(width: CGFloat) {
|
||||
editorWidth = width
|
||||
trigger = TriggerBinding()
|
||||
|
||||
// Populate the active SettingsSearchIndex with every pane's static strings — this is the
|
||||
// section-level search registration that lets queries match content in panes that haven't
|
||||
// been lazily built yet. The panes themselves register their highlight targets only once
|
||||
// they're constructed (so live highlights inside a built pane work as usual).
|
||||
SettingsSearchIndex.registerStrings(FilteringPane.searchableStrings)
|
||||
SettingsSearchIndex.registerStrings(AppearancePane.searchableStrings)
|
||||
SettingsSearchIndex.registerStrings(OrderingPane.searchableStrings)
|
||||
|
||||
let triggerTable = TableGroupView(width: width)
|
||||
triggerTable.addRow(TableGroupView.Row(
|
||||
leftTitle: ShortcutEditor.triggerLabel,
|
||||
rightViews: [trigger.view]))
|
||||
|
||||
let labels = [
|
||||
ShortcutEditor.tabLabelFiltering,
|
||||
ShortcutEditor.tabLabelAppearance,
|
||||
ShortcutEditor.tabLabelOrdering,
|
||||
]
|
||||
tabControl = NSSegmentedControl(labels: labels, trackingMode: .selectOne, target: nil, action: nil)
|
||||
tabControl.selectedSegment = ShortcutEditor.selectedTabSegment
|
||||
LabelAndControl.applySystemSelectedSegmentStyle(tabControl)
|
||||
tabControl.widthAnchor.constraint(equalToConstant: width).isActive = true
|
||||
let segmentWidth = width / CGFloat(labels.count)
|
||||
for i in 0..<labels.count { tabControl.setWidth(segmentWidth, forSegment: i) }
|
||||
|
||||
// The view tree is: triggerTable / tabControl / one of the three lazily-built panes.
|
||||
// With `distribution = .gravityAreas` (NSStackView's default) plus the `contentMinHeight`
|
||||
// constraint, NSStackView distributes the slack between gravity areas in ways that don't
|
||||
// match the simple "pack at the top" mental model — leaving a gap between the trigger
|
||||
// row and the tab control + visible pane. Switching to `.fill` distribution + a trailing
|
||||
// flexible spacer puts the slack at the bottom, where it's invisible, regardless of how
|
||||
// many panes have been built.
|
||||
let bottomSpacer = NSView()
|
||||
bottomSpacer.translatesAutoresizingMaskIntoConstraints = false
|
||||
// The spacer must absorb slack ahead of any sibling. The triggerTable / tabControl /
|
||||
// pane views default to `.defaultLow` (250) hugging on the vertical axis, so we pin the
|
||||
// spacer well below that (1) — under `distribution = .fill`, NSStackView stretches the
|
||||
// lowest-hugging view first, which is what we want.
|
||||
bottomSpacer.setContentHuggingPriority(NSLayoutConstraint.Priority(1), for: .vertical)
|
||||
bottomSpacer.setContentCompressionResistancePriority(NSLayoutConstraint.Priority(1), for: .vertical)
|
||||
self.bottomSpacer = bottomSpacer
|
||||
|
||||
outerContainer = NSStackView()
|
||||
outerContainer.orientation = .vertical
|
||||
outerContainer.alignment = .leading
|
||||
outerContainer.spacing = TableGroupSetView.tableGroupSpacing
|
||||
outerContainer.distribution = .fill
|
||||
outerContainer.addArrangedSubview(triggerTable)
|
||||
outerContainer.addArrangedSubview(tabControl)
|
||||
outerContainer.addArrangedSubview(bottomSpacer)
|
||||
// Extra breathing room below the Trigger row — it's the headline action of the editor
|
||||
// and the tab control / content tables that follow are secondary configuration.
|
||||
outerContainer.setCustomSpacing(10, after: triggerTable)
|
||||
outerContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: ShortcutEditor.contentMinHeight).isActive = true
|
||||
view = outerContainer
|
||||
|
||||
tabControl.onAction = { [weak self] control in
|
||||
self?.applySelectedSegment((control as! NSSegmentedControl).selectedSegment)
|
||||
}
|
||||
|
||||
// Build the initially-selected pane so the editor isn't empty on first open. The other
|
||||
// two panes wait until the user clicks their tab segment.
|
||||
ensurePaneBuilt(forSegment: ShortcutEditor.selectedTabSegment)
|
||||
}
|
||||
|
||||
/// Re-aim every owned binding at `index`'s preference keys, then refresh visible state. Only
|
||||
/// already-constructed panes are rebound; unbuilt panes pick up the current index from
|
||||
/// `ensurePaneBuilt` when they're eventually created.
|
||||
func bind(toShortcut index: Int) {
|
||||
currentIndex = index
|
||||
trigger.bind(toShortcut: index)
|
||||
filteringPane?.bind(toShortcut: index)
|
||||
appearancePane?.bind(toShortcut: index)
|
||||
orderingPane?.bind(toShortcut: index)
|
||||
}
|
||||
|
||||
/// Re-display values without changing the bound index. Called when a global appearance pref
|
||||
/// changes (non-overridden controls need to resnap to the new global) or after a pro-lock
|
||||
/// transition (stored values may have been downgraded).
|
||||
func refreshFromCurrentBind() {
|
||||
bind(toShortcut: currentIndex)
|
||||
}
|
||||
|
||||
/// Programmatically switch to the Appearance segment. Used by AppearanceTab's "Overridden in
|
||||
/// Shortcut: N" link to deep-link into the right tab.
|
||||
func showAppearanceSegment() {
|
||||
applySelectedSegment(1)
|
||||
}
|
||||
|
||||
/// Externally-driven tab swap — used by ControlsTab to keep the gesture editor's tab in sync
|
||||
/// with the shortcut editor when the user changes it on the other one.
|
||||
func applySelectedSegment(_ segment: Int) {
|
||||
ShortcutEditor.selectedTabSegment = segment
|
||||
tabControl.selectedSegment = segment
|
||||
ensurePaneBuilt(forSegment: segment)
|
||||
}
|
||||
|
||||
private func ensurePaneBuilt(forSegment segment: Int) {
|
||||
switch segment {
|
||||
case 0:
|
||||
if filteringPane == nil {
|
||||
let pane = FilteringPane(width: editorWidth)
|
||||
pane.bind(toShortcut: currentIndex)
|
||||
insertPaneBeforeBottomSpacer(pane.view)
|
||||
filteringPane = pane
|
||||
}
|
||||
case 1:
|
||||
if appearancePane == nil {
|
||||
let pane = AppearancePane(width: editorWidth)
|
||||
pane.bind(toShortcut: currentIndex)
|
||||
insertPaneBeforeBottomSpacer(pane.view)
|
||||
appearancePane = pane
|
||||
}
|
||||
case 2:
|
||||
if orderingPane == nil {
|
||||
let pane = OrderingPane(width: editorWidth)
|
||||
pane.bind(toShortcut: currentIndex)
|
||||
insertPaneBeforeBottomSpacer(pane.view)
|
||||
orderingPane = pane
|
||||
}
|
||||
default: break
|
||||
}
|
||||
filteringPane?.view.isHidden = segment != 0
|
||||
appearancePane?.view.isHidden = segment != 1
|
||||
orderingPane?.view.isHidden = segment != 2
|
||||
}
|
||||
|
||||
/// Insert a freshly-built pane just before the trailing `bottomSpacer`, so the spacer
|
||||
/// remains at the bottom and continues to absorb the `contentMinHeight` slack.
|
||||
private func insertPaneBeforeBottomSpacer(_ paneView: NSView) {
|
||||
let spacerIndex = outerContainer.arrangedSubviews.firstIndex(of: bottomSpacer) ?? outerContainer.arrangedSubviews.count
|
||||
outerContainer.insertArrangedSubview(paneView, at: spacerIndex)
|
||||
}
|
||||
|
||||
/// Per-segment static searchable strings for the tab control. Consumed by
|
||||
/// `SettingsWindow.highlightTarget(_ segmentedControl:)` so a query that matches content
|
||||
/// inside a (possibly unbuilt) pane lights up the corresponding tab segment.
|
||||
var tabSegmentSearchableStringsEntry: (key: ObjectIdentifier, perSegmentStrings: [[String]]) {
|
||||
(ObjectIdentifier(tabControl), [
|
||||
FilteringPane.searchableStrings,
|
||||
AppearancePane.searchableStrings,
|
||||
OrderingPane.searchableStrings,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trigger row (hold + next-window recorders)
|
||||
|
||||
/// The Trigger row owns two recorders (hold + next-window). They have a cross-reference: the
|
||||
/// next-window recorder is restricted to the modifiers of the hold key. On rebind we swap both
|
||||
/// recorders' identifiers, refresh both displayed values, and resync `shortcutControls` so other
|
||||
/// modules (CustomRecorderControl, the conflict detector) find the right control.
|
||||
final class TriggerBinding {
|
||||
let view: NSView
|
||||
private let holdRecorder: CustomRecorderControl
|
||||
private let nextRecorder: CustomRecorderControl
|
||||
private let holdLabel = NSTextField(labelWithString: "")
|
||||
private let andPressLabel: NSTextField
|
||||
private let selectNextLabel: NSTextField
|
||||
private static let holdLabelText = NSLocalizedString("Hold", comment: "")
|
||||
private static let andPressText = NSLocalizedString("and press", comment: "")
|
||||
private static let selectNextText = NSLocalizedString("Select next window", comment: "")
|
||||
private var currentIndex: Int = -1
|
||||
|
||||
init() {
|
||||
// Placeholder identifier — replaced in bind().
|
||||
holdRecorder = CustomRecorderControl(nil, false, "holdShortcut0")
|
||||
nextRecorder = CustomRecorderControl(nil, true, "nextWindowShortcut0")
|
||||
|
||||
let holdLabelField = LabelAndControl.makeLabel(TriggerBinding.holdLabelText)
|
||||
andPressLabel = LabelAndControl.makeLabel(TriggerBinding.andPressText)
|
||||
selectNextLabel = LabelAndControl.makeLabel(TriggerBinding.selectNextText)
|
||||
|
||||
let stack = NSStackView(views: [holdLabelField, holdRecorder, andPressLabel, nextRecorder, selectNextLabel])
|
||||
stack.orientation = .horizontal
|
||||
stack.alignment = .centerY
|
||||
stack.spacing = TableGroupView.spacing
|
||||
stack.heightAnchor.constraint(equalToConstant: ShortcutEditor.triggerRowContentHeight).isActive = true
|
||||
view = stack
|
||||
|
||||
// Recorder edits need three things to happen in order:
|
||||
// 1. Persist the new shortcut into `Preferences` (via `controlWasChanged`). Otherwise
|
||||
// the value lives only in `RecorderControl.objectValue` and rebinding to a different
|
||||
// shortcut later loses it — the sidebar row's summary and the recorder's displayed
|
||||
// value both come from `Preferences.shortcut`, so they'd show stale state.
|
||||
// 2. Re-run `shortcutChangedCallback` so the global hotkey gets (re-)registered with
|
||||
// the new value and modifier restrictions cascade to the sibling recorder.
|
||||
// 3. Refresh the sidebar row's summary so the user sees the new modifier + key combo
|
||||
// next to "Shortcut N" without needing to switch rows and come back.
|
||||
holdRecorder.onAction = { [weak self] _ in
|
||||
guard let self else { return }
|
||||
LabelAndControl.controlWasChanged(self.holdRecorder, nil)
|
||||
ControlsTab.shortcutChangedCallback(self.holdRecorder)
|
||||
ControlsTab.refreshShortcutRowContent(forIndex: self.currentIndex)
|
||||
}
|
||||
nextRecorder.onAction = { [weak self] _ in
|
||||
guard let self else { return }
|
||||
LabelAndControl.controlWasChanged(self.nextRecorder, nil)
|
||||
ControlsTab.shortcutChangedCallback(self.nextRecorder)
|
||||
ControlsTab.refreshShortcutRowContent(forIndex: self.currentIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func bind(toShortcut index: Int) {
|
||||
// Clear any previous mapping in `shortcutControls`.
|
||||
if currentIndex >= 0 {
|
||||
ControlsTab.shortcutControls.removeValue(forKey: Preferences.indexToName("holdShortcut", currentIndex))
|
||||
ControlsTab.shortcutControls.removeValue(forKey: Preferences.indexToName("nextWindowShortcut", currentIndex))
|
||||
}
|
||||
currentIndex = index
|
||||
let holdKey = Preferences.indexToName("holdShortcut", index)
|
||||
let nextKey = Preferences.indexToName("nextWindowShortcut", index)
|
||||
holdRecorder.identifier = NSUserInterfaceItemIdentifier(holdKey)
|
||||
nextRecorder.identifier = NSUserInterfaceItemIdentifier(nextKey)
|
||||
holdRecorder.objectValue = Preferences.shortcut(holdKey)
|
||||
nextRecorder.objectValue = Preferences.shortcut(nextKey)
|
||||
ControlsTab.shortcutControls[holdKey] = (holdRecorder, TriggerBinding.holdLabelText)
|
||||
ControlsTab.shortcutControls[nextKey] = (nextRecorder, TriggerBinding.selectNextText)
|
||||
// Reapply modifier restriction on the next-window recorder based on the hold shortcut.
|
||||
if let holdModifiers = Preferences.shortcut(holdKey)?.modifierFlags {
|
||||
nextRecorder.restrictModifiers(holdModifiers)
|
||||
} else {
|
||||
nextRecorder.restrictModifiers([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filtering pane
|
||||
|
||||
/// Filtering tab content: per-shortcut filtering dropdowns. All controls bound via
|
||||
/// ShortcutBoundDropdown; no override semantics here.
|
||||
final class FilteringPane {
|
||||
// Single source of truth for the pane's labels — referenced by both `init` (when the pane is
|
||||
// actually built) and `searchableStrings` (used by the parent ShortcutEditor at section-build
|
||||
// time to populate the search index even before the pane has been built lazily).
|
||||
private static let labelApps = NSLocalizedString("Show windows from applications", comment: "")
|
||||
private static let labelSpaces = NSLocalizedString("Show windows from Spaces", comment: "")
|
||||
private static let labelScreens = NSLocalizedString("Show windows from screens", comment: "")
|
||||
private static let labelMinimized = NSLocalizedString("Show minimized windows", comment: "")
|
||||
private static let labelHidden = NSLocalizedString("Show hidden windows", comment: "")
|
||||
private static let labelFullscreen = NSLocalizedString("Show fullscreen windows", comment: "")
|
||||
private static let labelWindowless = NSLocalizedString("Show apps with no open window", comment: "")
|
||||
|
||||
/// All text contributed to the search index by this pane. Includes row titles + the localized
|
||||
/// strings of each dropdown's `MacroPreference` cases. `ShortcutEditor.init` registers this
|
||||
/// list into the active `SettingsSearchIndex` so search matches inside the pane work even
|
||||
/// before the pane is constructed (the user-facing button to switch to this tab still lights
|
||||
/// up; clicking it triggers the lazy build, after which in-pane highlights work normally).
|
||||
static let searchableStrings: [String] = [
|
||||
labelApps, labelSpaces, labelScreens,
|
||||
labelMinimized, labelHidden, labelFullscreen, labelWindowless,
|
||||
] + AppsToShowPreference.allCases.map { $0.localizedString }
|
||||
+ SpacesToShowPreference.allCases.map { $0.localizedString }
|
||||
+ ScreensToShowPreference.allCases.map { $0.localizedString }
|
||||
+ ShowHowPreference.allCases.map { $0.localizedString }
|
||||
|
||||
let view: NSView
|
||||
private let appsToShow: ShortcutBoundDropdown
|
||||
private let spacesToShow: ShortcutBoundDropdown
|
||||
private let screensToShow: ShortcutBoundDropdown
|
||||
private let showMinimized: ShortcutBoundDropdown
|
||||
private let showHidden: ShortcutBoundDropdown
|
||||
private let showFullscreen: ShortcutBoundDropdown
|
||||
private let showWindowless: ShortcutBoundDropdown
|
||||
|
||||
init(width: CGFloat) {
|
||||
appsToShow = ShortcutBoundDropdown(baseName: "appsToShow", cases: AppsToShowPreference.allCases)
|
||||
spacesToShow = ShortcutBoundDropdown(baseName: "spacesToShow", cases: SpacesToShowPreference.allCases)
|
||||
screensToShow = ShortcutBoundDropdown(baseName: "screensToShow", cases: ScreensToShowPreference.allCases)
|
||||
showMinimized = ShortcutBoundDropdown(baseName: "showMinimizedWindows", cases: ShowHowPreference.allCases)
|
||||
showHidden = ShortcutBoundDropdown(baseName: "showHiddenWindows", cases: ShowHowPreference.allCases)
|
||||
showFullscreen = ShortcutBoundDropdown(baseName: "showFullscreenWindows", cases: ShowHowPreference.allCases.filter { $0 != .showAtTheEnd })
|
||||
showWindowless = ShortcutBoundDropdown(baseName: "showWindowlessApps", cases: ShowHowPreference.allCases)
|
||||
|
||||
let table = TableGroupView(width: width)
|
||||
table.addRow(leftViews: [TableGroupView.makeText(Self.labelApps)], rightViews: [appsToShow])
|
||||
table.addRow(leftViews: [TableGroupView.makeText(Self.labelSpaces)], rightViews: [spacesToShow])
|
||||
table.addRow(leftViews: [TableGroupView.makeText(Self.labelScreens)], rightViews: [screensToShow])
|
||||
table.addRow(TableGroupView.Row(leftTitle: Self.labelMinimized, rightViews: [showMinimized]))
|
||||
table.addRow(TableGroupView.Row(leftTitle: Self.labelHidden, rightViews: [showHidden]))
|
||||
table.addRow(TableGroupView.Row(leftTitle: Self.labelFullscreen, rightViews: [showFullscreen]))
|
||||
table.addRow(TableGroupView.Row(leftTitle: Self.labelWindowless, rightViews: [showWindowless]))
|
||||
view = table
|
||||
}
|
||||
|
||||
func bind(toShortcut index: Int) {
|
||||
appsToShow.bind(toShortcut: index)
|
||||
spacesToShow.bind(toShortcut: index)
|
||||
screensToShow.bind(toShortcut: index)
|
||||
showMinimized.bind(toShortcut: index)
|
||||
showHidden.bind(toShortcut: index)
|
||||
showFullscreen.bind(toShortcut: index)
|
||||
showWindowless.bind(toShortcut: index)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ordering & Grouping pane
|
||||
|
||||
final class OrderingPane {
|
||||
private static let labelGroupApps = NSLocalizedString("Group apps", comment: "")
|
||||
private static let labelGroupTabs = NSLocalizedString("Group tabs", comment: "")
|
||||
private static let labelWindowOrder = NSLocalizedString("Order windows by", comment: "")
|
||||
|
||||
/// See `FilteringPane.searchableStrings`.
|
||||
static let searchableStrings: [String] = [
|
||||
labelGroupApps, labelGroupTabs, labelWindowOrder,
|
||||
] + ShowAppsOrWindowsPreference.allCases.map { $0.localizedString }
|
||||
+ GroupTabsPreference.allCases.map { $0.localizedString }
|
||||
+ WindowOrderPreference.allCases.map { $0.localizedString }
|
||||
|
||||
let view: NSView
|
||||
private let showAppsOrWindows: ShortcutBoundDropdown
|
||||
private let showTabsAsWindows: ShortcutBoundDropdown
|
||||
private let windowOrder: ShortcutBoundDropdown
|
||||
|
||||
init(width: CGFloat) {
|
||||
showAppsOrWindows = ShortcutBoundDropdown(baseName: "showAppsOrWindows", cases: ShowAppsOrWindowsPreference.allCases)
|
||||
showTabsAsWindows = ShortcutBoundDropdown(baseName: "showTabsAsWindows", cases: GroupTabsPreference.allCases)
|
||||
windowOrder = ShortcutBoundDropdown(baseName: "windowOrder", cases: WindowOrderPreference.allCases)
|
||||
|
||||
let table = TableGroupView(width: width)
|
||||
table.addRow(TableGroupView.Row(leftTitle: Self.labelGroupApps, rightViews: [showAppsOrWindows]))
|
||||
table.addRow(TableGroupView.Row(leftTitle: Self.labelGroupTabs, rightViews: [showTabsAsWindows]))
|
||||
table.addRow(TableGroupView.Row(leftTitle: Self.labelWindowOrder, rightViews: [windowOrder]))
|
||||
view = table
|
||||
}
|
||||
|
||||
func bind(toShortcut index: Int) {
|
||||
showAppsOrWindows.bind(toShortcut: index)
|
||||
showTabsAsWindows.bind(toShortcut: index)
|
||||
windowOrder.bind(toShortcut: index)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Appearance pane (override controls)
|
||||
|
||||
final class AppearancePane {
|
||||
/// See `FilteringPane.searchableStrings`. The labels (Size / Theme / After keys released /
|
||||
/// Preview selected window) are shared with the global appearance UI; reference the same
|
||||
/// constants from `AppearanceTab` rather than re-declaring them.
|
||||
static let searchableStrings: [String] = [
|
||||
AppearanceTab.labelSize,
|
||||
AppearanceTab.labelTheme,
|
||||
AppearanceTab.labelShortcutStyle,
|
||||
AppearanceTab.labelPreviewSelectedWindow,
|
||||
ProBadgeView.proLabel,
|
||||
] + AppearanceStylePreference.allCases.map { $0.localizedString }
|
||||
+ AppearanceSizePreference.allCases.map { $0.localizedString }
|
||||
+ AppearanceThemePreference.allCases.map { $0.localizedString }
|
||||
+ ShortcutStylePreference.allCases.map { $0.localizedString }
|
||||
|
||||
let view: NSView
|
||||
private let style: ShortcutOverrideRadios
|
||||
private let size: ShortcutOverrideSegmented
|
||||
private let theme: ShortcutOverrideSegmented
|
||||
private let shortcutStyle: ShortcutOverrideSegmented
|
||||
private let preview: ShortcutOverrideSwitch
|
||||
|
||||
init(width: CGFloat) {
|
||||
let onChange: () -> Void = {
|
||||
AppearanceTab.refreshAllOverrideInfoLabels()
|
||||
}
|
||||
|
||||
style = ShortcutOverrideRadios(
|
||||
baseName: "appearanceStyleOverride",
|
||||
cases: AppearanceStylePreference.allCases,
|
||||
globalIndex: { Preferences.appearanceStyle.index },
|
||||
proGatedIndices: AppearanceTab.proGatedAppearanceStyleIndices(),
|
||||
onChange: onChange)
|
||||
AppearanceTab.addProBadgesToStyleButtons(style.stack)
|
||||
|
||||
size = ShortcutOverrideSegmented(
|
||||
baseName: "appearanceSizeOverride",
|
||||
cases: AppearanceSizePreference.allCases,
|
||||
globalIndex: { Preferences.appearanceSize.index },
|
||||
proGatedIndices: [AppearanceSizePreference.allCases.firstIndex(of: .auto)!],
|
||||
segmentWidth: 100,
|
||||
attachBadge: { c in AppearanceTab.addProBadgeToAutoSegment(c) },
|
||||
refreshBadge: { c, overlay in
|
||||
AppearanceTab.refreshTrailingSegmentBadge(c, proIndex: AppearanceSizePreference.allCases.firstIndex(of: .auto)!, overlay: overlay)
|
||||
},
|
||||
onChange: onChange)
|
||||
|
||||
theme = ShortcutOverrideSegmented(
|
||||
baseName: "appearanceThemeOverride",
|
||||
cases: AppearanceThemePreference.allCases,
|
||||
globalIndex: { Preferences.appearanceTheme.index },
|
||||
proGatedIndices: [],
|
||||
segmentWidth: 100,
|
||||
attachBadge: nil,
|
||||
refreshBadge: nil,
|
||||
onChange: onChange)
|
||||
|
||||
shortcutStyle = ShortcutOverrideSegmented(
|
||||
baseName: "shortcutStyleOverride",
|
||||
cases: ShortcutStylePreference.allCases,
|
||||
globalIndex: { Preferences.shortcutStyle.index },
|
||||
proGatedIndices: [ShortcutStylePreference.allCases.firstIndex(of: .searchOnRelease)!],
|
||||
segmentWidth: 100,
|
||||
attachBadge: { c in AppearanceTab.addProBadgeToShortcutStyleSegment(c, proIndex: ShortcutStylePreference.allCases.firstIndex(of: .searchOnRelease)!) },
|
||||
refreshBadge: { c, overlay in
|
||||
AppearanceTab.refreshTrailingSegmentBadge(c, proIndex: ShortcutStylePreference.allCases.firstIndex(of: .searchOnRelease)!, overlay: overlay)
|
||||
},
|
||||
onChange: onChange)
|
||||
|
||||
preview = ShortcutOverrideSwitch(
|
||||
baseName: "previewFocusedWindowOverride",
|
||||
globalValue: { Preferences.previewSelectedWindow },
|
||||
onChange: onChange)
|
||||
|
||||
let table = TableGroupView(width: width)
|
||||
let styleRow = NSStackView(views: [style.stack, style.unlink])
|
||||
styleRow.orientation = .horizontal
|
||||
styleRow.alignment = .centerY
|
||||
styleRow.spacing = TableGroupView.padding
|
||||
table.addRow(secondaryViews: [styleRow], secondaryViewsAlignment: .centerX)
|
||||
table.addRow(leftText: AppearanceTab.labelSize, rightViews: [size.segmented, size.unlink])
|
||||
table.addRow(leftText: AppearanceTab.labelTheme, rightViews: [theme.segmented, theme.unlink])
|
||||
table.addRow(leftText: AppearanceTab.labelShortcutStyle, rightViews: [shortcutStyle.segmented, shortcutStyle.unlink])
|
||||
table.addRow(leftText: AppearanceTab.labelPreviewSelectedWindow, rightViews: [preview.toggle, preview.unlink])
|
||||
view = table
|
||||
}
|
||||
|
||||
func bind(toShortcut index: Int) {
|
||||
style.bind(toShortcut: index)
|
||||
size.bind(toShortcut: index)
|
||||
theme.bind(toShortcut: index)
|
||||
shortcutStyle.bind(toShortcut: index)
|
||||
preview.bind(toShortcut: index)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bindings: simple per-shortcut
|
||||
|
||||
/// A dropdown bound to a per-shortcut preference key (e.g. `appsToShow2`). On `bind`, swaps the
|
||||
/// identifier and refreshes the selected item from UserDefaults. Writes go through the inherited
|
||||
/// `onAction` → `LabelAndControl.controlWasChanged`, which reads the current identifier — so
|
||||
/// `bind` is the only place we need to keep the binding consistent.
|
||||
final class ShortcutBoundDropdown: PopupButtonLikeSystemSettings {
|
||||
private let baseName: String
|
||||
private let cases: [MacroPreference]
|
||||
|
||||
init(baseName: String, cases: [MacroPreference]) {
|
||||
self.baseName = baseName
|
||||
self.cases = cases
|
||||
super.init(frame: .zero, pullsDown: false)
|
||||
addItems(withTitles: cases.map { $0.localizedString })
|
||||
// Identifier set in bind(); placeholder until then.
|
||||
identifier = NSUserInterfaceItemIdentifier(Preferences.indexToName(baseName, 0))
|
||||
onAction = { control in
|
||||
LabelAndControl.controlWasChanged(control, nil)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("Class only supports programmatic initialization") }
|
||||
|
||||
func bind(toShortcut index: Int) {
|
||||
let key = Preferences.indexToName(baseName, index)
|
||||
identifier = NSUserInterfaceItemIdentifier(key)
|
||||
let selectedIndex = CachedUserDefaults.intFromMacroPref(key, cases)
|
||||
let clamped = max(0, min(selectedIndex, numberOfItems - 1))
|
||||
selectItem(at: clamped)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bindings: override controls
|
||||
|
||||
/// A segmented control wired to a per-shortcut "override" preference (e.g. `appearanceSizeOverride2`).
|
||||
/// Displays the override value when one is set, otherwise the global. Owns its own unlink button
|
||||
/// and an optional pro-badge overlay on a Pro-gated segment.
|
||||
final class ShortcutOverrideSegmented {
|
||||
let segmented: NSSegmentedControl
|
||||
let unlink: NSButton
|
||||
private let badgeOverlay: ProBadgeView.SegmentOverlay?
|
||||
|
||||
private let baseName: String
|
||||
private let cases: [MacroPreference]
|
||||
private let globalIndex: () -> Int
|
||||
private let proGatedIndices: Set<Int>
|
||||
private let refreshBadge: ((NSSegmentedControl, ProBadgeView.SegmentOverlay) -> Void)?
|
||||
private let onChange: (() -> Void)?
|
||||
private var currentShortcutIndex: Int = 0
|
||||
|
||||
init(baseName: String,
|
||||
cases: [MacroPreference],
|
||||
globalIndex: @escaping () -> Int,
|
||||
proGatedIndices: Set<Int>,
|
||||
segmentWidth: CGFloat,
|
||||
attachBadge: ((NSSegmentedControl) -> ProBadgeView.SegmentOverlay)?,
|
||||
refreshBadge: ((NSSegmentedControl, ProBadgeView.SegmentOverlay) -> Void)?,
|
||||
onChange: (() -> Void)?) {
|
||||
self.baseName = baseName
|
||||
self.cases = cases
|
||||
self.globalIndex = globalIndex
|
||||
self.proGatedIndices = proGatedIndices
|
||||
self.refreshBadge = refreshBadge
|
||||
self.onChange = onChange
|
||||
|
||||
segmented = LabelAndControl.makeSegmentedControl(
|
||||
Preferences.indexToName(baseName, 0), cases, segmentWidth: segmentWidth, extraAction: nil)
|
||||
badgeOverlay = attachBadge?(segmented)
|
||||
if let overlay = badgeOverlay {
|
||||
overlay.badge.onWindowKeyChanged = { [weak segmented] in
|
||||
guard let segmented, let refreshBadge else { return }
|
||||
refreshBadge(segmented, overlay)
|
||||
}
|
||||
}
|
||||
unlink = ShortcutEditor.makeUnlinkButton()
|
||||
|
||||
let weakSelf = WeakRef(self)
|
||||
segmented.onAction = { control in
|
||||
weakSelf.value?.handleClick(control as! NSSegmentedControl)
|
||||
}
|
||||
unlink.onAction = { _ in
|
||||
weakSelf.value?.unlinkOverride()
|
||||
}
|
||||
}
|
||||
|
||||
func bind(toShortcut index: Int) {
|
||||
currentShortcutIndex = index
|
||||
let key = Preferences.indexToName(baseName, index)
|
||||
segmented.identifier = NSUserInterfaceItemIdentifier(key)
|
||||
let displayedIndex: Int
|
||||
if Preferences.hasOverride(baseName, index) {
|
||||
displayedIndex = CachedUserDefaults.intFromMacroPref(key, cases)
|
||||
} else {
|
||||
displayedIndex = globalIndex()
|
||||
}
|
||||
let clamped = max(0, min(displayedIndex, cases.count - 1))
|
||||
segmented.selectedSegment = clamped
|
||||
unlink.isHidden = !Preferences.hasOverride(baseName, index)
|
||||
refreshOverlayIfNeeded()
|
||||
}
|
||||
|
||||
private func handleClick(_ control: NSSegmentedControl) {
|
||||
let newIndex = control.selectedSegment
|
||||
// Pro-lock intercept: clicking a Pro-gated segment while locked redirects to Upgrade.
|
||||
if proGatedIndices.contains(newIndex) && LicenseManager.shared.isProLocked {
|
||||
let stored = CachedUserDefaults.intFromMacroPref(
|
||||
Preferences.indexToName(baseName, currentShortcutIndex), cases)
|
||||
let revertTo = Preferences.hasOverride(baseName, currentShortcutIndex) ? stored : globalIndex()
|
||||
control.selectedSegment = max(0, min(revertTo, cases.count - 1))
|
||||
refreshOverlayIfNeeded()
|
||||
UpgradeTab.navigateToUpgradeTab()
|
||||
return
|
||||
}
|
||||
let key = Preferences.indexToName(baseName, currentShortcutIndex)
|
||||
let decision = OverrideClickResolver.decide(
|
||||
newIndex: newIndex,
|
||||
hasOverride: Preferences.hasOverride(baseName, currentShortcutIndex),
|
||||
storedOverrideValue: UserDefaults.standard.string(forKey: key),
|
||||
globalIndex: globalIndex(),
|
||||
valueAtIndex: { String($0) })
|
||||
if case .write(let value) = decision {
|
||||
Preferences.set(key, value)
|
||||
unlink.isHidden = false
|
||||
onChange?()
|
||||
}
|
||||
refreshOverlayIfNeeded()
|
||||
}
|
||||
|
||||
private func unlinkOverride() {
|
||||
Preferences.removeOverride(baseName, currentShortcutIndex)
|
||||
// Resnap to global.
|
||||
segmented.selectedSegment = max(0, min(globalIndex(), cases.count - 1))
|
||||
unlink.isHidden = true
|
||||
refreshOverlayIfNeeded()
|
||||
onChange?()
|
||||
}
|
||||
|
||||
private func refreshOverlayIfNeeded() {
|
||||
if let overlay = badgeOverlay, let refreshBadge {
|
||||
refreshBadge(segmented, overlay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Radio-button equivalent (an NSStackView of `ImageTextButtonView`s) bound to a per-shortcut
|
||||
/// override preference. Used for the Appearance Style picker.
|
||||
final class ShortcutOverrideRadios {
|
||||
let stack: NSStackView
|
||||
let unlink: NSButton
|
||||
|
||||
private let baseName: String
|
||||
private let cases: [MacroPreference]
|
||||
private let globalIndex: () -> Int
|
||||
private let proGatedIndices: Set<Int>
|
||||
private let onChange: (() -> Void)?
|
||||
private var currentShortcutIndex: Int = 0
|
||||
|
||||
init(baseName: String,
|
||||
cases: [MacroPreference],
|
||||
globalIndex: @escaping () -> Int,
|
||||
proGatedIndices: Set<Int>,
|
||||
onChange: (() -> Void)?) {
|
||||
self.baseName = baseName
|
||||
self.cases = cases
|
||||
self.globalIndex = globalIndex
|
||||
self.proGatedIndices = proGatedIndices
|
||||
self.onChange = onChange
|
||||
|
||||
stack = LabelAndControl.makeImageRadioButtons(
|
||||
Preferences.indexToName(baseName, 0),
|
||||
cases as! [ImageMacroPreference],
|
||||
extraAction: nil,
|
||||
buttonSpacing: 10,
|
||||
proGatedIndices: proGatedIndices)
|
||||
unlink = ShortcutEditor.makeUnlinkButton()
|
||||
|
||||
let weakSelf = WeakRef(self)
|
||||
let buttonViews = stack.arrangedSubviews.compactMap { $0 as? ImageTextButtonView }
|
||||
for (i, buttonView) in buttonViews.enumerated() {
|
||||
buttonView.onClick = { [weak stack] _ in
|
||||
guard let _ = stack else { return }
|
||||
weakSelf.value?.handleClick(buttonIndex: i)
|
||||
}
|
||||
}
|
||||
unlink.onAction = { _ in
|
||||
weakSelf.value?.unlinkOverride()
|
||||
}
|
||||
}
|
||||
|
||||
func bind(toShortcut index: Int) {
|
||||
currentShortcutIndex = index
|
||||
let key = Preferences.indexToName(baseName, index)
|
||||
// Radios don't use NSControl.identifier for their writes — they call controlWasChanged
|
||||
// with a manually computed value. So no identifier-swap needed; we re-target manually below.
|
||||
let displayedIndex: Int
|
||||
if Preferences.hasOverride(baseName, index) {
|
||||
displayedIndex = CachedUserDefaults.intFromMacroPref(key, cases)
|
||||
} else {
|
||||
displayedIndex = globalIndex()
|
||||
}
|
||||
let buttonViews = stack.arrangedSubviews.compactMap { $0 as? ImageTextButtonView }
|
||||
for (i, b) in buttonViews.enumerated() {
|
||||
b.state = (i == displayedIndex) ? .on : .off
|
||||
}
|
||||
unlink.isHidden = !Preferences.hasOverride(baseName, index)
|
||||
}
|
||||
|
||||
private func handleClick(buttonIndex i: Int) {
|
||||
let buttonViews = stack.arrangedSubviews.compactMap { $0 as? ImageTextButtonView }
|
||||
let key = Preferences.indexToName(baseName, currentShortcutIndex)
|
||||
if proGatedIndices.contains(i) && LicenseManager.shared.isProLocked {
|
||||
// Snap back to the stored value.
|
||||
let storedIndex = Int(UserDefaults.standard.string(forKey: key) ?? "") ?? -1
|
||||
for (j, b) in buttonViews.enumerated() { b.state = (j == storedIndex) ? .on : .off }
|
||||
UpgradeTab.navigateToUpgradeTab()
|
||||
return
|
||||
}
|
||||
let decision = OverrideClickResolver.decide(
|
||||
newIndex: i,
|
||||
hasOverride: Preferences.hasOverride(baseName, currentShortcutIndex),
|
||||
storedOverrideValue: UserDefaults.standard.string(forKey: key),
|
||||
globalIndex: globalIndex(),
|
||||
valueAtIndex: { String($0) })
|
||||
let onIndex: Int
|
||||
switch decision {
|
||||
case .skip:
|
||||
onIndex = Preferences.hasOverride(baseName, currentShortcutIndex)
|
||||
? (Int(UserDefaults.standard.string(forKey: key) ?? "") ?? -1)
|
||||
: globalIndex()
|
||||
case .write(let value):
|
||||
Preferences.set(key, value)
|
||||
onIndex = i
|
||||
}
|
||||
for (j, b) in buttonViews.enumerated() { b.state = (j == onIndex) ? .on : .off }
|
||||
if case .write = decision {
|
||||
unlink.isHidden = false
|
||||
onChange?()
|
||||
}
|
||||
}
|
||||
|
||||
private func unlinkOverride() {
|
||||
Preferences.removeOverride(baseName, currentShortcutIndex)
|
||||
let buttonViews = stack.arrangedSubviews.compactMap { $0 as? ImageTextButtonView }
|
||||
let gi = globalIndex()
|
||||
for (j, b) in buttonViews.enumerated() { b.state = (j == gi) ? .on : .off }
|
||||
unlink.isHidden = true
|
||||
onChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch bound to a per-shortcut override preference. Used for "Preview selected window".
|
||||
final class ShortcutOverrideSwitch {
|
||||
let toggle: Switch
|
||||
let unlink: NSButton
|
||||
|
||||
private let baseName: String
|
||||
private let globalValue: () -> Bool
|
||||
private let onChange: (() -> Void)?
|
||||
private var currentShortcutIndex: Int = 0
|
||||
|
||||
init(baseName: String, globalValue: @escaping () -> Bool, onChange: (() -> Void)?) {
|
||||
self.baseName = baseName
|
||||
self.globalValue = globalValue
|
||||
self.onChange = onChange
|
||||
toggle = LabelAndControl.makeSwitch(Preferences.indexToName(baseName, 0), extraAction: nil)
|
||||
unlink = ShortcutEditor.makeUnlinkButton()
|
||||
|
||||
let weakSelf = WeakRef(self)
|
||||
toggle.onAction = { c in
|
||||
weakSelf.value?.handleToggle(c as! Switch)
|
||||
}
|
||||
unlink.onAction = { _ in
|
||||
weakSelf.value?.unlinkOverride()
|
||||
}
|
||||
}
|
||||
|
||||
func bind(toShortcut index: Int) {
|
||||
currentShortcutIndex = index
|
||||
let key = Preferences.indexToName(baseName, index)
|
||||
toggle.identifier = NSUserInterfaceItemIdentifier(key)
|
||||
let on: Bool
|
||||
if Preferences.hasOverride(baseName, index) {
|
||||
on = CachedUserDefaults.bool(key)
|
||||
} else {
|
||||
on = globalValue()
|
||||
}
|
||||
toggle.setSilently(on ? .on : .off)
|
||||
unlink.isHidden = !Preferences.hasOverride(baseName, index)
|
||||
}
|
||||
|
||||
private func handleToggle(_ control: Switch) {
|
||||
// Toggling always changes state — always write the override.
|
||||
let key = Preferences.indexToName(baseName, currentShortcutIndex)
|
||||
Preferences.set(key, control.state == .on ? "true" : "false")
|
||||
unlink.isHidden = false
|
||||
onChange?()
|
||||
}
|
||||
|
||||
private func unlinkOverride() {
|
||||
Preferences.removeOverride(baseName, currentShortcutIndex)
|
||||
toggle.setSilently(globalValue() ? .on : .off)
|
||||
unlink.isHidden = true
|
||||
onChange?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared helpers
|
||||
|
||||
extension ShortcutEditor {
|
||||
/// Builds an unlink button used by all override bindings. Visibility is managed by each
|
||||
/// binding's `bind()` based on `Preferences.hasOverride`.
|
||||
static func makeUnlinkButton() -> NSButton {
|
||||
let image = NSImage.fromSymbol(.link, pointSize: 14)
|
||||
let button = NSButton(image: image, target: nil, action: nil)
|
||||
button.bezelStyle = .regularSquare
|
||||
button.isBordered = false
|
||||
if #available(macOS 10.14, *) {
|
||||
button.contentTintColor = .controlAccentColor
|
||||
}
|
||||
button.toolTip = NSLocalizedString("Sync with global value", comment: "")
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.widthAnchor.constraint(equalToConstant: 20).isActive = true
|
||||
button.heightAnchor.constraint(equalToConstant: 20).isActive = true
|
||||
button.isHidden = true
|
||||
return button
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiny weak-reference helper so closures can capture `self` without holding a strong cycle.
|
||||
private final class WeakRef<T: AnyObject> {
|
||||
weak var value: T?
|
||||
init(_ value: T) { self.value = value }
|
||||
}
|
||||
@@ -1,32 +1,54 @@
|
||||
import Cocoa
|
||||
|
||||
class ShortcutsWhenActiveSheet: SheetWindow {
|
||||
private static let title = NSLocalizedString("Shortcuts When Active", comment: "")
|
||||
private static let labelFocus = NSLocalizedString("Focus selected window", comment: "")
|
||||
private static let labelPrevious = NSLocalizedString("Select previous window", comment: "")
|
||||
private static let labelCancel = NSLocalizedString("Cancel", comment: "")
|
||||
private static let labelSearch = NSLocalizedString("Search", comment: "")
|
||||
private static let labelLockSearch = NSLocalizedString("Lock search", comment: "")
|
||||
private static let labelClose = NSLocalizedString("Close window", comment: "")
|
||||
private static let labelMinDemin = NSLocalizedString("Minimize/Deminimize window", comment: "")
|
||||
private static let labelFullscreen = NSLocalizedString("Fullscreen/Defullscreen window", comment: "")
|
||||
private static let labelQuit = NSLocalizedString("Quit app", comment: "")
|
||||
private static let labelHideShow = NSLocalizedString("Hide/Show app", comment: "")
|
||||
|
||||
/// Pre-build search index for the open-button. See `SettingsSearchIndex.sheetSearchableStrings`.
|
||||
/// `ProBadgeView.proLabel` contributes the "Pro" tag rendered on the search/lock-search rows.
|
||||
static let searchableStrings: [String] = [
|
||||
title,
|
||||
labelFocus, labelPrevious, labelCancel,
|
||||
labelSearch, labelLockSearch,
|
||||
labelClose, labelMinDemin, labelFullscreen, labelQuit, labelHideShow,
|
||||
ProBadgeView.proLabel,
|
||||
]
|
||||
|
||||
override func makeContentView() -> NSView {
|
||||
let focusWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Focus selected window", comment: ""),
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Focus selected window", comment: ""), "focusWindowShortcut", Preferences.focusWindowShortcut, labelPosition: .right)[0]])
|
||||
let previousWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Select previous window", comment: ""),
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Select previous window", comment: ""), "previousWindowShortcut", Preferences.previousWindowShortcut, labelPosition: .right)[0]])
|
||||
let cancelShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Cancel", comment: ""),
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Cancel", comment: ""), "cancelShortcut", Preferences.cancelShortcut, labelPosition: .right)[0]])
|
||||
let closeWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Close window", comment: ""),
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Close window", comment: ""), "closeWindowShortcut", Preferences.closeWindowShortcut, labelPosition: .right)[0]])
|
||||
let minDeminWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Minimize/Deminimize window", comment: ""),
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Minimize/Deminimize window", comment: ""), "minDeminWindowShortcut", Preferences.minDeminWindowShortcut, labelPosition: .right)[0]])
|
||||
let toggleFullscreenWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Fullscreen/Defullscreen window", comment: ""),
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Fullscreen/Defullscreen window", comment: ""), "toggleFullscreenWindowShortcut", Preferences.toggleFullscreenWindowShortcut, labelPosition: .right)[0]])
|
||||
let quitAppShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Quit app", comment: ""),
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Quit app", comment: ""), "quitAppShortcut", Preferences.quitAppShortcut, labelPosition: .right)[0]])
|
||||
let hideShowAppShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Hide/Show app", comment: ""),
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Hide/Show app", comment: ""), "hideShowAppShortcut", Preferences.hideShowAppShortcut, labelPosition: .right)[0]])
|
||||
let table = TableGroupView(title: NSLocalizedString("Shortcuts When Active", comment: ""), width: SheetWindow.width)
|
||||
let focusWindowShortcut = TableGroupView.Row(leftTitle: Self.labelFocus,
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(Self.labelFocus, "focusWindowShortcut", Preferences.focusWindowShortcut, labelPosition: .right)[0]])
|
||||
let previousWindowShortcut = TableGroupView.Row(leftTitle: Self.labelPrevious,
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(Self.labelPrevious, "previousWindowShortcut", Preferences.previousWindowShortcut, labelPosition: .right)[0]])
|
||||
let cancelShortcut = TableGroupView.Row(leftTitle: Self.labelCancel,
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(Self.labelCancel, "cancelShortcut", Preferences.cancelShortcut, labelPosition: .right)[0]])
|
||||
let closeWindowShortcut = TableGroupView.Row(leftTitle: Self.labelClose,
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(Self.labelClose, "closeWindowShortcut", Preferences.closeWindowShortcut, labelPosition: .right)[0]])
|
||||
let minDeminWindowShortcut = TableGroupView.Row(leftTitle: Self.labelMinDemin,
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(Self.labelMinDemin, "minDeminWindowShortcut", Preferences.minDeminWindowShortcut, labelPosition: .right)[0]])
|
||||
let toggleFullscreenWindowShortcut = TableGroupView.Row(leftTitle: Self.labelFullscreen,
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(Self.labelFullscreen, "toggleFullscreenWindowShortcut", Preferences.toggleFullscreenWindowShortcut, labelPosition: .right)[0]])
|
||||
let quitAppShortcut = TableGroupView.Row(leftTitle: Self.labelQuit,
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(Self.labelQuit, "quitAppShortcut", Preferences.quitAppShortcut, labelPosition: .right)[0]])
|
||||
let hideShowAppShortcut = TableGroupView.Row(leftTitle: Self.labelHideShow,
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(Self.labelHideShow, "hideShowAppShortcut", Preferences.hideShowAppShortcut, labelPosition: .right)[0]])
|
||||
let table = TableGroupView(title: Self.title, width: SheetWindow.width)
|
||||
_ = table.addRow(focusWindowShortcut)
|
||||
_ = table.addRow(previousWindowShortcut)
|
||||
_ = table.addRow(cancelShortcut)
|
||||
let searchRow = table.addRow(leftText: NSLocalizedString("Search", comment: ""),
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Search", comment: ""), "searchShortcut", Preferences.searchShortcut, labelPosition: .right)[0]])
|
||||
let searchRow = table.addRow(leftText: Self.labelSearch,
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(Self.labelSearch, "searchShortcut", Preferences.searchShortcut, labelPosition: .right)[0]])
|
||||
addProBadgeToLeftLabel(searchRow)
|
||||
let lockSearchRow = table.addRow(leftText: NSLocalizedString("Lock search", comment: ""),
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Lock search", comment: ""), "lockSearchShortcut", Preferences.lockSearchShortcut, labelPosition: .right)[0]])
|
||||
let lockSearchRow = table.addRow(leftText: Self.labelLockSearch,
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(Self.labelLockSearch, "lockSearchShortcut", Preferences.lockSearchShortcut, labelPosition: .right)[0]])
|
||||
addProBadgeToLeftLabel(lockSearchRow)
|
||||
_ = table.addRow(closeWindowShortcut)
|
||||
_ = table.addRow(minDeminWindowShortcut)
|
||||
|
||||
@@ -31,7 +31,7 @@ enum ProGradient {
|
||||
}
|
||||
|
||||
static func makeProImage(font: NSFont) -> NSImage {
|
||||
return makeGradientTextImage(NSLocalizedString("Pro", comment: ""), font: font)
|
||||
return makeGradientTextImage(ProBadgeView.proLabel, font: font)
|
||||
}
|
||||
|
||||
static func makeGradientTextImage(_ string: String, font: NSFont) -> NSImage {
|
||||
@@ -62,7 +62,7 @@ enum ProGradient {
|
||||
}
|
||||
|
||||
static func makeProTextAttachment(font: NSFont, baselineOffset: CGFloat = 0) -> NSAttributedString {
|
||||
return makeGradientTextAttachment(NSLocalizedString("Pro", comment: ""), font: font, baselineOffset: baselineOffset)
|
||||
return makeGradientTextAttachment(ProBadgeView.proLabel, font: font, baselineOffset: baselineOffset)
|
||||
}
|
||||
|
||||
/// Render any string as a gradient `NSImage` wrapped in an `NSTextAttachment`, ready to be
|
||||
@@ -216,6 +216,11 @@ class ProBadgeView: NSView {
|
||||
/// Inset between the badge and the segmented control's trailing edge.
|
||||
static let segmentTrailingPadding: CGFloat = 4
|
||||
|
||||
/// The literal text rendered on the badge. Exposed as a constant so the search index can
|
||||
/// reference the same string (e.g. `ShortcutsWhenActiveSheet.searchableStrings`) without
|
||||
/// duplicating the `NSLocalizedString` call.
|
||||
static let proLabel = NSLocalizedString("Pro", comment: "")
|
||||
|
||||
/// Bundle returned from `attach(to:segmentIndex:)`: the badge plus the icon and label
|
||||
/// overlays we render on top of the Pro segment. Callers store this to drive
|
||||
/// `refreshSelection` after click / window-key / Pro-lock transitions.
|
||||
@@ -321,7 +326,7 @@ class ProBadgeView: NSView {
|
||||
overlay.label.needsDisplay = true
|
||||
}
|
||||
|
||||
private let label = NSTextField(labelWithString: NSLocalizedString("Pro", comment: ""))
|
||||
private let label = NSTextField(labelWithString: ProBadgeView.proLabel)
|
||||
private let fillGradient = ProGradient.makeLayer(alpha: 0.1)
|
||||
private let borderGradient = ProGradient.makeLayer(alpha: 0.7)
|
||||
private let textGradient = ProGradient.makeLayer(alpha: 1)
|
||||
@@ -333,6 +338,10 @@ class ProBadgeView: NSView {
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
// Register the "Pro" tag with the search index if a section build is in progress —
|
||||
// mirrors what the post-construction walk in `SettingsWindow.collectSearchContent` does
|
||||
// when it spots a `ProBadgeView`, just without needing the walk to find it after.
|
||||
SettingsSearchIndex.registerString(ProBadgeView.proLabel)
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
wantsLayer = true
|
||||
layer?.cornerRadius = 4
|
||||
@@ -353,7 +362,7 @@ class ProBadgeView: NSView {
|
||||
borderMask.strokeColor = NSColor.white.cgColor
|
||||
borderMask.lineWidth = 1
|
||||
borderGradient.mask = borderMask
|
||||
textMask.string = NSLocalizedString("Pro", comment: "")
|
||||
textMask.string = ProBadgeView.proLabel
|
||||
textMask.font = NSFont.systemFont(ofSize: 9, weight: .semibold)
|
||||
textMask.fontSize = 9
|
||||
textMask.foregroundColor = NSColor.white.cgColor
|
||||
@@ -401,6 +410,16 @@ class ProBadgeView: NSView {
|
||||
self?.updateColors()
|
||||
})
|
||||
}
|
||||
// Resync colors + force a layout pass for the current key state. This matters when the
|
||||
// badge is built lazily (e.g. the per-shortcut Appearance pane's `Size` Pro segment,
|
||||
// created when the user clicks the Appearance tab while Settings is already key) —
|
||||
// `init` ran with `window=nil`, so `updateColors` picked the not-key branch, and the
|
||||
// `didBecomeKey` observer never fires because there's no actual state change. Without
|
||||
// this nudge the badge's `layout()` may not run before the first display, leaving the
|
||||
// `borderMask.path` and `textMask.frame` unset and only the 10%-alpha fill layer visible
|
||||
// — i.e. the "faded" badge regression.
|
||||
updateColors()
|
||||
needsLayout = true
|
||||
}
|
||||
|
||||
func setSelected(_ selected: Bool) {
|
||||
|
||||
+83
-5
@@ -19,16 +19,94 @@ extension NSImage {
|
||||
}
|
||||
}
|
||||
|
||||
// `LightLabel` and `ProBadgeView` live in the test target's source membership (they're pulled
|
||||
// in by `ProBadgeViewSegmentTests`) but reference `Appearance` and `SettingsSearchIndex` —
|
||||
// types not in the test target. Minimal stubs so the test target compiles. Tests never exercise
|
||||
// these code paths; the stubs just satisfy the type checker.
|
||||
// Test-target reimplementation of `SettingsSearchIndex`'s inline-registration API. The production
|
||||
// type lives in the app target but its `sheetSearchableStrings(forButtonAction:)` references the
|
||||
// sheet classes + AppearanceTab/ControlsTab selectors, which would drag the whole settings window
|
||||
// into the test target. The indexing surface that the row/label widgets actually call
|
||||
// (`registerString` / `registerStrings` / `registerTarget`) and that the editor tests drive
|
||||
// (`indexed { ... }`) is small and self-contained, so we mirror just that here. Keep in sync with
|
||||
// the production version's behavior for these five members.
|
||||
enum SettingsSearchIndex {
|
||||
static func registerString(_ s: String?) {}
|
||||
static var current: Builder?
|
||||
|
||||
final class Builder {
|
||||
var strings: [String] = []
|
||||
var targets: [SettingsSearchHighlightTarget] = []
|
||||
}
|
||||
|
||||
static func indexed<T>(_ build: () -> T) -> (result: T, builder: Builder) {
|
||||
let previous = current
|
||||
let builder = Builder()
|
||||
current = builder
|
||||
defer { current = previous }
|
||||
let result = build()
|
||||
return (result, builder)
|
||||
}
|
||||
|
||||
static func registerString(_ s: String?) {
|
||||
guard let current, let s else { return }
|
||||
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { current.strings.append(trimmed) }
|
||||
}
|
||||
|
||||
static func registerStrings(_ strings: [String]) {
|
||||
guard let current else { return }
|
||||
for s in strings {
|
||||
let t = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !t.isEmpty { current.strings.append(t) }
|
||||
}
|
||||
}
|
||||
|
||||
static func registerTarget(_ target: SettingsSearchHighlightTarget?) {
|
||||
guard let target, let current else { return }
|
||||
current.targets.append(target)
|
||||
}
|
||||
}
|
||||
|
||||
class Appearance {
|
||||
static var searchMatchHighlightColor: NSColor = .yellow
|
||||
static var searchMatchForegroundColor: NSColor = .black
|
||||
}
|
||||
|
||||
// Minimal stubs so `TableGroupView.swift` / `SidebarList.swift` compile in the test target without
|
||||
// dragging in `AppearanceTab` (which `IllustratedImageThemeView` lives in) or the full
|
||||
// `SettingsWindow`. Tests never instantiate these — they only need the symbols to resolve.
|
||||
enum SettingsWindow {
|
||||
static let contentWidth = CGFloat(700)
|
||||
}
|
||||
|
||||
class IllustratedImageThemeView: NSView {}
|
||||
|
||||
// `EventClosure` is declared in `LabelAndControl.swift` (app target only). `TableGroupView`
|
||||
// uses it in its row-builder signatures, so mirror the typealias here.
|
||||
typealias EventClosure = (NSEvent, NSView) -> Void
|
||||
|
||||
// Helpers from `HelperExtensions.swift` (app target only) that `TableGroupView` /
|
||||
// `SettingsSearchHighlight` reference. The test never exercises their visual behavior — it only
|
||||
// needs the symbols to resolve — so these are functional-but-minimal stand-ins.
|
||||
func noAnimation<T: CALayer>(_ make: () -> T) -> T {
|
||||
return make()
|
||||
}
|
||||
|
||||
// 10.13-safe stand-ins (the test target's deployment floor matches the app's 10.13). The real
|
||||
// extensions in HelperExtensions.swift `#available`-gate the 10.14+ system colors; the tests
|
||||
// never inspect these values, so plain 10.13-era colors suffice.
|
||||
extension NSColor {
|
||||
class var systemAccentColor: NSColor { .alternateSelectedControlColor }
|
||||
class var tableBorderColor: NSColor { .gridColor }
|
||||
class var tableBackgroundColor: NSColor { .windowBackgroundColor }
|
||||
class var tableSeparatorColor: NSColor { .gridColor }
|
||||
class var tableHoverColor: NSColor { .selectedControlColor }
|
||||
}
|
||||
|
||||
extension NSView {
|
||||
func addOrUpdateConstraint(_ anchor: NSLayoutDimension, _ constant: CGFloat) {
|
||||
if let constraint = (constraints.first { $0.firstAnchor == anchor && $0.secondAnchor == nil }) {
|
||||
constraint.constant = constant
|
||||
} else {
|
||||
anchor.constraint(equalToConstant: constant).isActive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SearchKeyResult {
|
||||
|
||||
@@ -266,4 +266,56 @@ final class ProBadgeViewSegmentTests: XCTestCase {
|
||||
badge.setSelected(false)
|
||||
XCTAssertFalse(badge.isSelectedState)
|
||||
}
|
||||
|
||||
// MARK: - Layout / viewDidMoveToWindow (regression coverage)
|
||||
|
||||
/// `layout()` is where the badge sizes its three gradient sublayers and — critically — sets
|
||||
/// `borderMask.path` and `textMask.frame`. If `layout()` is ever broken (e.g. an earlier
|
||||
/// attempt no-op'd `_layoutSubtreeWithOldSize:` as a cascade-stop, which also suppressed the
|
||||
/// `layout()` callback), the masks stay unconfigured and the badge renders with only its
|
||||
/// 10%-alpha fill — the "faded" look. This pins that `layout()` produces sized sublayers.
|
||||
func testLayoutConfiguresGradientSublayerFrames() {
|
||||
let badge = ProBadgeView()
|
||||
let host = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 200, height: 100),
|
||||
styleMask: [.titled], backing: .buffered, defer: false)
|
||||
host.contentView?.addSubview(badge)
|
||||
badge.frame = NSRect(x: 0, y: 0, width: 30, height: 14)
|
||||
badge.layoutSubtreeIfNeeded()
|
||||
guard let sublayers = badge.layer?.sublayers, sublayers.count >= 3 else {
|
||||
return XCTFail("badge must own three gradient sublayers (fill / border / text) — got \(badge.layer?.sublayers?.count ?? 0)")
|
||||
}
|
||||
for (i, sublayer) in sublayers.prefix(3).enumerated() {
|
||||
XCTAssertGreaterThan(sublayer.frame.width, 0,
|
||||
"regression: badge gradient sublayer \(i) has zero width after `layout()` — the gradient frames weren't sized, so the badge can't render its border/text gradients")
|
||||
XCTAssertGreaterThan(sublayer.frame.height, 0,
|
||||
"regression: badge gradient sublayer \(i) has zero height after `layout()`")
|
||||
}
|
||||
host.orderOut(nil)
|
||||
}
|
||||
|
||||
/// Regression pin for the "faded badge in a lazily-built pane" bug.
|
||||
///
|
||||
/// When the badge is created off-window (the per-shortcut Appearance pane is built lazily,
|
||||
/// while Settings is already key) its `init` runs with `window == nil`, so `updateColors`
|
||||
/// resolves the *not-key* branch. The `didBecomeKey` notification observer registered in
|
||||
/// `viewDidMoveToWindow` never fires (the window was already key — no state *change*), so
|
||||
/// without an explicit resync the badge stays stuck in the state `init` left it in.
|
||||
///
|
||||
/// The fix is the `updateColors()` call in `viewDidMoveToWindow`. `updateColors()` always
|
||||
/// invokes `onWindowKeyChanged`, which gives us a deterministic, window-key-independent hook
|
||||
/// to assert on: entering a window must re-run `updateColors` exactly because of that call.
|
||||
func testViewDidMoveToWindowResyncsColors() {
|
||||
let badge = ProBadgeView()
|
||||
var resyncCount = 0
|
||||
// `onWindowKeyChanged` is invoked from inside `updateColors()`. We set it *after* init
|
||||
// (init's own `updateColors` call already ran, before this closure existed) so the only
|
||||
// way the counter increments is a fresh `updateColors` triggered by entering the window.
|
||||
badge.onWindowKeyChanged = { resyncCount += 1 }
|
||||
let host = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 200, height: 100),
|
||||
styleMask: [.titled], backing: .buffered, defer: false)
|
||||
host.contentView?.addSubview(badge)
|
||||
XCTAssertGreaterThan(resyncCount, 0,
|
||||
"regression: `ProBadgeView.viewDidMoveToWindow` must call `updateColors()` so a badge added to an already-key window resyncs its visual state — otherwise a selected badge built in a lazy pane stays in the gradient state `init` chose when `window` was nil")
|
||||
host.orderOut(nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import XCTest
|
||||
import Cocoa
|
||||
|
||||
/// Coverage for the settings-search *highlight* machinery — the layer that turns a query match
|
||||
/// into yellow-highlighted text on a specific control.
|
||||
///
|
||||
/// Why this suite exists: we've repeatedly regressed search highlighting in the editor. The most
|
||||
/// recent case was the sidebar shortcut rows ("Shortcut 1", "Shortcut 2", …) no longer lighting
|
||||
/// up when the user typed "sho". Two distinct things have to work for that to happen:
|
||||
///
|
||||
/// 1. `SettingsSearchHighlight.highlightTarget(_:)` must turn a label into a *working*
|
||||
/// `SettingsSearchHighlightTarget` — one that reports a match and applies the highlight to
|
||||
/// the matched range. (Tier A: the factory + target in isolation.)
|
||||
/// 2. The widget factories (`TableGroupView.makeText`, and `SidebarListRow.registerSearchContent`
|
||||
/// for the sidebar) must *register* that target into the active `SettingsSearchIndex.Builder`,
|
||||
/// so a section's search actually drives it. (Tier B: factory ⇄ index wiring.)
|
||||
///
|
||||
/// The sidebar rows feed `NSTextField` labels into `highlightTarget`; the rounded-section rows
|
||||
/// feed `LightLabel`. Both overloads are covered.
|
||||
///
|
||||
/// (`SidebarListRow` itself can't be compiled into this test target — it uses the 10.13-only
|
||||
/// `NSAppearance.current` API which is a hard error under the test target's 13.1 deployment
|
||||
/// floor + warnings-as-errors. So we pin the *mechanism* it depends on rather than the row type.)
|
||||
final class SettingsSearchHighlightTests: XCTestCase {
|
||||
|
||||
// MARK: - Tier A: NSTextField highlight target (what sidebar rows use)
|
||||
|
||||
func testTextFieldTargetReportsMatch() {
|
||||
let tf = NSTextField(labelWithString: "Shortcut 1")
|
||||
guard let target = SettingsSearchHighlight.highlightTarget(tf) else {
|
||||
return XCTFail("a non-empty label must produce a highlight target — sidebar rows rely on this to be searchable")
|
||||
}
|
||||
XCTAssertTrue(target.hasMatch("Sho"),
|
||||
"regression: a prefix query must match the label text — this is exactly the 'typing sho doesn't highlight Shortcut N' bug")
|
||||
XCTAssertFalse(target.hasMatch("zzz"),
|
||||
"a non-matching query must not report a match")
|
||||
}
|
||||
|
||||
func testTextFieldTargetIsNilForEmptyLabel() {
|
||||
let tf = NSTextField(labelWithString: "")
|
||||
XCTAssertNil(SettingsSearchHighlight.highlightTarget(tf),
|
||||
"an empty label has nothing to highlight, so the factory returns nil (the index skips it)")
|
||||
}
|
||||
|
||||
func testTextFieldHighlightAppliesSearchColorToMatchedRange() {
|
||||
let tf = NSTextField(labelWithString: "Shortcut 1")
|
||||
let target = SettingsSearchHighlight.highlightTarget(tf)!
|
||||
target.updateHighlight("Sho")
|
||||
// The matched prefix [0..<3] must carry the search-match foreground color; the rest of the
|
||||
// string must NOT (it falls through to the textField's own textColor — that's what keeps
|
||||
// selection-driven color changes working, per the highlightTarget contract).
|
||||
let attr = tf.attributedStringValue
|
||||
XCTAssertEqual(attr.string, "Shortcut 1")
|
||||
var matchedRangeHasColor = false
|
||||
var tailHasColor = false
|
||||
attr.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: attr.length)) { value, range, _ in
|
||||
guard value as? NSColor == Appearance.searchMatchForegroundColor else { return }
|
||||
if range.location == 0, range.length == 3 { matchedRangeHasColor = true }
|
||||
if range.location >= 3 { tailHasColor = true }
|
||||
}
|
||||
XCTAssertTrue(matchedRangeHasColor,
|
||||
"regression: the matched prefix must be tinted with the search-match color")
|
||||
XCTAssertFalse(tailHasColor,
|
||||
"regression: only the matched range may be tinted — tinting the whole string is the bug that froze the boldness/color on the wrong selection")
|
||||
}
|
||||
|
||||
func testTextFieldHighlightClearsBackToPlain() {
|
||||
let tf = NSTextField(labelWithString: "Shortcut 1")
|
||||
let target = SettingsSearchHighlight.highlightTarget(tf)!
|
||||
target.updateHighlight("Sho") // apply
|
||||
target.updateHighlight("zzz") // no match → clears
|
||||
let attr = tf.attributedStringValue
|
||||
var anyColor = false
|
||||
attr.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: attr.length)) { value, _, _ in
|
||||
if value as? NSColor == Appearance.searchMatchForegroundColor { anyColor = true }
|
||||
}
|
||||
XCTAssertFalse(anyColor,
|
||||
"after a non-matching query the highlight must be fully cleared so no stale yellow text remains")
|
||||
}
|
||||
|
||||
// MARK: - Tier A: LightLabel highlight target (what rounded-section rows use)
|
||||
|
||||
func testLightLabelTargetAppliesAndClearsRanges() {
|
||||
let label = LightLabel("Group apps")
|
||||
guard let target = SettingsSearchHighlight.highlightTarget(label) else {
|
||||
return XCTFail("a non-empty LightLabel must produce a highlight target")
|
||||
}
|
||||
XCTAssertTrue(target.hasMatch("Group"))
|
||||
target.updateHighlight("Group")
|
||||
XCTAssertEqual(label.highlightRanges, [NSRange(location: 0, length: 5)],
|
||||
"regression: LightLabel must receive the matched range so it can draw the yellow background inline on its next draw")
|
||||
target.updateHighlight("zzz")
|
||||
XCTAssertTrue(label.highlightRanges.isEmpty,
|
||||
"a non-matching query must clear the LightLabel's highlight ranges")
|
||||
}
|
||||
|
||||
// MARK: - Tier B: factory ⇄ index registration wiring
|
||||
|
||||
/// `TableGroupView.makeText` is the row-title factory for every rounded settings section. It
|
||||
/// must, as a side effect of construction, push its string + a highlight target into whatever
|
||||
/// `SettingsSearchIndex.Builder` is active. This is the same inline-registration contract that
|
||||
/// `SidebarListRow.registerSearchContent` relies on — and whose absence was the sidebar
|
||||
/// search regression. If `makeText` ever stops registering, this fails.
|
||||
func testMakeTextRegistersStringAndTargetInActiveBuilder() {
|
||||
let (label, builder) = SettingsSearchIndex.indexed {
|
||||
TableGroupView.makeText("Order windows by")
|
||||
}
|
||||
XCTAssertEqual(label.stringValue, "Order windows by")
|
||||
XCTAssertTrue(builder.strings.contains("Order windows by"),
|
||||
"regression: makeText must register its label string into the active search index")
|
||||
XCTAssertEqual(builder.targets.count, 1,
|
||||
"regression: makeText must register exactly one highlight target — without it the label is searchable-by-string but never lights up")
|
||||
XCTAssertTrue(builder.targets.first?.hasMatch("Order") ?? false,
|
||||
"the registered target must actually match the label's text")
|
||||
}
|
||||
|
||||
/// The index is push-based and scoped: factories called with no active builder must silently
|
||||
/// no-op (so they're safe to call from any context, e.g. a `refreshShortcutRows` that runs
|
||||
/// outside the section-build scope). Pins that contract.
|
||||
func testMakeTextOutsideIndexedScopeDoesNotCrashOrLeak() {
|
||||
SettingsSearchIndex.current = nil
|
||||
let label = TableGroupView.makeText("Theme")
|
||||
XCTAssertEqual(label.stringValue, "Theme",
|
||||
"makeText must still build the label when no builder is active; registration just no-ops")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user