perf: improve performance of the settings window

This commit is contained in:
lwouis committed 2026-05-28 13:56:50 +02:00
1 parent 5bd746d342
commit 3059dc2356
22 files changed
+2257 -941

No files matched your search

+28 -2
View File
@@ -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;
+1
View File
@@ -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
+51 -22
View File
@@ -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
}
}
+236
View File
@@ -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)
+23 -4
View File
@@ -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
View File
@@ -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 {
+52
View File
@@ -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")
}
}