From 647935c26f1cfc3c56dd432bc73036da7fb55844 Mon Sep 17 00:00:00 2001 From: uniplanck <198168437+uniplanck@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:23:34 +0900 Subject: [PATCH] feat: improve Board-Man filters and settings UI --- Clipy/Sources/AppDelegate.swift | 12 +- Clipy/Sources/Constants.swift | 2 + Clipy/Sources/Managers/MenuManager.swift | 925 ++++++++++++++++-- .../Services/AccessibilityService.swift | 2 +- Clipy/Sources/Services/HotKeyService.swift | 88 +- ClipyTests/EntitlementGateTests.swift | 206 +++- ClipyTests/HotKeyServiceTests.swift | 79 +- 7 files changed, 1121 insertions(+), 193 deletions(-) diff --git a/Clipy/Sources/AppDelegate.swift b/Clipy/Sources/AppDelegate.swift index cd61f5e..df184d1 100644 --- a/Clipy/Sources/AppDelegate.swift +++ b/Clipy/Sources/AppDelegate.swift @@ -29,9 +29,17 @@ class AppDelegate: NSObject, NSMenuItemValidation { private let disposeBag = DisposeBag() static func shouldStartRuntimeServices( - environment: [String: String] = ProcessInfo.processInfo.environment + environment: [String: String] = ProcessInfo.processInfo.environment, + arguments: [String] = ProcessInfo.processInfo.arguments, + bundlePaths: [String] = Bundle.allBundles.map(\.bundlePath), + hasXCTestCase: Bool = NSClassFromString("XCTestCase") != nil ) -> Bool { - return environment["XCTestConfigurationFilePath"] == nil + return !BoardManRuntimeEnvironment.isRunningTests( + environment: environment, + arguments: arguments, + bundlePaths: bundlePaths, + hasXCTestCase: hasXCTestCase + ) } // MARK: - Init diff --git a/Clipy/Sources/Constants.swift b/Clipy/Sources/Constants.swift index 9c2fade..68bedc1 100644 --- a/Clipy/Sources/Constants.swift +++ b/Clipy/Sources/Constants.swift @@ -74,6 +74,8 @@ struct Constants { static let boardManShowRowNumbers = "BoardManShowRowNumbers" static let boardManHistoryUsageFilter = "BoardManHistoryUsageFilter" static let boardManHistoryConditionsJSON = "BoardManHistoryConditionsJSON" + static let boardManSavedFiltersJSON = "BoardManSavedFiltersJSON" + static let boardManSelectedSavedFilterID = "BoardManSelectedSavedFilterID" static let boardManTimestampFormat = "BoardManTimestampFormat" static let boardManRelativeTimestampTemplate = "BoardManRelativeTimestampTemplate" static let boardManRelativeNumberStyle = "BoardManRelativeNumberStyle" diff --git a/Clipy/Sources/Managers/MenuManager.swift b/Clipy/Sources/Managers/MenuManager.swift index db6b968..2b367d6 100644 --- a/Clipy/Sources/Managers/MenuManager.swift +++ b/Clipy/Sources/Managers/MenuManager.swift @@ -1857,7 +1857,12 @@ func boardManText(_ english: String) -> String { "Clear history after confirmation": "確認後に履歴を消去します", "Add": "追加", "Edit": "編集", "Delete": "削除", "Clear": "消去", "Save": "保存", "Upgrade": "アップグレード", "OK": "OK", "Category": "グループ", "Add Group": "グループ追加", "Rename Group": "グループ名変更", "Delete Group": "グループ削除", - "Title": "タイトル", "Content": "内容", "Group Enabled": "グループを有効化", "Snippet Enabled": "スニペットを有効化", + "Title": "タイトル", "Content": "内容", "Group Enabled": "グループを有効化", "Snippet Enabled": "定型文を有効化", + "Saved Filters": "保存フィルタ", "Save Current Filter…": "現在の条件を保存…", + "Update Selected Filter": "選択中のフィルタを更新", "Rename Selected Filter…": "選択中のフィルタ名を変更…", + "Delete Selected Filter": "選択中のフィルタを削除", "Clear Saved Filter": "保存フィルタの選択を解除", + "Save Filter": "フィルタを保存", "Rename Filter": "フィルタ名を変更", "Filter name": "フィルタ名", + "All Groups": "すべてのグループ", "%d Groups": "%dグループ", "Click the preview to edit": "右側のプレビューをクリックして編集", "Hover a snippet, then click the preview to edit • ⌘C Copy • ⌘P Pin": "スニペットにカーソルを合わせ、右側をクリックして編集 • ⌘C コピー • ⌘P Pin", "Skip pinned items with arrow keys": "上下キーではPin項目を飛ばす", @@ -2181,7 +2186,7 @@ fileprivate enum BoardManUIStyle: String, CaseIterable { } } -fileprivate enum BoardManHistoryUsageFilter: String, CaseIterable { +enum BoardManHistoryUsageFilter: String, CaseIterable { case all = "All" case unused = "Unused" case used = "Used" @@ -2598,6 +2603,90 @@ fileprivate final class BoardManHistoryConditionStore { } } +struct BoardManSavedFilterPreset: Codable, Equatable { + let id: String + var name: String + var usageFilterRawValue: String + var condition: BoardManHistoryCondition + var snippetGroupIdentifiers: [String] + + var usageFilter: BoardManHistoryUsageFilter { + return BoardManHistoryUsageFilter.allowed(usageFilterRawValue) + } + + var hasCriteria: Bool { + return usageFilter != .all || condition.hasCriteria || !snippetGroupIdentifiers.isEmpty + } + + func validSnippetGroupIdentifiers(availableIdentifiers: Set) -> [String] { + return snippetGroupIdentifiers.filter { availableIdentifiers.contains($0) } + } +} + +final class BoardManSavedFilterStore { + static let shared = BoardManSavedFilterStore() + private let defaults: UserDefaults + + init(defaults: UserDefaults = AppEnvironment.current.defaults) { + self.defaults = defaults + } + + var presets: [BoardManSavedFilterPreset] { + guard let json = defaults.string(forKey: Constants.UserDefaults.boardManSavedFiltersJSON), + let data = json.data(using: .utf8), + let decoded = try? JSONDecoder().decode([BoardManSavedFilterPreset].self, from: data) else { + return [] + } + return decoded + } + + var selectedPresetID: String? { + let value = defaults.string(forKey: Constants.UserDefaults.boardManSelectedSavedFilterID) + return value?.isEmpty == false ? value : nil + } + + var selectedPreset: BoardManSavedFilterPreset? { + guard let selectedPresetID else { return nil } + return presets.first { $0.id == selectedPresetID } + } + + func select(_ id: String?) { + if let id { + defaults.set(id, forKey: Constants.UserDefaults.boardManSelectedSavedFilterID) + } else { + defaults.removeObject(forKey: Constants.UserDefaults.boardManSelectedSavedFilterID) + } + defaults.synchronize() + } + + @discardableResult + func save(_ preset: BoardManSavedFilterPreset) -> BoardManSavedFilterPreset { + var next = presets + if let index = next.firstIndex(where: { $0.id == preset.id }) { + next[index] = preset + } else { + next.append(preset) + } + persist(next) + select(preset.id) + return preset + } + + func delete(_ id: String) { + persist(presets.filter { $0.id != id }) + if selectedPresetID == id { + select(nil) + } + } + + private func persist(_ presets: [BoardManSavedFilterPreset]) { + guard let data = try? JSONEncoder().encode(presets), + let json = String(data: data, encoding: .utf8) else { return } + defaults.set(json, forKey: Constants.UserDefaults.boardManSavedFiltersJSON) + defaults.synchronize() + } +} + fileprivate final class BoardManProLockedControlView: NSView { private let feature: EntitlementFeature @@ -3089,7 +3178,7 @@ final class BoardManCenteredTextFieldCell: NSTextFieldCell { final class BoardManCenteredSearchFieldCell: NSSearchFieldCell { var opticalYOffset: CGFloat = 0 - var searchButtonOpticalYOffset: CGFloat = -1 + var searchButtonOpticalYOffset: CGFloat = -2 private func verticallyCentered(_ rect: NSRect, height: CGFloat) -> NSRect { let targetHeight = min(rect.height, height) @@ -3491,11 +3580,15 @@ class BoardManPanel: NSPanel { private var glassBackgroundView: NSVisualEffectView? private var searchField: NSSearchField? private var segmentedControl: BoardManHeaderSegmentedControl? + private var settingsButton: NSButton? private var historyUsageFilterControl: NSSegmentedControl? private var historySortButton: NSButton? + private var historySavedFilterPopup: NSPopUpButton? private var historyConditionButton: NSButton? private var settingsBackgroundView: NSView? private var settingsSidebarView: NSView? + private var settingsScrollView: NSScrollView? + private var settingsDocumentView: NSView? private var settingsCategoryButtons: [NSButton] = [] private var settingsPageTitleLabel: NSTextField? private var settingsPageDescriptionLabel: NSTextField? @@ -3522,6 +3615,8 @@ class BoardManPanel: NSPanel { private var timestampShortcutDelayLabel: NSTextField? private var timestampShortcutDelayField: NSTextField? private var timestampShortcutDelayStepper: NSStepper? + private var timestampShortcutDelayDecreaseButton: NSButton? + private var timestampShortcutDelayIncreaseButton: NSButton? private var timestampShortcutSecondsLabel: NSTextField? private var usageCountButton: NSButton? private var usageStyleLabel: NSTextField? @@ -3555,6 +3650,8 @@ class BoardManPanel: NSPanel { private var maxHistorySizeLabel: NSTextField? private var maxHistorySizeStepper: NSStepper? private var maxHistorySizeValueLabel: NSTextField? + private var maxHistoryDecreaseButton: NSButton? + private var maxHistoryIncreaseButton: NSButton? private var statusItemLabel: NSTextField? private var statusItemPopup: NSPopUpButton? private var shortcutSectionLabel: NSTextField? @@ -3613,6 +3710,8 @@ class BoardManPanel: NSPanel { private var timedPinPresetRemoveButton: NSButton? private var timedPinDurationStepper: NSStepper? private var timedPinDurationValueLabel: NSTextField? + private var timedPinDurationDecreaseButton: NSButton? + private var timedPinDurationIncreaseButton: NSButton? private var timedPinDurationUnitPopup: NSPopUpButton? private var textPreviewScaleLabel: NSTextField? private var textPreviewScaleSlider: NSSlider? @@ -3631,6 +3730,8 @@ class BoardManPanel: NSPanel { private var heightControlLabel: NSTextField? private var heightStepper: NSStepper? private var heightLabel: NSTextField? + private var heightDecreaseButton: NSButton? + private var heightIncreaseButton: NSButton? private var footerNote: NSTextField? private var snippetCategoryLabel: NSTextField? private var snippetCategoryPopup: NSPopUpButton? @@ -3677,6 +3778,8 @@ class BoardManPanel: NSPanel { private var activeTab: BoardManPanelTab = .history private var activeSettingsCategory: BoardManInlineSettingsCategory = .general private var activeSnippetCategoryIdentifier: String = BoardManPanel.allCategoriesIdentifier + private var activeSnippetGroupIdentifiers: Set = [] + private var shouldScrollSettingsToTop = true fileprivate var onPasteRequested: ((BoardManHistoryItem, CFAbsoluteTime?) -> Void)? fileprivate var onTimestampActionRequested: ((KeyCombo, TimeInterval, CFAbsoluteTime?) -> Void)? var onRefreshRequested: (() -> Void)? @@ -3718,7 +3821,9 @@ class BoardManPanel: NSPanel { func selectSettingsTab() { activeTab = .settings - segmentedControl?.selectedSegment = activeTab.rawValue + segmentedControl?.selectedSegment = -1 + shouldScrollSettingsToTop = true + updateSettingsButtonAppearance() refreshGlobalShortcutRows() refreshSnippetSettingsSummary() refreshExcludedAppsSummary() @@ -3732,9 +3837,12 @@ class BoardManPanel: NSPanel { func openSnippetsManagerMode(categoryIdentifier: String? = nil) { activeTab = .snippets - segmentedControl?.selectedSegment = activeTab.rawValue + segmentedControl?.selectedSegment = BoardManPanelTab.snippets.rawValue + updateSettingsButtonAppearance() if let categoryIdentifier { - activeSnippetCategoryIdentifier = categoryIdentifier + setActiveSnippetGroupIdentifiers( + categoryIdentifier == BoardManPanel.allCategoriesIdentifier ? [] : [categoryIdentifier] + ) } selectedIndex = -1 hoveredRow = -1 @@ -4391,16 +4499,14 @@ class BoardManPanel: NSPanel { contentView.addSubview(search) searchField = search - // Primary navigation stays visible and uses one native border plus a lightweight hover overlay. + // History and Templates remain the two primary tabs. Settings lives in the trailing gear button. let tabs = BoardManHeaderSegmentedControl(frame: .zero) - tabs.segmentCount = 3 + tabs.segmentCount = 2 tabs.setLabel("History", forSegment: 0) tabs.setLabel("Snippets", forSegment: 1) - tabs.setLabel("Settings", forSegment: 2) if #available(macOS 11.0, *) { tabs.setImage(NSImage(systemSymbolName: "clock.arrow.circlepath", accessibilityDescription: "History"), forSegment: 0) tabs.setImage(NSImage(systemSymbolName: "text.badge.plus", accessibilityDescription: "Snippets"), forSegment: 1) - tabs.setImage(NSImage(systemSymbolName: "slider.horizontal.3", accessibilityDescription: "Settings"), forSegment: 2) } tabs.selectedSegment = 0 tabs.target = self @@ -4413,6 +4519,21 @@ class BoardManPanel: NSPanel { contentView.addSubview(tabs) segmentedControl = tabs + let gear = NSButton(title: "", target: self, action: #selector(settingsButtonPressed(_:))) + gear.bezelStyle = .rounded + gear.controlSize = .large + gear.imagePosition = .imageOnly + gear.toolTip = boardManText("Settings") + gear.setAccessibilityLabel(boardManText("Settings")) + gear.identifier = NSUserInterfaceItemIdentifier("BoardManSettingsButton") + if #available(macOS 11.0, *) { + gear.image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: boardManText("Settings")) + } else { + gear.title = "⚙" + } + contentView.addSubview(gear) + settingsButton = gear + let historyFilter = NSSegmentedControl(frame: .zero) historyFilter.segmentCount = BoardManHistoryUsageFilter.allCases.count historyFilter.trackingMode = .selectOne @@ -4458,6 +4579,17 @@ class BoardManPanel: NSPanel { historyConditionButton = historyCondition updateHistoryConditionButton() + let savedFilter = NSPopUpButton(frame: .zero, pullsDown: false) + savedFilter.controlSize = .small + savedFilter.font = NSFont.systemFont(ofSize: 11, weight: .medium) + savedFilter.target = self + savedFilter.action = #selector(savedFilterPopupChanged(_:)) + savedFilter.identifier = NSUserInterfaceItemIdentifier("BoardManSavedFilterPopup") + savedFilter.setAccessibilityLabel(boardManText("Saved Filters")) + contentView.addSubview(savedFilter) + historySavedFilterPopup = savedFilter + reloadSavedFilterPopup() + let settingsBackground = NSView(frame: .zero) settingsBackground.wantsLayer = true settingsBackground.layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor @@ -4465,6 +4597,22 @@ class BoardManPanel: NSPanel { contentView.addSubview(settingsBackground) settingsBackgroundView = settingsBackground + let settingsDocument = NSView(frame: .zero) + settingsDocument.wantsLayer = true + let settingsScroll = NSScrollView(frame: .zero) + settingsScroll.documentView = settingsDocument + settingsScroll.hasVerticalScroller = true + settingsScroll.hasHorizontalScroller = false + settingsScroll.autohidesScrollers = true + settingsScroll.borderType = .noBorder + settingsScroll.drawsBackground = false + settingsScroll.scrollerStyle = .overlay + settingsScroll.identifier = NSUserInterfaceItemIdentifier("BoardManSettingsScrollView") + settingsScroll.isHidden = true + contentView.addSubview(settingsScroll) + settingsScrollView = settingsScroll + settingsDocumentView = settingsDocument + let sidebar = NSView(frame: .zero) sidebar.wantsLayer = true sidebar.layer?.cornerRadius = LayoutMetrics.cardCornerRadius @@ -4586,9 +4734,28 @@ class BoardManPanel: NSPanel { maxHistoryFormatter.allowsFloats = false maxHistoryValue.formatter = maxHistoryFormatter maxHistoryValue.identifier = NSUserInterfaceItemIdentifier("BoardManVisibleHistoryField") + configureSettingsInputField(maxHistoryValue) contentView.addSubview(maxHistoryValue) maxHistorySizeValueLabel = maxHistoryValue + let maxHistoryDecrease = makeAdjustmentButton( + title: "−", + action: #selector(adjustMaxHistorySize(_:)), + identifier: "BoardManVisibleHistoryDecreaseButton", + delta: -1 + ) + contentView.addSubview(maxHistoryDecrease) + maxHistoryDecreaseButton = maxHistoryDecrease + + let maxHistoryIncrease = makeAdjustmentButton( + title: "+", + action: #selector(adjustMaxHistorySize(_:)), + identifier: "BoardManVisibleHistoryIncreaseButton", + delta: 1 + ) + contentView.addSubview(maxHistoryIncrease) + maxHistoryIncreaseButton = maxHistoryIncrease + let statusLabel = NSTextField(labelWithString: boardManText("Icon")) statusLabel.font = NSFont.systemFont(ofSize: 11) statusLabel.textColor = .labelColor @@ -4901,9 +5068,28 @@ class BoardManPanel: NSPanel { shortcutDelayField.action = #selector(timestampShortcutDelayFieldChanged(_:)) shortcutDelayField.delegate = self shortcutDelayField.identifier = NSUserInterfaceItemIdentifier("BoardManTimestampShortcutDelayField") + configureSettingsInputField(shortcutDelayField) contentView.addSubview(shortcutDelayField) timestampShortcutDelayField = shortcutDelayField + let shortcutDelayDecrease = makeAdjustmentButton( + title: "−", + action: #selector(adjustTimestampShortcutDelay(_:)), + identifier: "BoardManTimestampShortcutDelayDecreaseButton", + delta: -1 + ) + contentView.addSubview(shortcutDelayDecrease) + timestampShortcutDelayDecreaseButton = shortcutDelayDecrease + + let shortcutDelayIncrease = makeAdjustmentButton( + title: "+", + action: #selector(adjustTimestampShortcutDelay(_:)), + identifier: "BoardManTimestampShortcutDelayIncreaseButton", + delta: 1 + ) + contentView.addSubview(shortcutDelayIncrease) + timestampShortcutDelayIncreaseButton = shortcutDelayIncrease + let shortcutDelayStepper = NSStepper(frame: .zero) shortcutDelayStepper.minValue = 0 shortcutDelayStepper.maxValue = 60 @@ -5322,9 +5508,28 @@ class BoardManPanel: NSPanel { durationValue.formatter = durationFormatter durationValue.identifier = NSUserInterfaceItemIdentifier("BoardManTimedPinDurationField") durationValue.toolTip = boardManText("Enter the timed Pin duration directly.") + configureSettingsInputField(durationValue) contentView.addSubview(durationValue) timedPinDurationValueLabel = durationValue + let durationDecrease = makeAdjustmentButton( + title: "−", + action: #selector(adjustTimedPinDuration(_:)), + identifier: "BoardManTimedPinDurationDecreaseButton", + delta: -1 + ) + contentView.addSubview(durationDecrease) + timedPinDurationDecreaseButton = durationDecrease + + let durationIncrease = makeAdjustmentButton( + title: "+", + action: #selector(adjustTimedPinDuration(_:)), + identifier: "BoardManTimedPinDurationIncreaseButton", + delta: 1 + ) + contentView.addSubview(durationIncrease) + timedPinDurationIncreaseButton = durationIncrease + let durationUnit = NSPopUpButton(frame: .zero, pullsDown: false) BoardManTimedPinUnit.allCases.forEach { unit in durationUnit.addItem(withTitle: unit.title) @@ -5419,6 +5624,7 @@ class BoardManPanel: NSPanel { hideText.font = NSFont.systemFont(ofSize: 11) hideText.target = self hideText.action = #selector(addHideRuleRequested(_:)) + configureSettingsInputField(hideText) contentView.addSubview(hideText) hideRuleTextField = hideText @@ -5586,13 +5792,46 @@ class BoardManPanel: NSPanel { contentView.addSubview(stepper) heightStepper = stepper - let heightText = NSTextField(labelWithString: "\(stepper.integerValue)") + let heightText = NSTextField(frame: .zero) + heightText.cell = BoardManCenteredTextFieldCell(textCell: "\(stepper.integerValue)") heightText.alignment = .right - heightText.font = NSFont.systemFont(ofSize: 11) + heightText.font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) heightText.textColor = .labelColor + heightText.integerValue = stepper.integerValue + heightText.isEditable = true + heightText.isSelectable = true + heightText.target = self + heightText.action = #selector(panelHeightFieldChanged(_:)) + heightText.delegate = self + let heightFormatter = NumberFormatter() + heightFormatter.numberStyle = .none + heightFormatter.minimum = 520 + heightFormatter.maximum = 1200 + heightFormatter.allowsFloats = false + heightText.formatter = heightFormatter + heightText.identifier = NSUserInterfaceItemIdentifier("BoardManPanelHeightField") + configureSettingsInputField(heightText) contentView.addSubview(heightText) heightLabel = heightText + let heightDecrease = makeAdjustmentButton( + title: "−", + action: #selector(adjustPanelHeight(_:)), + identifier: "BoardManPanelHeightDecreaseButton", + delta: -1 + ) + contentView.addSubview(heightDecrease) + heightDecreaseButton = heightDecrease + + let heightIncrease = makeAdjustmentButton( + title: "+", + action: #selector(adjustPanelHeight(_:)), + identifier: "BoardManPanelHeightIncreaseButton", + delta: 1 + ) + contentView.addSubview(heightIncrease) + heightIncreaseButton = heightIncrease + // Scroll list: one native surface avoids stacked cards and unnecessary visual-effect layers. let scroll = NSScrollView(frame: .zero) scroll.hasVerticalScroller = true @@ -5672,6 +5911,7 @@ class BoardManPanel: NSPanel { categoryPopup.font = NSFont.systemFont(ofSize: 11) categoryPopup.target = self categoryPopup.action = #selector(snippetCategoryFilterChanged(_:)) + categoryPopup.identifier = NSUserInterfaceItemIdentifier("BoardManSnippetGroupPopup") categoryPopup.isHidden = true categoryPopup.toolTip = boardManText("Hover to open group list") contentView.addSubview(categoryPopup) @@ -5864,6 +6104,9 @@ class BoardManPanel: NSPanel { bubbleImage.isHidden = true previewBubbleImageView = bubbleImage + // Move only the right settings pane into its scroll document. The sidebar remains fixed. + installSettingsScrollHierarchy() + // Load initial data applyControlMetrics() layoutPanelSubviews() @@ -5871,6 +6114,84 @@ class BoardManPanel: NSPanel { synchronizeListGeometry() } + private var settingsContentViews: [NSView] { + let controls: [NSView?] = [ + settingsPageTitleLabel, settingsPageDescriptionLabel, + generalSectionLabel, launchOnLoginButton, inputPasteCommandButton, languageLabel, languagePopup, + maxHistorySizeLabel, maxHistorySizeStepper, maxHistorySizeValueLabel, + maxHistoryDecreaseButton, maxHistoryIncreaseButton, + statusItemLabel, statusItemPopup, shortcutSectionLabel, shortcutStatusLabel, + snippetSettingsSectionLabel, snippetSummaryLabel, snippetFoldersLabel, snippetGroupProNoteLabel, + snippetGroupOrderPopup, snippetGroupMoveUpButton, snippetGroupMoveDownButton, + snippetShortcutsLabel, snippetShortcutScrollView, manageSnippetsButton, + viewSectionLabel, rowNumbersButton, timestampLabel, timestampPopup, timestampPositionLabel, timestampPositionPopup, + relativeNumberLabel, relativeNumberPopup, relativeUnitLabel, relativeUnitPopup, + relativeSuffixLabel, relativeSuffixPopup, relativeNowLabel, relativeNowPopup, + timestampInteractionLabel, timestampInteractionPopup, + timestampShortcutEnabledButton, timestampShortcutLabel, timestampShortcutRecordView, + timestampShortcutDelayLabel, timestampShortcutDelayField, timestampShortcutDelayStepper, + timestampShortcutDelayDecreaseButton, timestampShortcutDelayIncreaseButton, timestampShortcutSecondsLabel, + usageCountButton, usageStyleLabel, usageStylePopup, usedItemStyleLabel, usedItemStylePopup, + themePresetLabel, themePresetPopup, appearanceModeLabel, appearanceModePopup, + uiStyleLabel, uiStylePopup, fontChoiceLabel, fontChoicePopup, themeLightenButton, + customAccentLabel, customAccentColorWell, customAccentOpacitySlider, + customPanelLabel, customPanelColorWell, customPanelOpacitySlider, + customUsedColorLabel, customUsedColorWell, customUsedOpacitySlider, resetCustomColorsButton, + textPreviewScaleLabel, textPreviewScaleSlider, textPreviewScaleValueLabel, + imagePreviewScaleLabel, imagePreviewScaleSlider, imagePreviewScaleValueLabel, previewScaleProNoteLabel, + historySectionLabel, dedupeButton, overwriteSameHistoryButton, reuseTopButton, clearHistoryButton, + skipPinnedNavigationButton, longPressActionLabel, longPressActionPopup, + timedPinDurationLabel, timedPinPresetPopup, timedPinPresetAddButton, timedPinPresetRemoveButton, + timedPinDurationStepper, timedPinDurationValueLabel, + timedPinDurationDecreaseButton, timedPinDurationIncreaseButton, + timedPinDurationUnitPopup, exportHistoryCSVButton, + privacySectionLabel, hideMaskedPreviewButton, hideMaskedTitleButton, + excludedAppsButton, excludedAppsSummaryLabel, storedTypesSectionLabel, + filterSectionLabel, hideRuleTextField, hideRuleModePopup, addHideRuleButton, + removeLastHideRuleButton, clearHideRulesButton, hideRulesSummaryLabel, + hideRulesExamplesLabel, hideRulesNoteLabel, + updatesPreferenceView, + licenseSectionLabel, licensePlanLabel, licenseStateLabel, licenseLimitsLabel, + licenseKeyField, licenseActivateButton, licenseActivationStatusLabel, + licenseUpgradeButton, licenseProLockedControlView, licenseMockNoteLabel, licenseStateExamplesLabel, + labsSectionLabel, labsNoteLabel, + heightControlLabel, heightLabel, heightStepper, heightDecreaseButton, heightIncreaseButton, + pauseRecordingButton + ] + return controls.compactMap { $0 } + + globalShortcutRows.flatMap { $0.views } + + storedTypeButtons + } + + private func installSettingsScrollHierarchy() { + guard let document = settingsDocumentView else { return } + for view in settingsContentViews where view.superview !== document { + view.removeFromSuperview() + document.addSubview(view) + } + } + + private func configureSettingsInputField(_ field: NSTextField) { + field.isEditable = true + field.isSelectable = true + field.isEnabled = true + field.isBezeled = true + field.bezelStyle = .roundedBezel + field.drawsBackground = true + field.backgroundColor = .textBackgroundColor + field.focusRingType = .default + } + + private func makeAdjustmentButton(title: String, action: Selector, identifier: String, delta: Int) -> NSButton { + let button = NSButton(title: title, target: self, action: action) + button.bezelStyle = .rounded + button.controlSize = .small + button.font = NSFont.systemFont(ofSize: 14, weight: .semibold) + button.tag = delta + button.identifier = NSUserInterfaceItemIdentifier(identifier) + return button + } + private func inferredFontWeight(_ font: NSFont) -> NSFont.Weight { let traits = font.fontDescriptor.symbolicTraits if traits.contains(.bold) { return .semibold } @@ -6335,12 +6656,22 @@ class BoardManPanel: NSPanel { let width = bounds.width - (margin * 2) let headerY = isQuickMode ? bounds.height - 42 : bounds.height - 70 let isSettings = activeTab == .settings && !isQuickMode - let tabsWidth: CGFloat = isCompact ? 240 : min(324, max(282, floor(width * 0.40))) + let gearWidth: CGFloat = 36 + let gearGap: CGFloat = isCompact ? 8 : 12 + let tabsWidth: CGFloat = isCompact ? 190 : min(250, max(216, floor(width * 0.31))) let tabsFrame = NSIntegralRect(NSRect(x: margin, y: headerY, width: tabsWidth, height: 36)) segmentedControl?.frame = tabsFrame segmentedControl?.isHidden = isQuickMode updateTabWidths(totalWidth: tabsWidth) applyResponsiveTabPresentation(isCompact: isCompact) + settingsButton?.isHidden = isQuickMode + settingsButton?.frame = NSIntegralRect(NSRect( + x: margin + width - gearWidth, + y: headerY, + width: gearWidth, + height: 36 + )) + updateSettingsButtonAppearance() searchField?.isHidden = isSettings || isQuickMode let showsSnippetButtons = activeTab == .snippets && !isSettings && !isQuickMode @@ -6349,7 +6680,8 @@ class BoardManPanel: NSPanel { let snippetButtonsWidth = showsSnippetButtons ? snippetButtonWidths.reduce(0, +) + (snippetButtonGap * 2) : 0 let headerGap: CGFloat = isCompact ? 10 : 14 let rightX = margin + tabsWidth + headerGap - let rightWidth = max(0, width - tabsWidth - headerGap) + let rightEdge = margin + width - gearWidth - gearGap + let rightWidth = max(0, rightEdge - rightX) let searchWidth = max(78, rightWidth - snippetButtonsWidth - (showsSnippetButtons ? headerGap : 0)) let searchHeight = min(32, max(28, ceil(searchField?.intrinsicContentSize.height ?? 30))) @@ -6380,6 +6712,7 @@ class BoardManPanel: NSPanel { historyUsageFilterControl?.isHidden = !showsHistoryToolbar historySortButton?.isHidden = !showsHistoryToolbar historyConditionButton?.isHidden = !showsHistoryToolbar + historySavedFilterPopup?.isHidden = !showsHistoryToolbar let historyToolbarY = isQuickMode ? bounds.height - 50 : contentTop - 30 if showsHistoryToolbar { let filterWidth: CGFloat = 114 @@ -6392,6 +6725,13 @@ class BoardManPanel: NSPanel { } historySortButton?.frame = NSRect(x: margin + filterWidth + 8, y: historyToolbarY, width: 32, height: 26) historyConditionButton?.frame = NSRect(x: margin + filterWidth + 48, y: historyToolbarY, width: 32, height: 26) + let savedFilterX = margin + filterWidth + 88 + historySavedFilterPopup?.frame = NSIntegralRect(NSRect( + x: savedFilterX, + y: historyToolbarY, + width: max(140, min(isCompact ? 166 : 220, margin + width - savedFilterX)), + height: 26 + )) } let sidebarWidth: CGFloat = min(184, max(160, floor(width * 0.25))) @@ -6401,9 +6741,26 @@ class BoardManPanel: NSPanel { settingsSidebarView?.isHidden = !isSettings settingsSidebarView?.frame = NSRect(x: margin, y: 28, width: sidebarWidth, height: max(220, contentTop - 28)) layoutSettingsSidebar(width: sidebarWidth, height: max(220, contentTop - 28)) + let settingsViewportHeight = max(220, contentTop - 28) + let settingsFrame = NSIntegralRect(NSRect( + x: settingsContentX, + y: 28, + width: settingsContentWidth, + height: settingsViewportHeight + )) settingsBackgroundView?.isHidden = !isSettings - settingsBackgroundView?.frame = NSRect(x: settingsContentX, y: 28, width: settingsContentWidth, height: max(220, contentTop - 28)) - layoutInlineSettingsControls(margin: settingsContentX, width: settingsContentWidth, topY: contentTop, isVisible: isSettings) + settingsBackgroundView?.frame = settingsFrame + settingsScrollView?.isHidden = !isSettings + settingsScrollView?.frame = settingsFrame + let documentHeight = settingsDocumentHeight(viewportHeight: settingsViewportHeight) + settingsDocumentView?.frame = NSRect(x: 0, y: 0, width: settingsContentWidth, height: documentHeight) + layoutInlineSettingsControls(margin: 0, width: settingsContentWidth, topY: documentHeight, isVisible: isSettings) + if isSettings, shouldScrollSettingsToTop, let scroll = settingsScrollView { + let topOrigin = max(0, documentHeight - scroll.contentView.bounds.height) + scroll.contentView.scroll(to: NSPoint(x: 0, y: topOrigin)) + scroll.reflectScrolledClipView(scroll.contentView) + shouldScrollSettingsToTop = false + } footerNote?.isHidden = true scrollView?.isHidden = isSettings let showsSnippetCategories = activeTab == .snippets && !isSettings @@ -6472,9 +6829,7 @@ class BoardManPanel: NSPanel { let titleLabelHeight: CGFloat = 17 let titleFieldHeight = LayoutMetrics.controlHeight let titleLabelToFieldGap: CGFloat = 4 - let titleFieldToStatusGap: CGFloat = 24 - let statusHeight: CGFloat = 32 - let statusToToggleGap: CGFloat = 14 + let titleFieldToToggleGap: CGFloat = 18 let toggleHeight: CGFloat = 22 let toggleToContentLabelGap: CGFloat = 16 let contentLabelHeight: CGFloat = 17 @@ -6482,8 +6837,7 @@ class BoardManPanel: NSPanel { let titleLabelY = topY - titleLabelHeight let titleFieldY = titleLabelY - titleLabelToFieldGap - titleFieldHeight - let statusY = titleFieldY - titleFieldToStatusGap - statusHeight - let toggleY = statusY - statusToToggleGap - toggleHeight + let toggleY = titleFieldY - titleFieldToToggleGap - toggleHeight let contentLabelY = toggleY - toggleToContentLabelGap - contentLabelHeight let contentTop = contentLabelY - contentLabelToEditorGap @@ -6499,12 +6853,8 @@ class BoardManPanel: NSPanel { width: contentWidth, height: titleFieldHeight )) - snippetEditorStatusLabel?.frame = NSIntegralRect(NSRect( - x: inset, - y: statusY, - width: contentWidth, - height: statusHeight - )) + snippetEditorStatusLabel?.isHidden = true + snippetEditorStatusLabel?.frame = .zero let toggleGap: CGFloat = 8 let toggleWidth = max(104, floor((contentWidth - toggleGap) / 2)) @@ -6606,20 +6956,34 @@ class BoardManPanel: NSPanel { ofSize: isCompact ? 11.25 : 12.5, weight: .medium ) - for tab in BoardManPanelTab.allCases { + for tab in [BoardManPanelTab.history, BoardManPanelTab.snippets] { segmentedControl.setLabel(tab.title(compact: isCompact), forSegment: tab.rawValue) segmentedControl.setToolTip(tab.title, forSegment: tab.rawValue) } } private func updateTabWidths(totalWidth: CGFloat) { - guard let segmentedControl else { return } - let segmentWidth = max(72, floor(totalWidth / 3)) - for segment in 0..<3 { + guard let segmentedControl, segmentedControl.segmentCount > 0 else { return } + let segmentWidth = max(72, floor(totalWidth / CGFloat(segmentedControl.segmentCount))) + for segment in 0.. CGFloat { + let requiredHeight: CGFloat + switch activeSettingsCategory { + case .general: requiredHeight = 790 + case .view: requiredHeight = 760 + case .history: requiredHeight = 730 + case .snippets: requiredHeight = 640 + case .privacy: requiredHeight = 690 + case .updates: requiredHeight = 390 + case .license: requiredHeight = 650 + } + return max(viewportHeight, requiredHeight) + } + private func layoutSettingsSidebar(width: CGFloat, height: CGFloat) { guard let sidebar = settingsSidebarView else { return } let inset: CGFloat = 12 @@ -6660,7 +7024,9 @@ class BoardManPanel: NSPanel { let allControls: [NSView?] = [ settingsPageTitleLabel, settingsPageDescriptionLabel, generalSectionLabel, launchOnLoginButton, inputPasteCommandButton, languageLabel, languagePopup, - maxHistorySizeLabel, maxHistorySizeStepper, maxHistorySizeValueLabel, statusItemLabel, statusItemPopup, shortcutSectionLabel, shortcutStatusLabel, + maxHistorySizeLabel, maxHistorySizeStepper, maxHistorySizeValueLabel, + maxHistoryDecreaseButton, maxHistoryIncreaseButton, + statusItemLabel, statusItemPopup, shortcutSectionLabel, shortcutStatusLabel, snippetSettingsSectionLabel, snippetSummaryLabel, snippetFoldersLabel, snippetGroupProNoteLabel, snippetGroupOrderPopup, snippetGroupMoveUpButton, snippetGroupMoveDownButton, snippetShortcutsLabel, snippetShortcutScrollView, manageSnippetsButton, @@ -6669,8 +7035,11 @@ class BoardManPanel: NSPanel { relativeSuffixLabel, relativeSuffixPopup, relativeNowLabel, relativeNowPopup, timestampInteractionLabel, timestampInteractionPopup, timestampShortcutEnabledButton, timestampShortcutLabel, timestampShortcutRecordView, - timestampShortcutDelayLabel, timestampShortcutDelayField, timestampShortcutDelayStepper, timestampShortcutSecondsLabel, - usageCountButton, usageStyleLabel, usageStylePopup, usedItemStyleLabel, usedItemStylePopup, themePresetLabel, themePresetPopup, appearanceModeLabel, appearanceModePopup, uiStyleLabel, uiStylePopup, fontChoiceLabel, fontChoicePopup, themeLightenButton, + timestampShortcutDelayLabel, timestampShortcutDelayField, timestampShortcutDelayStepper, + timestampShortcutDelayDecreaseButton, timestampShortcutDelayIncreaseButton, timestampShortcutSecondsLabel, + usageCountButton, usageStyleLabel, usageStylePopup, usedItemStyleLabel, usedItemStylePopup, + themePresetLabel, themePresetPopup, appearanceModeLabel, appearanceModePopup, + uiStyleLabel, uiStylePopup, fontChoiceLabel, fontChoicePopup, themeLightenButton, customAccentLabel, customAccentColorWell, customAccentOpacitySlider, customPanelLabel, customPanelColorWell, customPanelOpacitySlider, customUsedColorLabel, customUsedColorWell, customUsedOpacitySlider, resetCustomColorsButton, @@ -6680,13 +7049,18 @@ class BoardManPanel: NSPanel { skipPinnedNavigationButton, longPressActionLabel, longPressActionPopup, timedPinDurationLabel, timedPinPresetPopup, timedPinPresetAddButton, timedPinPresetRemoveButton, timedPinDurationStepper, timedPinDurationValueLabel, + timedPinDurationDecreaseButton, timedPinDurationIncreaseButton, timedPinDurationUnitPopup, exportHistoryCSVButton, privacySectionLabel, hideMaskedPreviewButton, hideMaskedTitleButton, excludedAppsButton, excludedAppsSummaryLabel, storedTypesSectionLabel, - filterSectionLabel, hideRuleTextField, hideRuleModePopup, addHideRuleButton, removeLastHideRuleButton, clearHideRulesButton, hideRulesSummaryLabel, hideRulesExamplesLabel, hideRulesNoteLabel, - licenseSectionLabel, licensePlanLabel, licenseStateLabel, licenseLimitsLabel, licenseKeyField, licenseActivateButton, licenseActivationStatusLabel, licenseUpgradeButton, licenseProLockedControlView, licenseMockNoteLabel, licenseStateExamplesLabel, + filterSectionLabel, hideRuleTextField, hideRuleModePopup, addHideRuleButton, + removeLastHideRuleButton, clearHideRulesButton, hideRulesSummaryLabel, + hideRulesExamplesLabel, hideRulesNoteLabel, + licenseSectionLabel, licensePlanLabel, licenseStateLabel, licenseLimitsLabel, + licenseKeyField, licenseActivateButton, licenseActivationStatusLabel, licenseUpgradeButton, + licenseProLockedControlView, licenseMockNoteLabel, licenseStateExamplesLabel, labsSectionLabel, labsNoteLabel, - heightControlLabel, heightLabel, heightStepper + heightControlLabel, heightLabel, heightStepper, heightDecreaseButton, heightIncreaseButton ] allControls.forEach { $0?.isHidden = true } globalShortcutRows.flatMap { $0.views }.forEach { $0.isHidden = true } @@ -6713,7 +7087,7 @@ class BoardManPanel: NSPanel { var generalControls: [NSView?] = [ generalSectionLabel, launchOnLoginButton, inputPasteCommandButton, languageLabel, languagePopup, - maxHistorySizeLabel, maxHistorySizeStepper, maxHistorySizeValueLabel, + maxHistorySizeLabel, maxHistorySizeValueLabel, maxHistoryDecreaseButton, maxHistoryIncreaseButton, statusItemLabel, statusItemPopup, shortcutSectionLabel, shortcutStatusLabel ] generalControls.append(contentsOf: globalShortcutRows.flatMap { $0.views }.map { Optional($0) }) @@ -6731,16 +7105,17 @@ class BoardManPanel: NSPanel { customUsedColorLabel, customUsedColorWell, customUsedOpacitySlider, resetCustomColorsButton, textPreviewScaleLabel, textPreviewScaleSlider, textPreviewScaleValueLabel, imagePreviewScaleLabel, imagePreviewScaleSlider, imagePreviewScaleValueLabel, previewScaleProNoteLabel, - heightControlLabel, heightLabel, heightStepper + heightControlLabel, heightLabel, heightDecreaseButton, heightIncreaseButton ] let historyControls: [NSView?] = [ historySectionLabel, dedupeButton, overwriteSameHistoryButton, reuseTopButton, skipPinnedNavigationButton, longPressActionLabel, longPressActionPopup, timestampInteractionLabel, timestampInteractionPopup, timestampShortcutEnabledButton, timestampShortcutLabel, timestampShortcutRecordView, - timestampShortcutDelayLabel, timestampShortcutDelayField, timestampShortcutDelayStepper, timestampShortcutSecondsLabel, + timestampShortcutDelayLabel, timestampShortcutDelayField, + timestampShortcutDelayDecreaseButton, timestampShortcutDelayIncreaseButton, timestampShortcutSecondsLabel, timedPinDurationLabel, timedPinPresetPopup, timedPinPresetAddButton, timedPinPresetRemoveButton, - timedPinDurationStepper, timedPinDurationValueLabel, + timedPinDurationValueLabel, timedPinDurationDecreaseButton, timedPinDurationIncreaseButton, timedPinDurationUnitPopup, exportHistoryCSVButton, clearHistoryButton ] let snippetControls: [NSView?] = [snippetSettingsSectionLabel, snippetSummaryLabel, snippetFoldersLabel, snippetGroupProNoteLabel, snippetGroupOrderPopup, snippetGroupMoveUpButton, snippetGroupMoveDownButton, snippetShortcutsLabel, snippetShortcutScrollView, manageSnippetsButton] @@ -6789,8 +7164,9 @@ class BoardManPanel: NSPanel { placeLabeledRow(label: languageLabel, control: languagePopup, originX: originX, originY: originY - 112, width: width) maxHistorySizeLabel?.frame = NSRect(x: originX, y: originY - 157, width: fieldLabelWidth, height: 16) let visibleHistoryX = originX + fieldLabelWidth + 12 - maxHistorySizeValueLabel?.frame = NSRect(x: visibleHistoryX, y: originY - 164, width: 82, height: rowH) - maxHistorySizeStepper?.frame = NSRect(x: visibleHistoryX + 90, y: originY - 164, width: 24, height: rowH) + maxHistoryDecreaseButton?.frame = NSRect(x: visibleHistoryX, y: originY - 164, width: 30, height: rowH) + maxHistorySizeValueLabel?.frame = NSRect(x: visibleHistoryX + 36, y: originY - 164, width: 82, height: rowH) + maxHistoryIncreaseButton?.frame = NSRect(x: visibleHistoryX + 124, y: originY - 164, width: 30, height: rowH) placeLabeledRow(label: statusItemLabel, control: statusItemPopup, originX: originX, originY: originY - 204, width: width) } @@ -6838,8 +7214,10 @@ class BoardManPanel: NSPanel { themeLightenButton?.frame = NSRect(x: originX, y: originY - 312, width: 128, height: 20) heightControlLabel?.frame = NSRect(x: originX + 144, y: originY - 312, width: fieldLabelWidth, height: 16) - heightStepper?.frame = NSRect(x: originX + 144 + fieldLabelWidth + 12, y: originY - 319, width: 76, height: rowH) - heightLabel?.frame = NSRect(x: originX + 144 + fieldLabelWidth + 100, y: originY - 312, width: 52, height: 16) + let heightControlX = originX + 144 + fieldLabelWidth + 12 + heightDecreaseButton?.frame = NSRect(x: heightControlX, y: originY - 319, width: 30, height: rowH) + heightLabel?.frame = NSRect(x: heightControlX + 36, y: originY - 319, width: 64, height: rowH) + heightIncreaseButton?.frame = NSRect(x: heightControlX + 106, y: originY - 319, width: 30, height: rowH) func placeColorRow(label: NSTextField?, well: NSColorWell?, slider: NSSlider?, originX: CGFloat, originY: CGFloat, rowWidth: CGFloat) { let labelWidth: CGFloat = min(78, max(56, floor(rowWidth * 0.28))) @@ -6944,21 +7322,28 @@ class BoardManPanel: NSPanel { )) let shortcutDelayX = originX + delayLabelWidth + 12 let secondsWidth: CGFloat = 44 + let adjustmentWidth: CGFloat = 30 let delayFieldWidth: CGFloat = min(82, max(58, floor(width * 0.18))) - timestampShortcutDelayField?.frame = NSIntegralRect(NSRect( + timestampShortcutDelayDecreaseButton?.frame = NSIntegralRect(NSRect( x: shortcutDelayX, y: delayRowY, + width: adjustmentWidth, + height: rowH + )) + timestampShortcutDelayField?.frame = NSIntegralRect(NSRect( + x: shortcutDelayX + adjustmentWidth + 6, + y: delayRowY, width: delayFieldWidth, height: rowH )) - timestampShortcutDelayStepper?.frame = NSIntegralRect(NSRect( - x: shortcutDelayX + delayFieldWidth + 8, + timestampShortcutDelayIncreaseButton?.frame = NSIntegralRect(NSRect( + x: shortcutDelayX + adjustmentWidth + delayFieldWidth + 12, y: delayRowY, - width: 24, + width: adjustmentWidth, height: rowH )) timestampShortcutSecondsLabel?.frame = NSIntegralRect(NSRect( - x: shortcutDelayX + delayFieldWidth + 42, + x: shortcutDelayX + (adjustmentWidth * 2) + delayFieldWidth + 24, y: delayRowY + 7, width: secondsWidth, height: 16 @@ -6998,19 +7383,25 @@ class BoardManPanel: NSPanel { let durationRowY = presetRowY - 36 let durationValueWidth: CGFloat = min(96, max(72, floor(width * 0.22))) - timedPinDurationValueLabel?.frame = NSIntegralRect(NSRect( + timedPinDurationDecreaseButton?.frame = NSIntegralRect(NSRect( x: originX, y: durationRowY, + width: 30, + height: rowH + )) + timedPinDurationValueLabel?.frame = NSIntegralRect(NSRect( + x: originX + 36, + y: durationRowY, width: durationValueWidth, height: rowH )) - timedPinDurationStepper?.frame = NSIntegralRect(NSRect( - x: originX + durationValueWidth + 8, + timedPinDurationIncreaseButton?.frame = NSIntegralRect(NSRect( + x: originX + durationValueWidth + 42, y: durationRowY, - width: 24, + width: 30, height: rowH )) - let unitX = originX + durationValueWidth + 44 + let unitX = originX + durationValueWidth + 84 timedPinDurationUnitPopup?.frame = NSIntegralRect(NSRect( x: unitX, y: durationRowY, @@ -7223,6 +7614,25 @@ class BoardManPanel: NSPanel { setSelectedIndex(index) } + func setSnippetGroupIdentifiersForTesting(_ identifiers: Set) { + setActiveSnippetGroupIdentifiers(identifiers) + reloadSnippetCategoryPopup() + applyCurrentFilter() + } + + func reloadSnippetGroupsForTesting() { + reloadSnippetCategoryPopup() + applyCurrentFilter() + } + + var activeSnippetGroupIdentifiersForTesting: Set { + return activeSnippetGroupIdentifiers + } + + var visibleItemHashesForTesting: [String] { + return historyItems.map(\.dataHash) + } + fileprivate func prepareReadmeScreenshot(scene: String, width: CGFloat?, height: CGFloat?) { var targetFrame = frame if let width { @@ -7406,6 +7816,13 @@ class BoardManPanel: NSPanel { updateSnippetModeUI() } + @objc private func adjustTimedPinDuration(_ sender: NSButton) { + let current = timedPinDurationValueLabel?.integerValue ?? configuredTimedPinDurationValue + _ = BoardManTimedPinPresetStore.updateSelected(value: current + sender.tag) + refreshTimedPinSettingsControls() + updateSnippetModeUI() + } + @objc private func timedPinDurationUnitChanged(_ sender: NSPopUpButton) { let rawValue = sender.selectedItem?.representedObject as? String _ = BoardManTimedPinPresetStore.updateSelected(unit: BoardManTimedPinUnit.allowed(rawValue)) @@ -7534,6 +7951,13 @@ class BoardManPanel: NSPanel { refreshTimestampShortcutControls() } + @objc private func adjustTimestampShortcutDelay(_ sender: NSButton) { + let current = timestampShortcutDelayField?.doubleValue ?? configuredTimestampShortcutDelay + let value = BoardManPanel.clampedTimestampShortcutDelay(current + (Double(sender.tag) * 0.1)) + AppEnvironment.current.defaults.set(value, forKey: Constants.UserDefaults.boardManTimestampShortcutDelay) + refreshTimestampShortcutControls() + } + @objc private func timestampInteractionChanged(_ sender: NSPopUpButton) { let rawValue = sender.selectedItem?.representedObject as? String let interaction = BoardManTimestampInteraction.allowed(rawValue) @@ -7815,7 +8239,7 @@ class BoardManPanel: NSPanel { @objc private func toggleSnippetReorderMode(_ sender: NSButton) { if activeSnippetCategoryIdentifier == BoardManPanel.allCategoriesIdentifier, let categoryIdentifier = selectedSnippetItem?.categoryIdentifier { - activeSnippetCategoryIdentifier = categoryIdentifier + setActiveSnippetGroupIdentifiers([categoryIdentifier]) reloadSnippetCategoryPopup() applyCurrentFilter() } @@ -7856,8 +8280,8 @@ class BoardManPanel: NSPanel { let snippet = realm.object(ofType: CPYSnippet.self, forPrimaryKey: item.dataHash) else { isSnippetEditing = false editingSnippetIdentifier = nil - snippetEditorStatusLabel?.stringValue = boardManText("Select a snippet to preview") - snippetEditorStatusLabel?.textColor = .secondaryLabelColor + snippetEditorStatusLabel?.isHidden = true + snippetEditorStatusLabel?.stringValue = "" snippetEditorStatusLabel?.layer?.backgroundColor = NSColor.clear.cgColor snippetEditorView?.layer?.borderWidth = 1 snippetEditorView?.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.55).cgColor @@ -7891,50 +8315,95 @@ class BoardManPanel: NSPanel { snippetEditorTextView?.isEditable = canEditSelection snippetEditorTextView?.isSelectable = true + snippetEditorStatusLabel?.isHidden = true + snippetEditorStatusLabel?.stringValue = "" + snippetEditorStatusLabel?.layer?.backgroundColor = NSColor.clear.cgColor + snippetEditorView?.layer?.borderWidth = 1 + snippetEditorView?.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.55).cgColor if isEditingSelection { - snippetEditorStatusLabel?.stringValue = boardManText("Edit mode: change the fields, then save") - snippetEditorStatusLabel?.textColor = themeAccentColor - snippetEditorStatusLabel?.layer?.backgroundColor = themeAccentColor.withAlphaComponent(0.12).cgColor - snippetEditorView?.layer?.borderWidth = 2 - snippetEditorView?.layer?.borderColor = themeAccentColor.withAlphaComponent(0.85).cgColor snippetSaveButton?.title = boardManText("Save Changes") - } else { - snippetEditorStatusLabel?.stringValue = boardManText("Click the preview to edit") - snippetEditorStatusLabel?.textColor = .secondaryLabelColor - snippetEditorStatusLabel?.layer?.backgroundColor = NSColor.secondaryLabelColor.withAlphaComponent(0.08).cgColor - snippetEditorView?.layer?.borderWidth = 1 - snippetEditorView?.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.55).cgColor } } + private func setActiveSnippetGroupIdentifiers(_ identifiers: Set) { + activeSnippetGroupIdentifiers = identifiers + activeSnippetCategoryIdentifier = identifiers.count == 1 + ? (identifiers.first ?? BoardManPanel.allCategoriesIdentifier) + : BoardManPanel.allCategoriesIdentifier + } + + private func snippetGroupSummaryTitle(folders: [CPYFolder]) -> String { + guard !activeSnippetGroupIdentifiers.isEmpty else { return boardManText("All Groups") } + if activeSnippetGroupIdentifiers.count == 1, + let identifier = activeSnippetGroupIdentifiers.first { + if identifier == BoardManPanel.uncategorizedCategoryIdentifier { + return boardManText("Uncategorized") + } + if let folder = folders.first(where: { $0.identifier == identifier }) { + let title = folder.title.trimmingCharacters(in: .whitespacesAndNewlines) + return title.isEmpty ? boardManText("Untitled folder") : title + } + } + return String(format: boardManText("%d Groups"), activeSnippetGroupIdentifiers.count) + } + private func reloadSnippetCategoryPopup() { guard let popup = snippetCategoryPopup else { return } - let selectedIdentifier = activeSnippetCategoryIdentifier - popup.removeAllItems() - addCategoryMenuItem(to: popup, title: "All", identifier: BoardManPanel.allCategoriesIdentifier) - let realm = try! Realm() - let folders = realm.objects(CPYFolder.self).sorted(byKeyPath: #keyPath(CPYFolder.index), ascending: true) - folders.forEach { folder in - let title = folder.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "untitled folder" : folder.title - addCategoryMenuItem(to: popup, title: title, identifier: folder.identifier) + let folders = Array(realm.objects(CPYFolder.self).sorted(byKeyPath: #keyPath(CPYFolder.index), ascending: true)) + var availableIdentifiers = Set(folders.map(\.identifier)) + let includesUncategorized = allItems.contains { + $0.categoryIdentifier == BoardManPanel.uncategorizedCategoryIdentifier } - - if allItems.contains(where: { $0.categoryIdentifier == BoardManPanel.uncategorizedCategoryIdentifier }) { - addCategoryMenuItem(to: popup, title: "Uncategorized", identifier: BoardManPanel.uncategorizedCategoryIdentifier) + if includesUncategorized { + availableIdentifiers.insert(BoardManPanel.uncategorizedCategoryIdentifier) } + setActiveSnippetGroupIdentifiers(activeSnippetGroupIdentifiers.intersection(availableIdentifiers)) - let identifiers = popup.itemArray.compactMap { $0.representedObject as? String } - activeSnippetCategoryIdentifier = identifiers.contains(selectedIdentifier) ? selectedIdentifier : BoardManPanel.allCategoriesIdentifier - if let item = popup.itemArray.first(where: { ($0.representedObject as? String) == activeSnippetCategoryIdentifier }) { - popup.select(item) + popup.removeAllItems() + popup.addItem(withTitle: snippetGroupSummaryTitle(folders: folders)) + popup.lastItem?.representedObject = "__boardman_group_summary__" + popup.lastItem?.isEnabled = false + popup.menu?.addItem(.separator()) + addCategoryMenuItem( + to: popup, + title: boardManText("All Groups"), + identifier: BoardManPanel.allCategoriesIdentifier, + isSelected: activeSnippetGroupIdentifiers.isEmpty + ) + folders.forEach { folder in + let title = folder.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? boardManText("Untitled folder") + : folder.title + addCategoryMenuItem( + to: popup, + title: title, + identifier: folder.identifier, + isSelected: activeSnippetGroupIdentifiers.contains(folder.identifier) + ) } + if includesUncategorized { + addCategoryMenuItem( + to: popup, + title: boardManText("Uncategorized"), + identifier: BoardManPanel.uncategorizedCategoryIdentifier, + isSelected: activeSnippetGroupIdentifiers.contains(BoardManPanel.uncategorizedCategoryIdentifier) + ) + } + popup.selectItem(at: 0) + popup.toolTip = activeSnippetGroupIdentifiers.isEmpty + ? boardManText("All Groups") + : activeSnippetGroupIdentifiers.sorted().joined(separator: ", ") updateSnippetActionButtons() } - private func addCategoryMenuItem(to popup: NSPopUpButton, title: String, identifier: String) { + private func addCategoryMenuItem(to popup: NSPopUpButton, + title: String, + identifier: String, + isSelected: Bool) { popup.addItem(withTitle: title) popup.lastItem?.representedObject = identifier + popup.lastItem?.state = isSelected ? .on : .off } private func selectedCategoryFolder() -> CPYFolder? { @@ -7947,13 +8416,26 @@ class BoardManPanel: NSPanel { } @objc private func snippetCategoryFilterChanged(_ sender: NSPopUpButton) { - activeSnippetCategoryIdentifier = (sender.selectedItem?.representedObject as? String) ?? BoardManPanel.allCategoriesIdentifier + guard let identifier = sender.selectedItem?.representedObject as? String else { + reloadSnippetCategoryPopup() + return + } + var identifiers = activeSnippetGroupIdentifiers + if identifier == BoardManPanel.allCategoriesIdentifier { + identifiers.removeAll() + } else if identifiers.contains(identifier) { + identifiers.remove(identifier) + } else { + identifiers.insert(identifier) + } + setActiveSnippetGroupIdentifiers(identifiers) isSnippetEditing = false editingSnippetIdentifier = nil isSnippetReorderMode = false selectedIndex = -1 hoveredRow = -1 hidePreviewBubble() + reloadSnippetCategoryPopup() applyCurrentFilter() updateSnippetActionButtons() } @@ -7972,7 +8454,7 @@ class BoardManPanel: NSPanel { realm.transaction { realm.add(folder) } - activeSnippetCategoryIdentifier = folder.identifier + setActiveSnippetGroupIdentifiers([folder.identifier]) onRefreshRequested?() } @@ -8016,7 +8498,7 @@ class BoardManPanel: NSPanel { } realm.delete(savedFolder) } - activeSnippetCategoryIdentifier = fallbackFolder.identifier + setActiveSnippetGroupIdentifiers([fallbackFolder.identifier]) selectedIndex = -1 onRefreshRequested?() refreshSnippetEditor() @@ -8056,13 +8538,13 @@ class BoardManPanel: NSPanel { realm.transaction { folder.snippets.append(snippet) } - activeSnippetCategoryIdentifier = folder.identifier + setActiveSnippetGroupIdentifiers([folder.identifier]) } else { snippet.index = realm.objects(CPYSnippet.self).count realm.transaction { realm.add(snippet) } - activeSnippetCategoryIdentifier = BoardManPanel.uncategorizedCategoryIdentifier + setActiveSnippetGroupIdentifiers([BoardManPanel.uncategorizedCategoryIdentifier]) } onRefreshRequested?() selectSnippetInCurrentList(identifier: snippet.identifier) @@ -8337,12 +8819,24 @@ class BoardManPanel: NSPanel { popup.removeAllItems() let realm = try! Realm() let folders = realm.objects(CPYFolder.self).sorted(byKeyPath: #keyPath(CPYFolder.index), ascending: true) + let effectiveIdentifier = selectedIdentifier == BoardManPanel.allCategoriesIdentifier + ? (folders.first?.identifier ?? BoardManPanel.uncategorizedCategoryIdentifier) + : selectedIdentifier folders.forEach { folder in let title = folder.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "untitled folder" : folder.title - addCategoryMenuItem(to: popup, title: title, identifier: folder.identifier) + addCategoryMenuItem( + to: popup, + title: title, + identifier: folder.identifier, + isSelected: folder.identifier == effectiveIdentifier + ) } - addCategoryMenuItem(to: popup, title: "Uncategorized", identifier: BoardManPanel.uncategorizedCategoryIdentifier) - let effectiveIdentifier = selectedIdentifier == BoardManPanel.allCategoriesIdentifier ? (folders.first?.identifier ?? BoardManPanel.uncategorizedCategoryIdentifier) : selectedIdentifier + addCategoryMenuItem( + to: popup, + title: "Uncategorized", + identifier: BoardManPanel.uncategorizedCategoryIdentifier, + isSelected: effectiveIdentifier == BoardManPanel.uncategorizedCategoryIdentifier + ) if let item = popup.itemArray.first(where: { ($0.representedObject as? String) == effectiveIdentifier }) { popup.select(item) } @@ -8412,10 +8906,10 @@ class BoardManPanel: NSPanel { } } - @objc private func panelHeightChanged(_ sender: NSStepper) { - let height = BoardManPanel.clampedPanelHeight(sender.integerValue) - sender.integerValue = height - heightLabel?.stringValue = "\(height)" + private func applyPanelHeight(_ rawValue: Int) { + let height = BoardManPanel.clampedPanelHeight(rawValue) + heightStepper?.integerValue = height + heightLabel?.integerValue = height AppEnvironment.current.defaults.set(height, forKey: Constants.UserDefaults.boardManPanelHeight) var frame = self.frame frame.origin.y += frame.height - CGFloat(height) @@ -8424,8 +8918,36 @@ class BoardManPanel: NSPanel { layoutPanelSubviews() } + @objc private func panelHeightChanged(_ sender: NSStepper) { + applyPanelHeight(sender.integerValue) + } + + @objc private func panelHeightFieldChanged(_ sender: NSTextField) { + applyPanelHeight(sender.integerValue) + } + + @objc private func adjustPanelHeight(_ sender: NSButton) { + let current = heightLabel?.integerValue ?? BoardManPanel.clampedPanelHeight( + AppEnvironment.current.defaults.integer(forKey: Constants.UserDefaults.boardManPanelHeight) + ) + applyPanelHeight(current + (sender.tag * 40)) + } + @objc private func tabChanged(_ sender: NSSegmentedControl) { - activateTab(BoardManPanelTab(rawValue: sender.selectedSegment) ?? .history) + let tab: BoardManPanelTab = sender.selectedSegment == BoardManPanelTab.snippets.rawValue ? .snippets : .history + activateTab(tab) + } + + @objc private func settingsButtonPressed(_ sender: NSButton) { + activateTab(.settings) + } + + private func updateSettingsButtonAppearance() { + guard let button = settingsButton else { return } + button.state = activeTab == .settings ? .on : .off + if #available(macOS 10.14, *) { + button.contentTintColor = activeTab == .settings ? themeAccentColor : .secondaryLabelColor + } } private func activateTab(_ tab: BoardManPanelTab) { @@ -8438,7 +8960,11 @@ class BoardManPanel: NSPanel { _ = makeFirstResponder(nil) } activeTab = tab - segmentedControl?.selectedSegment = tab.rawValue + segmentedControl?.selectedSegment = tab == .settings ? -1 : tab.rawValue + if tab == .settings { + shouldScrollSettingsToTop = true + } + updateSettingsButtonAppearance() selectedIndex = -1 hoveredRow = -1 hidePreviewBubble() @@ -8461,6 +8987,8 @@ class BoardManPanel: NSPanel { @objc private func settingsCategoryButtonPressed(_ sender: NSButton) { activeSettingsCategory = BoardManInlineSettingsCategory(rawValue: sender.tag) ?? .general + shouldScrollSettingsToTop = true + _ = makeFirstResponder(nil) refreshSnippetSettingsSummary() refreshGlobalShortcutRows() updateSettingsSidebarSelection() @@ -8516,6 +9044,12 @@ class BoardManPanel: NSPanel { applyMaxHistorySize(sender.integerValue) } + @objc private func adjustMaxHistorySize(_ sender: NSButton) { + let current = maxHistorySizeValueLabel?.integerValue + ?? max(1, AppEnvironment.current.defaults.integer(forKey: Constants.UserDefaults.maxHistorySize)) + applyMaxHistorySize(current + (sender.tag * 10)) + } + @objc private func statusItemChanged(_ sender: NSPopUpButton) { let selectedRaw = sender.selectedItem?.representedObject as? String ?? sender.titleOfSelectedItem AppEnvironment.current.defaults.set(BoardManPanel.statusItemValue(for: selectedRaw), @@ -8792,6 +9326,159 @@ class BoardManPanel: NSPanel { ) } + private enum SavedFilterPopupCommand { + static let summary = "__boardman_saved_filter_summary__" + static let save = "__boardman_saved_filter_save__" + static let update = "__boardman_saved_filter_update__" + static let rename = "__boardman_saved_filter_rename__" + static let delete = "__boardman_saved_filter_delete__" + static let clear = "__boardman_saved_filter_clear__" + } + + private var availableSnippetGroupIdentifiers: Set { + let realm = try! Realm() + var identifiers = Set(realm.objects(CPYFolder.self).map(\.identifier)) + if allItems.contains(where: { $0.categoryIdentifier == BoardManPanel.uncategorizedCategoryIdentifier }) { + identifiers.insert(BoardManPanel.uncategorizedCategoryIdentifier) + } + return identifiers + } + + private func currentSavedFilterPreset(id: String, name: String) -> BoardManSavedFilterPreset { + return BoardManSavedFilterPreset( + id: id, + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + usageFilterRawValue: currentHistoryUsageFilter.rawValue, + condition: BoardManHistoryConditionStore.shared.condition(for: currentHistoryUsageFilter) ?? .empty, + snippetGroupIdentifiers: activeSnippetGroupIdentifiers.sorted() + ) + } + + private func reloadSavedFilterPopup() { + guard let popup = historySavedFilterPopup else { return } + let store = BoardManSavedFilterStore.shared + let selectedID = store.selectedPresetID + popup.removeAllItems() + + popup.addItem(withTitle: boardManText("Saved Filters")) + popup.lastItem?.representedObject = SavedFilterPopupCommand.summary + popup.lastItem?.isEnabled = false + + for preset in store.presets { + popup.addItem(withTitle: preset.name) + popup.lastItem?.representedObject = preset.id + } + if !store.presets.isEmpty { + popup.menu?.addItem(.separator()) + } + popup.addItem(withTitle: boardManText("Save Current Filter…")) + popup.lastItem?.representedObject = SavedFilterPopupCommand.save + if selectedID != nil { + popup.addItem(withTitle: boardManText("Update Selected Filter")) + popup.lastItem?.representedObject = SavedFilterPopupCommand.update + popup.addItem(withTitle: boardManText("Rename Selected Filter…")) + popup.lastItem?.representedObject = SavedFilterPopupCommand.rename + popup.addItem(withTitle: boardManText("Delete Selected Filter")) + popup.lastItem?.representedObject = SavedFilterPopupCommand.delete + popup.addItem(withTitle: boardManText("Clear Saved Filter")) + popup.lastItem?.representedObject = SavedFilterPopupCommand.clear + } + + if let selectedID, + let item = popup.itemArray.first(where: { ($0.representedObject as? String) == selectedID }) { + popup.select(item) + } else { + popup.selectItem(at: 0) + } + popup.toolTip = store.selectedPreset?.name ?? boardManText("Saved Filters") + } + + private func promptForSavedFilterName(title: String, initialName: String) -> String? { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = title + alert.addButton(withTitle: boardManText("Save")) + alert.addButton(withTitle: boardManText("Cancel")) + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 28)) + field.stringValue = initialName + field.placeholderString = boardManText("Filter name") + field.isEditable = true + field.isSelectable = true + alert.accessoryView = field + NSApp.activate(ignoringOtherApps: true) + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + let name = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? nil : name + } + + private func applySavedFilterPreset(_ preset: BoardManSavedFilterPreset) { + let usageFilter = preset.usageFilter + AppEnvironment.current.defaults.set( + usageFilter.rawValue, + forKey: Constants.UserDefaults.boardManHistoryUsageFilter + ) + historyUsageFilterControl?.selectedSegment = BoardManHistoryUsageFilter.allCases.firstIndex(of: usageFilter) ?? 0 + if preset.condition.hasCriteria { + BoardManHistoryConditionStore.shared.save(preset.condition, for: usageFilter) + } else { + BoardManHistoryConditionStore.shared.delete(for: usageFilter) + } + let validGroups = Set(preset.validSnippetGroupIdentifiers( + availableIdentifiers: availableSnippetGroupIdentifiers + )) + setActiveSnippetGroupIdentifiers(validGroups) + BoardManSavedFilterStore.shared.select(preset.id) + reloadSnippetCategoryPopup() + reloadSavedFilterPopup() + updateHistoryConditionButton() + selectedIndex = -1 + hoveredRow = -1 + hidePreviewBubble() + applyCurrentFilter() + } + + @objc private func savedFilterPopupChanged(_ sender: NSPopUpButton) { + guard let value = sender.selectedItem?.representedObject as? String else { + reloadSavedFilterPopup() + return + } + let store = BoardManSavedFilterStore.shared + if let preset = store.presets.first(where: { $0.id == value }) { + applySavedFilterPreset(preset) + return + } + switch value { + case SavedFilterPopupCommand.save: + guard let name = promptForSavedFilterName(title: boardManText("Save Filter"), initialName: "") else { + reloadSavedFilterPopup() + return + } + let preset = currentSavedFilterPreset(id: UUID().uuidString, name: name) + _ = store.save(preset) + case SavedFilterPopupCommand.update: + guard let selected = store.selectedPreset else { break } + _ = store.save(currentSavedFilterPreset(id: selected.id, name: selected.name)) + case SavedFilterPopupCommand.rename: + guard let selected = store.selectedPreset, + let name = promptForSavedFilterName(title: boardManText("Rename Filter"), initialName: selected.name) else { + reloadSavedFilterPopup() + return + } + var renamed = selected + renamed.name = name + _ = store.save(renamed) + case SavedFilterPopupCommand.delete: + if let selectedID = store.selectedPresetID { + store.delete(selectedID) + } + case SavedFilterPopupCommand.clear: + store.select(nil) + default: + break + } + reloadSavedFilterPopup() + } + @objc private func historyUsageFilterChanged(_ sender: NSSegmentedControl) { guard let filter = BoardManHistoryUsageFilter.allCases[safe: sender.selectedSegment] else { return } AppEnvironment.current.defaults.set(filter.rawValue, forKey: Constants.UserDefaults.boardManHistoryUsageFilter) @@ -9007,10 +9694,13 @@ class BoardManPanel: NSPanel { tabbedItems = pinnedClips + pinnedNonHistoryItems + regularHistory case .snippets: let snippetItems = allItems.filter { $0.source == .snippet } - if activeSnippetCategoryIdentifier == BoardManPanel.allCategoriesIdentifier { + if activeSnippetGroupIdentifiers.isEmpty { tabbedItems = snippetItems } else { - tabbedItems = snippetItems.filter { $0.categoryIdentifier == activeSnippetCategoryIdentifier } + tabbedItems = snippetItems.filter { item in + guard let identifier = item.categoryIdentifier else { return false } + return activeSnippetGroupIdentifiers.contains(identifier) + } } case .settings: tabbedItems = [] @@ -9444,13 +10134,13 @@ class BoardManPanel: NSPanel { realm.transaction { folder.snippets.append(snippet) } - activeSnippetCategoryIdentifier = folder.identifier + setActiveSnippetGroupIdentifiers([folder.identifier]) } else { snippet.index = realm.objects(CPYSnippet.self).count realm.transaction { realm.add(snippet) } - activeSnippetCategoryIdentifier = BoardManPanel.uncategorizedCategoryIdentifier + setActiveSnippetGroupIdentifiers([BoardManPanel.uncategorizedCategoryIdentifier]) } onRefreshRequested?() } @@ -9591,6 +10281,10 @@ class BoardManPanel: NSPanel { } override func scrollWheel(with event: NSEvent) { + if activeTab == .settings { + super.scrollWheel(with: event) + return + } let horizontalDelta = event.hasPreciseScrollingDeltas ? event.scrollingDeltaX : event.deltaX * 10 let verticalDelta = event.hasPreciseScrollingDeltas ? event.scrollingDeltaY : event.deltaY * 10 guard let delta = Self.tabDelta(horizontalDelta: horizontalDelta, verticalDelta: verticalDelta), @@ -9608,13 +10302,16 @@ class BoardManPanel: NSPanel { DispatchQueue.main.asyncAfter(deadline: .now() + 0.28, execute: reset) guard abs(horizontalScrollAccumulator) >= 26 else { return } horizontalScrollAccumulator = 0 - let nextRaw = min(BoardManPanelTab.settings.rawValue, max(BoardManPanelTab.history.rawValue, activeTab.rawValue + delta)) + let nextRaw = min(BoardManPanelTab.snippets.rawValue, max(BoardManPanelTab.history.rawValue, activeTab.rawValue + delta)) guard let nextTab = BoardManPanelTab(rawValue: nextRaw), nextTab != activeTab else { return } activateTab(nextTab) NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now) } override func sendEvent(_ event: NSEvent) { + if event.type == .leftMouseDown, activeTab == .settings { + endSettingsEditingIfNeeded(for: event) + } if event.type == .keyDown, (isUpArrow(event) || isDownArrow(event)) { if selectRowByKeyboard(delta: isDownArrow(event) ? 1 : -1) { @@ -9627,6 +10324,22 @@ class BoardManPanel: NSPanel { super.sendEvent(event) } + private func endSettingsEditingIfNeeded(for event: NSEvent) { + guard let editor = firstResponder as? NSTextView, + let activeField = [ + maxHistorySizeValueLabel, + timestampShortcutDelayField, + timedPinDurationValueLabel, + heightLabel, + hideRuleTextField + ].compactMap({ $0 }).first(where: { $0.currentEditor() === editor }), + let contentView else { return } + let point = contentView.convert(event.locationInWindow, from: nil) + guard let hitView = contentView.hitTest(point) else { return } + if hitView === activeField || hitView.isDescendant(of: activeField) { return } + _ = makeFirstResponder(nil) + } + override func performKeyEquivalent(with event: NSEvent) -> Bool { if shouldHandlePanelKey(event), handlePanelKey(event) { return true @@ -9736,7 +10449,7 @@ class BoardManPanel: NSPanel { } private func moveTab(delta: Int) { - let tabs = BoardManPanelTab.allCases + let tabs = [BoardManPanelTab.history, BoardManPanelTab.snippets] guard let currentIndex = tabs.firstIndex(of: activeTab) else { return } let nextIndex = min(tabs.count - 1, max(0, currentIndex + delta)) guard nextIndex != currentIndex else { return } @@ -10390,6 +11103,8 @@ extension BoardManPanel: NSTextFieldDelegate { timedPinDurationFieldChanged(field) } else if field === timestampShortcutDelayField { timestampShortcutDelayFieldChanged(field) + } else if field === heightLabel { + panelHeightFieldChanged(field) } else if field === snippetEditorTitleField, !isSnippetEditing { snippetTitleFieldChanged(field) } diff --git a/Clipy/Sources/Services/AccessibilityService.swift b/Clipy/Sources/Services/AccessibilityService.swift index af9cd9a..fa51916 100644 --- a/Clipy/Sources/Services/AccessibilityService.swift +++ b/Clipy/Sources/Services/AccessibilityService.swift @@ -48,7 +48,7 @@ extension AccessibilityService { } func showAccessibilityAuthenticationAlert() { - guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { + guard !BoardManRuntimeEnvironment.isRunningTests() else { NSLog("Board-Man permission alert suppressed reason=test_process") return } diff --git a/Clipy/Sources/Services/HotKeyService.swift b/Clipy/Sources/Services/HotKeyService.swift index 2c5a5c1..7c6ddbb 100644 --- a/Clipy/Sources/Services/HotKeyService.swift +++ b/Clipy/Sources/Services/HotKeyService.swift @@ -16,6 +16,33 @@ import Carbon import Magnet import RealmSwift +enum BoardManRuntimeEnvironment { + static func isRunningTests( + environment: [String: String] = ProcessInfo.processInfo.environment, + arguments: [String] = ProcessInfo.processInfo.arguments, + bundlePaths: [String] = Bundle.allBundles.map(\.bundlePath), + hasXCTestCase: Bool = NSClassFromString("XCTestCase") != nil + ) -> Bool { + let markers = ["xctest", "xcinject", "swift_testing"] + if environment.contains(where: { key, value in + markers.contains(where: { + key.localizedCaseInsensitiveContains($0) + || value.localizedCaseInsensitiveContains($0) + }) + }) { + return true + } + if arguments.contains(where: { argument in + markers.contains(where: { argument.localizedCaseInsensitiveContains($0) }) + }) { + return true + } + return hasXCTestCase || bundlePaths.contains(where: { + $0.localizedCaseInsensitiveContains(".xctest") + }) + } +} + final class HotKeyService: NSObject { // MARK: - Properties @@ -33,6 +60,7 @@ final class HotKeyService: NSObject { fileprivate(set) var snippetKeyCombo: KeyCombo? fileprivate(set) var clearHistoryKeyCombo: KeyCombo? fileprivate(set) var quickModeKeyCombo: KeyCombo? + private let defaults: UserDefaults private var globalMainHotKeyEventTap: CFMachPort? private var globalMainHotKeyRunLoopSource: CFRunLoopSource? private var globalMainHotKeyMonitor: Any? @@ -43,14 +71,27 @@ final class HotKeyService: NSObject { static let mainHotKeyInvocationDebounce: TimeInterval = 0.75 + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + super.init() + } + static func shouldAcceptMainHotKeyInvocation(now: CFAbsoluteTime, last: CFAbsoluteTime) -> Bool { return last == 0 || now - last > mainHotKeyInvocationDebounce } static func shouldRegisterSystemHotKeys( - environment: [String: String] = ProcessInfo.processInfo.environment + environment: [String: String] = ProcessInfo.processInfo.environment, + arguments: [String] = ProcessInfo.processInfo.arguments, + bundlePaths: [String] = Bundle.allBundles.map(\.bundlePath), + hasXCTestCase: Bool = NSClassFromString("XCTestCase") != nil ) -> Bool { - return environment["XCTestConfigurationFilePath"] == nil + return !BoardManRuntimeEnvironment.isRunningTests( + environment: environment, + arguments: arguments, + bundlePaths: bundlePaths, + hasXCTestCase: hasXCTestCase + ) } deinit { @@ -102,10 +143,10 @@ extension HotKeyService { extension HotKeyService { func setupDefaultHotKeys() { // Migration new framework - if !AppEnvironment.current.defaults.bool(forKey: Constants.HotKey.migrateNewKeyCombo) { + if !defaults.bool(forKey: Constants.HotKey.migrateNewKeyCombo) { migrationKeyCombos() - AppEnvironment.current.defaults.set(true, forKey: Constants.HotKey.migrateNewKeyCombo) - AppEnvironment.current.defaults.synchronize() + defaults.set(true, forKey: Constants.HotKey.migrateNewKeyCombo) + defaults.synchronize() } migrateLegacyMainShortcutIfNeeded() // Snippet hotkey @@ -142,8 +183,8 @@ extension HotKeyService { func changeClearHistoryKeyCombo(_ keyCombo: KeyCombo?) { clearHistoryKeyCombo = keyCombo - AppEnvironment.current.defaults.set(keyCombo?.archive(), forKey: Constants.HotKey.clearHistoryKeyCombo) - AppEnvironment.current.defaults.synchronize() + defaults.set(keyCombo?.archive(), forKey: Constants.HotKey.clearHistoryKeyCombo) + defaults.synchronize() guard Self.shouldRegisterSystemHotKeys() else { return } // Reset hotkey HotKeyCenter.shared.unregisterHotKey(with: "ClearHistory") @@ -155,8 +196,8 @@ extension HotKeyService { func changeQuickModeKeyCombo(_ keyCombo: KeyCombo?) { quickModeKeyCombo = keyCombo - AppEnvironment.current.defaults.set(keyCombo?.archive(), forKey: Constants.HotKey.quickModeKeyCombo) - AppEnvironment.current.defaults.synchronize() + defaults.set(keyCombo?.archive(), forKey: Constants.HotKey.quickModeKeyCombo) + defaults.synchronize() guard Self.shouldRegisterSystemHotKeys() else { return } HotKeyCenter.shared.unregisterHotKey(with: "QuickMode") guard let keyCombo else { return } @@ -170,7 +211,7 @@ extension HotKeyService { } private func savedKeyCombo(forKey key: String) -> KeyCombo? { - guard let data = AppEnvironment.current.defaults.object(forKey: key) as? Data else { return nil } + guard let data = defaults.object(forKey: key) as? Data else { return nil } guard let keyCombo = NSKeyedUnarchiver.unarchiveObject(with: data) as? KeyCombo else { return nil } return keyCombo } @@ -182,8 +223,8 @@ extension HotKeyService { ) else { return nil } - AppEnvironment.current.defaults.set(keyCombo.archive(), forKey: Constants.HotKey.mainKeyCombo) - AppEnvironment.current.defaults.synchronize() + defaults.set(keyCombo.archive(), forKey: Constants.HotKey.mainKeyCombo) + defaults.synchronize() NSLog("Board-Man main hotkey archive missing or invalid; restored default Command-Option-V") return keyCombo } @@ -192,7 +233,7 @@ extension HotKeyService { // MARK: - Global Main HotKey Fallback private extension HotKeyService { func setupGlobalMainHotKeyFallback() { - guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return } + guard Self.shouldRegisterSystemHotKeys() else { return } guard mainKeyCombo != nil, !mainCarbonHotKeyRegistered else { stopGlobalMainHotKeyFallback() return @@ -362,8 +403,8 @@ private extension HotKeyService { } func save(with type: MenuType, keyCombo: KeyCombo?) { - AppEnvironment.current.defaults.set(keyCombo?.archive(), forKey: type.userDefaultsKey) - AppEnvironment.current.defaults.synchronize() + defaults.set(keyCombo?.archive(), forKey: type.userDefaultsKey) + defaults.synchronize() } } @@ -374,24 +415,24 @@ private extension HotKeyService { * Changed framework, PTHotKey to Magnet */ func migrationKeyCombos() { - guard let keyCombos = AppEnvironment.current.defaults.object(forKey: Constants.UserDefaults.hotKeys) as? [String: Any] else { return } + guard let keyCombos = defaults.object(forKey: Constants.UserDefaults.hotKeys) as? [String: Any] else { return } // Main menu if let (keyCode, modifiers) = parse(with: keyCombos, forKey: Constants.Menu.clip) { if let keyCombo = KeyCombo(QWERTYKeyCode: keyCode, carbonModifiers: modifiers) { - AppEnvironment.current.defaults.set(keyCombo.archive(), forKey: Constants.HotKey.mainKeyCombo) + defaults.set(keyCombo.archive(), forKey: Constants.HotKey.mainKeyCombo) } } // History menu if let (keyCode, modifiers) = parse(with: keyCombos, forKey: Constants.Menu.history) { if let keyCombo = KeyCombo(QWERTYKeyCode: keyCode, carbonModifiers: modifiers) { - AppEnvironment.current.defaults.set(keyCombo.archive(), forKey: Constants.HotKey.historyKeyCombo) + defaults.set(keyCombo.archive(), forKey: Constants.HotKey.historyKeyCombo) } } // Snippet menu if let (keyCode, modifiers) = parse(with: keyCombos, forKey: Constants.Menu.snippet) { if let keyCombo = KeyCombo(QWERTYKeyCode: keyCode, carbonModifiers: modifiers) { - AppEnvironment.current.defaults.set(keyCombo.archive(), forKey: Constants.HotKey.snippetKeyCombo) + defaults.set(keyCombo.archive(), forKey: Constants.HotKey.snippetKeyCombo) } } } @@ -403,7 +444,6 @@ private extension HotKeyService { } func migrateLegacyMainShortcutIfNeeded() { - let defaults = AppEnvironment.current.defaults guard !defaults.bool(forKey: Constants.HotKey.migrateOpenBoardManCommandOptionV) else { return } defer { defaults.set(true, forKey: Constants.HotKey.migrateOpenBoardManCommandOptionV) @@ -428,16 +468,16 @@ private extension HotKeyService { extension HotKeyService { private var folderKeyCombos: [String: KeyCombo]? { get { - guard let data = AppEnvironment.current.defaults.object(forKey: Constants.HotKey.folderKeyCombos) as? Data else { return nil } + guard let data = defaults.object(forKey: Constants.HotKey.folderKeyCombos) as? Data else { return nil } return NSKeyedUnarchiver.unarchiveObject(with: data) as? [String: KeyCombo] } set { if let value = newValue { - AppEnvironment.current.defaults.set(NSKeyedArchiver.archivedData(withRootObject: value), forKey: Constants.HotKey.folderKeyCombos) + defaults.set(NSKeyedArchiver.archivedData(withRootObject: value), forKey: Constants.HotKey.folderKeyCombos) } else { - AppEnvironment.current.defaults.removeObject(forKey: Constants.HotKey.folderKeyCombos) + defaults.removeObject(forKey: Constants.HotKey.folderKeyCombos) } - AppEnvironment.current.defaults.synchronize() + defaults.synchronize() } } diff --git a/ClipyTests/EntitlementGateTests.swift b/ClipyTests/EntitlementGateTests.swift index a14733f..b360769 100644 --- a/ClipyTests/EntitlementGateTests.swift +++ b/ClipyTests/EntitlementGateTests.swift @@ -1212,6 +1212,52 @@ final class BoardManInteractionRuleTests { } +@MainActor @Suite(.serialized) +final class BoardManFilterSettingsTests { + + @Test + func savedFiltersPersistCriteriaSelectionAndValidGroups() throws { + let suiteName = "BoardManSavedFilterTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = BoardManSavedFilterStore(defaults: defaults) + let condition = BoardManHistoryCondition( + isEnabled: true, + minimumLength: 12, + includedTerms: ["deploy", "release"], + excludedTerms: ["draft"], + matchesAllIncludedTerms: false, + shellLikeOnly: true + ) + let preset = BoardManSavedFilterPreset( + id: "release-filter", + name: "Release commands", + usageFilterRawValue: BoardManHistoryUsageFilter.used.rawValue, + condition: condition, + snippetGroupIdentifiers: ["group-a", "deleted-group"] + ) + + #expect(store.presets.isEmpty) + #expect(preset.hasCriteria) + _ = store.save(preset) + #expect(store.presets == [preset]) + #expect(store.selectedPreset == preset) + #expect(store.selectedPreset?.usageFilter == .used) + #expect(preset.validSnippetGroupIdentifiers(availableIdentifiers: ["group-a", "group-b"]) == ["group-a"]) + + var renamed = preset + renamed.name = "Release only" + _ = store.save(renamed) + #expect(store.presets == [renamed]) + #expect(store.selectedPreset?.name == "Release only") + + store.delete(renamed.id) + #expect(store.presets.isEmpty) + #expect(store.selectedPresetID == nil) + } + +} + @MainActor @Suite(.serialized) final class BoardManPanelLayoutTests { @@ -1275,11 +1321,11 @@ final class BoardManPanelLayoutTests { #expect((titleField?.frame.width ?? 0) >= 250, "Snippet title editor is still cramped.") if let titleField, let statusLabel, let folderToggle, let snippetToggle { - #expect(titleField.frame.minY - statusLabel.frame.maxY >= 24, - "Snippet editor status needs a full spacing step below the title field.") - #expect(statusLabel.frame.minY - folderToggle.frame.maxY >= 14, - "Snippet editor status should keep visible breathing room above the enable controls.") + #expect(statusLabel.isHidden, + "The redundant snippet editor status band should remain removed.") #expect(abs(folderToggle.frame.midY - snippetToggle.frame.midY) <= 0.5) + #expect(folderToggle.frame.minY >= 12, + "Snippet enable controls need deliberate bottom padding.") #expect(titleField.frame.maxY <= (titleField.superview?.bounds.maxY ?? titleField.frame.maxY) - 12, "Snippet title needs deliberate top padding.") } @@ -1299,6 +1345,16 @@ final class BoardManPanelLayoutTests { .sorted { $0.tag < $1.tag } #expect(panel.presentationItemScope == .historyOnly) #expect(categories.count == expectedTitles.count, "Settings sidebar did not create all categories.") + let settingsScroll = allSubviews(of: root) + .compactMap { $0 as? NSScrollView } + .first { $0.identifier?.rawValue == "BoardManSettingsScrollView" } + #expect(settingsScroll?.isHidden == false, "Settings content scroll view is not visible.") + #expect((settingsScroll?.documentView?.frame.height ?? 0) >= (settingsScroll?.contentView.bounds.height ?? 0), + "Settings document is shorter than its viewport and cannot provide stable scrolling.") + if let documentView = settingsScroll?.documentView { + #expect(categories.allSatisfy { !$0.isDescendant(of: documentView) }, + "Settings sidebar categories must stay fixed outside the scrolling document.") + } for category in categories { #expect(category is BoardManSettingsCategoryButton, "Settings category is missing hover-aware button behavior.") @@ -1405,9 +1461,10 @@ final class BoardManUIRegressionTests { let tabs = try #require(descendants.compactMap { $0 as? BoardManHeaderSegmentedControl }.first) let tabFrameBeforeHover = tabs.frame let tabWidthsBeforeHover = (0..= tabs.bounds.minX) #expect(tabs.frame == tabFrameBeforeHover, "Hover must not resize or move the tab control.") @@ -1588,6 +1645,74 @@ final class BoardManUIRegressionTests { extension BoardManPanelLayoutTests { + @Test + func multipleSnippetGroupsFilterTogetherAndDeletedGroupsAreDiscarded() throws { + let originalRealmConfiguration = Realm.Configuration.defaultConfiguration + Realm.Configuration.defaultConfiguration = Realm.Configuration(inMemoryIdentifier: UUID().uuidString) + defer { Realm.Configuration.defaultConfiguration = originalRealmConfiguration } + + let realm = try Realm() + let firstFolder = CPYFolder() + firstFolder.title = "First" + firstFolder.enable = true + let secondFolder = CPYFolder() + secondFolder.title = "Second" + secondFolder.enable = true + try realm.write { + realm.add([firstFolder, secondFolder]) + } + let firstIdentifier = firstFolder.identifier + let secondIdentifier = secondFolder.identifier + + func snippetItem(hash: String, folder: CPYFolder) -> BoardManHistoryItem { + return BoardManHistoryItem( + title: hash, + primaryTitle: hash, + compactTitle: hash, + metadataText: folder.title, + timestampText: "", + countText: "", + previewTitle: hash, + dataHash: hash, + imageDataPath: "", + inlineThumbnail: nil, + pasteCount: 0, + isPinned: false, + isMasked: false, + isEnabled: true, + source: .snippet, + categoryIdentifier: folder.identifier, + categoryTitle: folder.title + ) + } + + let panel = BoardManPanel() + panel.openSnippetsManagerMode() + panel.loadItemsForTesting([ + snippetItem(hash: "first", folder: firstFolder), + snippetItem(hash: "second", folder: secondFolder) + ]) + panel.setSnippetGroupIdentifiersForTesting(Set([firstIdentifier, secondIdentifier])) + + #expect(panel.activeSnippetGroupIdentifiersForTesting == Set([firstIdentifier, secondIdentifier])) + #expect(panel.visibleItemHashesForTesting == ["first", "second"]) + let popup = try #require(panel.contentView.flatMap { root in + (root.subviews + root.subviews.flatMap { $0.subviews }) + .compactMap { $0 as? NSPopUpButton } + .first { $0.identifier?.rawValue == "BoardManSnippetGroupPopup" } + }) + #expect(popup.itemArray.first { ($0.representedObject as? String) == firstIdentifier }?.state == .on) + #expect(popup.itemArray.first { ($0.representedObject as? String) == secondIdentifier }?.state == .on) + + try realm.write { + realm.delete(secondFolder) + } + panel.reloadSnippetGroupsForTesting() + #expect(panel.activeSnippetGroupIdentifiersForTesting == Set([firstIdentifier])) + #expect(panel.visibleItemHashesForTesting == ["first"]) + #expect(!popup.itemArray.contains { ($0.representedObject as? String) == secondIdentifier }) + } + @Test func responsiveTemplateTabLabelsStayReadableAcrossLanguages() async throws { let originalRealmConfiguration = Realm.Configuration.defaultConfiguration @@ -1696,8 +1821,21 @@ extension BoardManPanelLayoutTests { Issue.record("Hover-aware header tabs were not created.") return } - tabs.updateHoveredSegment(at: NSPoint(x: tabs.bounds.midX, y: tabs.bounds.midY)) - #expect(tabs.hoveredSegment == 1, "Header hover tracking did not resolve the middle segment.") + #expect(tabs.segmentCount == 2, + "The header should expose only History and Templates as primary tabs.") + tabs.updateHoveredSegment(at: NSPoint(x: tabs.bounds.maxX - 4, y: tabs.bounds.midY)) + #expect(tabs.hoveredSegment == 1, "Header hover tracking did not resolve the trailing Templates segment.") + + let settingsButton = descendants.compactMap { $0 as? NSButton }.first { + $0.identifier?.rawValue == "BoardManSettingsButton" + } + #expect(settingsButton?.isHidden == false) + #expect(settingsButton?.target != nil && settingsButton?.action != nil, + "The header Settings gear is missing its action wiring.") + if let settingsButton { + #expect(abs(settingsButton.frame.midY - tabs.frame.midY) <= 0.5, + "The Settings gear is not vertically aligned with the primary tabs.") + } let search = descendants.compactMap { $0 as? NSSearchField }.first #expect((search != nil) == expectsSearch || search?.isHidden == !expectsSearch) @@ -1721,8 +1859,8 @@ extension BoardManPanelLayoutTests { "Search optical correction must stay at the requested 2pt downward offset.") #expect(abs((textRect.midY - search.bounds.midY) - cell.opticalYOffset) <= 0.5, "Search text does not match its optical vertical offset.") - #expect(cell.searchButtonOpticalYOffset == -1, - "Search icon requires a separate 1pt upward optical correction in flipped control coordinates.") + #expect(cell.searchButtonOpticalYOffset == -2, + "Search icon requires the requested 2pt upward optical correction in flipped control coordinates.") #expect(abs((iconRect.midY - search.bounds.midY) - (cell.opticalYOffset + cell.searchButtonOpticalYOffset)) <= 0.5, "Search icon does not include its optical vertical correction.") } @@ -1824,14 +1962,18 @@ private func assertSettingsCategoryControls(title: String, descendants: [NSView] if title == "General" { let field = descendants.first { $0.identifier?.rawValue == "BoardManVisibleHistoryField" } as? NSTextField let stepper = descendants.first { $0.identifier?.rawValue == "BoardManVisibleHistoryStepper" } as? NSStepper - #expect(field?.isHidden == false && field?.isEditable == true) + let decrease = descendants.first { $0.identifier?.rawValue == "BoardManVisibleHistoryDecreaseButton" } as? NSButton + let increase = descendants.first { $0.identifier?.rawValue == "BoardManVisibleHistoryIncreaseButton" } as? NSButton + #expect(field?.isHidden == false && field?.isEditable == true && field?.isBezeled == true) #expect(field?.target != nil && field?.action != nil) - #expect(stepper?.target != nil && stepper?.action != nil) - if let field, let stepper { - #expect(field.frame.minX < stepper.frame.minX, - "Visible history input should sit to the left of its stepper, matching Pin duration.") - #expect(abs(field.frame.midY - stepper.frame.midY) <= 0.5, - "Visible history input and stepper are vertically misaligned.") + #expect(stepper?.isHidden == true, "The native stepper should remain hidden behind the explicit minus/plus controls.") + #expect(decrease?.isHidden == false && decrease?.target != nil && decrease?.action != nil) + #expect(increase?.isHidden == false && increase?.target != nil && increase?.action != nil) + if let field, let stepper, let decrease, let increase { + #expect(decrease.frame.maxX < field.frame.minX) + #expect(field.frame.maxX < increase.frame.minX) + #expect(abs(field.frame.midY - decrease.frame.midY) <= 0.5) + #expect(abs(field.frame.midY - increase.frame.midY) <= 0.5) field.integerValue = 250 _ = field.sendAction(field.action, to: field.target) #expect(AppEnvironment.current.defaults.integer(forKey: Constants.UserDefaults.maxHistorySize) == 250) @@ -1859,6 +2001,8 @@ private func assertSettingsCategoryControls(title: String, descendants: [NSView] } let delay = descendants.first { $0.identifier?.rawValue == "BoardManTimestampShortcutDelayField" } as? NSTextField let delayStepper = descendants.first { $0.identifier?.rawValue == "BoardManTimestampShortcutDelayStepper" } as? NSStepper + let delayDecrease = descendants.first { $0.identifier?.rawValue == "BoardManTimestampShortcutDelayDecreaseButton" } as? NSButton + let delayIncrease = descendants.first { $0.identifier?.rawValue == "BoardManTimestampShortcutDelayIncreaseButton" } as? NSButton let shortcutRecord = descendants.first { $0.identifier?.rawValue == "BoardManTimestampShortcutRecordView" } let interaction = descendants.first { view in guard let popup = view as? NSPopUpButton else { return false } @@ -1870,11 +2014,16 @@ private func assertSettingsCategoryControls(title: String, descendants: [NSView] let pinLabel = descendants.compactMap { $0 as? NSTextField }.first { $0.stringValue == "Pin duration" } let field = descendants.first { $0.identifier?.rawValue == "BoardManTimedPinDurationField" } as? NSTextField let stepper = descendants.first { $0.identifier?.rawValue == "BoardManTimedPinDurationStepper" } as? NSStepper + let decrease = descendants.first { $0.identifier?.rawValue == "BoardManTimedPinDurationDecreaseButton" } as? NSButton + let increase = descendants.first { $0.identifier?.rawValue == "BoardManTimedPinDurationIncreaseButton" } as? NSButton let unit = descendants.compactMap { $0 as? NSPopUpButton }.first { Set(["Minutes", "Hours", "Days", "Weeks"]).isSubset(of: Set($0.itemTitles)) } #expect(toggle?.isHidden == false && toggle?.target != nil && toggle?.action != nil) - #expect(delay?.isHidden == false) + #expect(delay?.isHidden == false && delay?.isEditable == true && delay?.isBezeled == true) + #expect(delayStepper?.isHidden == true) + #expect(delayDecrease?.isHidden == false && delayDecrease?.target != nil && delayDecrease?.action != nil) + #expect(delayIncrease?.isHidden == false && delayIncrease?.target != nil && delayIncrease?.action != nil) if let shortcutLabel, let shortcutRecord { #expect(shortcutLabel.frame.minY > shortcutRecord.frame.maxY, "Narrow History settings should stack the shortcut label above its recorder.") @@ -1898,26 +2047,33 @@ private func assertSettingsCategoryControls(title: String, descendants: [NSView] _ = toggle.sendAction(toggle.action, to: toggle.target) #expect(delay?.isEnabled == true) #expect(delayStepper?.isEnabled == true) + #expect(delayDecrease?.isEnabled == true) + #expect(delayIncrease?.isEnabled == true) #expect(abs((shortcutRecord?.alphaValue ?? 0) - 1) <= 0.01, "The timestamp shortcut must remain editable and undimmed while disabled.") } #expect(preset?.isHidden == false && (preset?.numberOfItems ?? 0) >= 1) #expect(add?.target != nil && add?.action != nil) #expect(remove?.target != nil && remove?.action != nil) - #expect(field?.isEditable == true && field?.isSelectable == true && field?.isEnabled == true) + #expect(field?.isEditable == true && field?.isSelectable == true && field?.isEnabled == true && field?.isBezeled == true) #expect(field?.target != nil && field?.action != nil) - if let pinLabel, let preset, let add, let remove, let field, let stepper, let unit { + #expect(stepper?.isHidden == true) + #expect(decrease?.isHidden == false && decrease?.target != nil && decrease?.action != nil) + #expect(increase?.isHidden == false && increase?.target != nil && increase?.action != nil) + if let pinLabel, let preset, let add, let remove, let field, let decrease, let increase, let unit { #expect(pinLabel.frame.minY > preset.frame.maxY, "Pin duration label should sit above the preset control row.") #expect(abs(preset.frame.midY - add.frame.midY) <= 0.5) #expect(abs(preset.frame.midY - remove.frame.midY) <= 0.5) #expect(preset.frame.minY > field.frame.maxY, "Preset selection should be separated from the value and unit row.") - #expect(field.frame.minX < stepper.frame.minX, - "Pin duration input should remain on the left of its stepper.") - #expect(stepper.frame.maxX < unit.frame.minX, - "Pin duration unit should follow the numeric field and stepper.") - #expect(abs(field.frame.midY - stepper.frame.midY) <= 0.5) + #expect(decrease.frame.maxX < field.frame.minX, + "Pin duration minus button should remain on the left of its input.") + #expect(field.frame.maxX < increase.frame.minX) + #expect(increase.frame.maxX < unit.frame.minX, + "Pin duration unit should follow the explicit minus/plus controls.") + #expect(abs(field.frame.midY - decrease.frame.midY) <= 0.5) + #expect(abs(field.frame.midY - increase.frame.midY) <= 0.5) #expect(abs(field.frame.midY - unit.frame.midY) <= 0.5) } } else if title == "Snippets" { diff --git a/ClipyTests/HotKeyServiceTests.swift b/ClipyTests/HotKeyServiceTests.swift index 9c49cd3..3b1d471 100644 --- a/ClipyTests/HotKeyServiceTests.swift +++ b/ClipyTests/HotKeyServiceTests.swift @@ -4,42 +4,33 @@ import Magnet import Testing @testable import Board_Man -@Suite(.serialized) +@MainActor @Suite(.serialized) final class HotKeyServiceTests { + private let defaultsSuiteName = "BoardManHotKeyServiceTests" + private lazy var defaults = UserDefaults(suiteName: defaultsSuiteName) ?? .standard + init() { - let defaults = UserDefaults.standard - defaults.removeObject(forKey: Constants.UserDefaults.hotKeys) - defaults.removeObject(forKey: Constants.HotKey.migrateNewKeyCombo) - defaults.removeObject(forKey: Constants.HotKey.migrateOpenBoardManCommandOptionV) - defaults.removeObject(forKey: Constants.HotKey.mainKeyCombo) - defaults.removeObject(forKey: Constants.HotKey.historyKeyCombo) - defaults.removeObject(forKey: Constants.HotKey.snippetKeyCombo) - defaults.removeObject(forKey: Constants.HotKey.clearHistoryKeyCombo) - defaults.removeObject(forKey: Constants.HotKey.folderKeyCombos) - defaults.synchronize() + resetDefaults() } deinit { - let defaults = UserDefaults.standard - defaults.removeObject(forKey: Constants.UserDefaults.hotKeys) - defaults.removeObject(forKey: Constants.HotKey.migrateNewKeyCombo) - defaults.removeObject(forKey: Constants.HotKey.migrateOpenBoardManCommandOptionV) - defaults.removeObject(forKey: Constants.HotKey.mainKeyCombo) - defaults.removeObject(forKey: Constants.HotKey.historyKeyCombo) - defaults.removeObject(forKey: Constants.HotKey.snippetKeyCombo) - defaults.removeObject(forKey: Constants.HotKey.clearHistoryKeyCombo) - defaults.removeObject(forKey: Constants.HotKey.folderKeyCombos) + let cleanupDefaults = UserDefaults(suiteName: "BoardManHotKeyServiceTests") ?? .standard + cleanupDefaults.removePersistentDomain(forName: "BoardManHotKeyServiceTests") + cleanupDefaults.synchronize() + } + + private func resetDefaults() { + defaults.removePersistentDomain(forName: defaultsSuiteName) defaults.synchronize() } @Test func migrateDefaultSettings() throws { - let service = HotKeyService() + let service = HotKeyService(defaults: defaults) #expect(service.mainKeyCombo == nil) #expect(service.historyKeyCombo == nil) #expect(service.snippetKeyCombo == nil) - let defaults = UserDefaults.standard #expect(defaults.bool(forKey: Constants.HotKey.migrateNewKeyCombo) == false) service.setupDefaultHotKeys() #expect(defaults.bool(forKey: Constants.HotKey.migrateNewKeyCombo) == true) @@ -65,12 +56,11 @@ final class HotKeyServiceTests { @Test func migrateCustomizeSettings() throws { - let service = HotKeyService() + let service = HotKeyService(defaults: defaults) #expect(service.mainKeyCombo == nil) #expect(service.historyKeyCombo == nil) #expect(service.snippetKeyCombo == nil) - let defaults = UserDefaults.standard let defaultKeyCombos: [String: Any] = [Constants.Menu.clip: ["keyCode": 0, "modifiers": 4352], Constants.Menu.history: ["keyCode": 9, "modifiers": 768], Constants.Menu.snippet: ["keyCode": 11, "modifiers": 4352]] @@ -102,10 +92,9 @@ final class HotKeyServiceTests { @Test func saveKeyCombos() throws { - let defaults = UserDefaults.standard defaults.set(true, forKey: Constants.HotKey.migrateNewKeyCombo) - let service = HotKeyService() + let service = HotKeyService(defaults: defaults) #expect(service.mainKeyCombo == nil) #expect(service.historyKeyCombo == nil) #expect(service.snippetKeyCombo == nil) @@ -157,7 +146,6 @@ final class HotKeyServiceTests { @Test func unarchiveSavedKeyCombos() throws { - let defaults = UserDefaults.standard defaults.set(true, forKey: Constants.HotKey.migrateNewKeyCombo) defaults.set(true, forKey: Constants.HotKey.migrateOpenBoardManCommandOptionV) @@ -169,7 +157,7 @@ final class HotKeyServiceTests { defaults.setArchiveData(historyKeyCombo, forKey: Constants.HotKey.historyKeyCombo) defaults.setArchiveData(snippetKeyCombo, forKey: Constants.HotKey.snippetKeyCombo) - let service = HotKeyService() + let service = HotKeyService(defaults: defaults) #expect(service.mainKeyCombo == nil) #expect(service.historyKeyCombo == nil) #expect(service.snippetKeyCombo == nil) @@ -197,7 +185,6 @@ final class HotKeyServiceTests { @Test func migratesLegacyDefaultMainShortcutToCommandOptionV() throws { - let defaults = UserDefaults.standard defaults.set(true, forKey: Constants.HotKey.migrateNewKeyCombo) let legacy = try #require(KeyCombo( QWERTYKeyCode: 9, @@ -205,7 +192,7 @@ final class HotKeyServiceTests { )) defaults.setArchiveData(legacy, forKey: Constants.HotKey.mainKeyCombo) - let service = HotKeyService() + let service = HotKeyService(defaults: defaults) service.setupDefaultHotKeys() let migrated = try #require(service.mainKeyCombo) @@ -229,8 +216,30 @@ final class HotKeyServiceTests { ] #expect(!HotKeyService.shouldRegisterSystemHotKeys(environment: testEnvironment)) #expect(!AppDelegate.shouldStartRuntimeServices(environment: testEnvironment)) - #expect(HotKeyService.shouldRegisterSystemHotKeys(environment: [:])) - #expect(AppDelegate.shouldStartRuntimeServices(environment: [:])) + #expect(!HotKeyService.shouldRegisterSystemHotKeys( + environment: ["SWIFT_TESTING_ENABLED": "1"], + arguments: [], + bundlePaths: [], + hasXCTestCase: false + )) + #expect(!HotKeyService.shouldRegisterSystemHotKeys( + environment: [:], + arguments: ["/tmp/Board-Man.xctest"], + bundlePaths: [], + hasXCTestCase: false + )) + #expect(HotKeyService.shouldRegisterSystemHotKeys( + environment: [:], + arguments: [], + bundlePaths: [], + hasXCTestCase: false + )) + #expect(AppDelegate.shouldStartRuntimeServices( + environment: [:], + arguments: [], + bundlePaths: [], + hasXCTestCase: false + )) } @Test @@ -252,7 +261,7 @@ final class HotKeyServiceTests { @Test func addAndRemoveClearHistoryHotkey() throws { - let service = HotKeyService() + let service = HotKeyService(defaults: defaults) #expect(service.clearHistoryKeyCombo == nil) @@ -262,7 +271,6 @@ final class HotKeyServiceTests { #expect(service.clearHistoryKeyCombo != nil) #expect(service.clearHistoryKeyCombo == keyCombo) - let defaults = UserDefaults.standard let savedData = try #require(defaults.object(forKey: Constants.HotKey.clearHistoryKeyCombo) as? Data) let savedKeyCombo = try #require(NSKeyedUnarchiver.unarchiveObject(with: savedData) as? KeyCombo) #expect(savedKeyCombo == keyCombo) @@ -273,7 +281,7 @@ final class HotKeyServiceTests { @Test func setAndClearSnippetFolderHotkey() throws { - let service = HotKeyService() + let service = HotKeyService(defaults: defaults) let keyCombo = try #require(KeyCombo(QWERTYKeyCode: 1, carbonModifiers: cmdKey)) let folderIdentifier = "folder-1" @@ -282,7 +290,6 @@ final class HotKeyServiceTests { service.setSnippetKeyCombo(keyCombo, forFolder: folderIdentifier) #expect(service.keyComboForSnippetFolder(identifier: folderIdentifier) == keyCombo) - let defaults = UserDefaults.standard let savedData = try #require(defaults.object(forKey: Constants.HotKey.folderKeyCombos) as? Data) let savedCombos = try #require(NSKeyedUnarchiver.unarchiveObject(with: savedData) as? [String: KeyCombo]) #expect(savedCombos[folderIdentifier] == keyCombo)