diff --git a/Clipy/Sources/Managers/MenuManager.swift b/Clipy/Sources/Managers/MenuManager.swift index e984eeb..2605e37 100644 --- a/Clipy/Sources/Managers/MenuManager.swift +++ b/Clipy/Sources/Managers/MenuManager.swift @@ -4310,13 +4310,28 @@ class BoardManPanel: NSPanel { maxHistoryStepper.integerValue = max(1, AppEnvironment.current.defaults.integer(forKey: Constants.UserDefaults.maxHistorySize)) maxHistoryStepper.target = self maxHistoryStepper.action = #selector(maxHistorySizeChanged(_:)) + maxHistoryStepper.identifier = NSUserInterfaceItemIdentifier("BoardManVisibleHistoryStepper") contentView.addSubview(maxHistoryStepper) maxHistorySizeStepper = maxHistoryStepper - let maxHistoryValue = NSTextField(labelWithString: "\(maxHistoryStepper.integerValue)") + let maxHistoryValue = NSTextField(frame: .zero) + maxHistoryValue.cell = BoardManCenteredTextFieldCell(textCell: "\(maxHistoryStepper.integerValue)") maxHistoryValue.alignment = .right - maxHistoryValue.font = NSFont.systemFont(ofSize: 11) + maxHistoryValue.font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) maxHistoryValue.textColor = .labelColor + maxHistoryValue.isEditable = true + maxHistoryValue.isSelectable = true + maxHistoryValue.isEnabled = true + maxHistoryValue.target = self + maxHistoryValue.action = #selector(maxHistorySizeFieldChanged(_:)) + maxHistoryValue.delegate = self + let maxHistoryFormatter = NumberFormatter() + maxHistoryFormatter.numberStyle = .none + maxHistoryFormatter.minimum = 1 + maxHistoryFormatter.maximum = 1000 + maxHistoryFormatter.allowsFloats = false + maxHistoryValue.formatter = maxHistoryFormatter + maxHistoryValue.identifier = NSUserInterfaceItemIdentifier("BoardManVisibleHistoryField") contentView.addSubview(maxHistoryValue) maxHistorySizeValueLabel = maxHistoryValue @@ -5030,6 +5045,7 @@ class BoardManPanel: NSPanel { durationStepper.integerValue = selectedTimedPinPreset.value durationStepper.target = self durationStepper.action = #selector(timedPinDurationChanged(_:)) + durationStepper.identifier = NSUserInterfaceItemIdentifier("BoardManTimedPinDurationStepper") contentView.addSubview(durationStepper) timedPinDurationStepper = durationStepper @@ -6381,8 +6397,9 @@ class BoardManPanel: NSPanel { inputPasteCommandButton?.frame = NSRect(x: originX, y: originY - 70, width: width, height: 20) placeLabeledRow(label: languageLabel, control: languagePopup, originX: originX, originY: originY - 112, width: width) maxHistorySizeLabel?.frame = NSRect(x: originX, y: originY - 157, width: fieldLabelWidth, height: 16) - maxHistorySizeStepper?.frame = NSRect(x: originX + fieldLabelWidth + 12, y: originY - 164, width: 24, height: rowH) - maxHistorySizeValueLabel?.frame = NSRect(x: originX + fieldLabelWidth + 48, y: originY - 164, width: 82, height: rowH) + 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) placeLabeledRow(label: statusItemLabel, control: statusItemPopup, originX: originX, originY: originY - 204, width: width) } @@ -7882,16 +7899,24 @@ class BoardManPanel: NSPanel { AppEnvironment.current.defaults.set(sender.state == .on, forKey: Constants.UserDefaults.inputPasteCommand) } - @objc private func maxHistorySizeChanged(_ sender: NSStepper) { - let value = max(1, sender.integerValue) - sender.integerValue = value - maxHistorySizeValueLabel?.stringValue = "\(value)" + private func applyMaxHistorySize(_ rawValue: Int) { + let value = min(1000, max(1, rawValue)) + maxHistorySizeStepper?.integerValue = value + maxHistorySizeValueLabel?.integerValue = value AppEnvironment.current.defaults.set(value, forKey: Constants.UserDefaults.maxHistorySize) AppEnvironment.current.defaults.synchronize() AppEnvironment.current.dataCleanService.cleanDatas() onRefreshRequested?() } + @objc private func maxHistorySizeChanged(_ sender: NSStepper) { + applyMaxHistorySize(sender.integerValue) + } + + @objc private func maxHistorySizeFieldChanged(_ sender: NSTextField) { + applyMaxHistorySize(sender.integerValue) + } + @objc private func statusItemChanged(_ sender: NSPopUpButton) { let selectedRaw = sender.selectedItem?.representedObject as? String ?? sender.titleOfSelectedItem AppEnvironment.current.defaults.set(BoardManPanel.statusItemValue(for: selectedRaw), @@ -9596,7 +9621,9 @@ extension BoardManPanel: RecordViewDelegate { extension BoardManPanel: NSTextFieldDelegate { func controlTextDidEndEditing(_ notification: Notification) { guard let field = notification.object as? NSTextField else { return } - if field === timedPinDurationValueLabel { + if field === maxHistorySizeValueLabel { + maxHistorySizeFieldChanged(field) + } else if field === timedPinDurationValueLabel { timedPinDurationFieldChanged(field) } else if field === timestampShortcutDelayField { timestampShortcutDelayFieldChanged(field) diff --git a/ClipyTests/EntitlementGateTests.swift b/ClipyTests/EntitlementGateTests.swift index 3657e87..bee20ff 100644 --- a/ClipyTests/EntitlementGateTests.swift +++ b/ClipyTests/EntitlementGateTests.swift @@ -1037,13 +1037,20 @@ final class BoardManPanelLayoutTests { let originalTimestampFormat = defaults.string(forKey: Constants.UserDefaults.boardManTimestampFormat) let originalTimestampPosition = defaults.string(forKey: Constants.UserDefaults.boardManTimestampPosition) let originalLanguage = defaults.string(forKey: Constants.UserDefaults.boardManLanguage) + let originalMaxHistory = defaults.object(forKey: Constants.UserDefaults.maxHistorySize) defaults.set("relative", forKey: Constants.UserDefaults.boardManTimestampFormat) defaults.set("below", forKey: Constants.UserDefaults.boardManTimestampPosition) defaults.set("English", forKey: Constants.UserDefaults.boardManLanguage) + defaults.set(100, forKey: Constants.UserDefaults.maxHistorySize) defer { defaults.set(originalTimestampFormat, forKey: Constants.UserDefaults.boardManTimestampFormat) defaults.set(originalTimestampPosition, forKey: Constants.UserDefaults.boardManTimestampPosition) defaults.set(originalLanguage, forKey: Constants.UserDefaults.boardManLanguage) + if let originalMaxHistory { + defaults.set(originalMaxHistory, forKey: Constants.UserDefaults.maxHistorySize) + } else { + defaults.removeObject(forKey: Constants.UserDefaults.maxHistorySize) + } } let panel = BoardManPanel() @@ -1172,59 +1179,7 @@ final class BoardManPanelLayoutTests { await settlePanelLayout(panel) assertTopLevelLayout(panel, mode: "Settings category \(category.tag)", expectsSearch: false) - let descendants = allSubviews(of: root) - if category.title == "Appearance" { - let relativePopupIDs = [ - "BoardManRelativeNumberStylePopup", - "BoardManRelativeUnitStylePopup", - "BoardManRelativeSuffixStylePopup", - "BoardManRelativeNowStylePopup" - ] - for identifier in relativePopupIDs { - let popup = descendants.first { $0.identifier?.rawValue == identifier } as? NSPopUpButton - #expect(popup?.isHidden == false, "Missing visible relative timestamp popup: \(identifier)") - #expect(abs((popup?.frame.height ?? 0) - 30) <= 0.5) - } - } else if category.title == "History" { - let shortcutToggle = descendants.first { - $0.identifier?.rawValue == "BoardManTimestampShortcutEnabledButton" - } as? NSButton - let shortcutDelay = descendants.first { - $0.identifier?.rawValue == "BoardManTimestampShortcutDelayField" - } as? NSTextField - let presetPopup = descendants.first { - $0.identifier?.rawValue == "BoardManTimedPinPresetPopup" - } as? NSPopUpButton - let addPreset = descendants.first { - $0.identifier?.rawValue == "BoardManTimedPinPresetAddButton" - } as? NSButton - let removePreset = descendants.first { - $0.identifier?.rawValue == "BoardManTimedPinPresetRemoveButton" - } as? NSButton - #expect(shortcutToggle?.isHidden == false) - #expect(shortcutToggle?.target != nil && shortcutToggle?.action != nil) - #expect(shortcutDelay?.isHidden == false) - #expect(presetPopup?.isHidden == false) - #expect((presetPopup?.numberOfItems ?? 0) >= 1) - #expect(addPreset?.target != nil && addPreset?.action != nil) - #expect(removePreset?.target != nil && removePreset?.action != nil) - } else if category.title == "Snippets" { - let manage = descendants.first { - $0.identifier?.rawValue == "BoardManManageSnippetsButton" - } as? NSButton - let shortcutScroll = descendants.first { - $0.identifier?.rawValue == "BoardManSnippetShortcutScrollView" - } as? NSScrollView - #expect(manage?.isHidden == false) - #expect(shortcutScroll?.isHidden == false) - if let manage, let shortcutScroll { - let gap = shortcutScroll.frame.minY - manage.frame.maxY - #expect(gap >= 10 && gap <= 18, - "Manage Snippets should sit directly below the shortcut list with deliberate spacing.") - #expect(manage.frame.minY >= 20, - "Manage Snippets is still pinned against the bottom edge.") - } - } + assertSettingsCategoryControls(title: category.title, descendants: allSubviews(of: root)) } } @@ -1275,6 +1230,62 @@ final class BoardManPanelLayoutTests { #expect((historyTable?.enclosingScrollView?.frame.height ?? 999) < 190) } + 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) + #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.") + field.integerValue = 250 + _ = field.sendAction(field.action, to: field.target) + #expect(AppEnvironment.current.defaults.integer(forKey: Constants.UserDefaults.maxHistorySize) == 250) + #expect(stepper.integerValue == 250) + } + } else if title == "Appearance" { + let identifiers = ["BoardManRelativeNumberStylePopup", "BoardManRelativeUnitStylePopup", + "BoardManRelativeSuffixStylePopup", "BoardManRelativeNowStylePopup"] + for identifier in identifiers { + let popup = descendants.first { $0.identifier?.rawValue == identifier } as? NSPopUpButton + #expect(popup?.isHidden == false, "Missing visible relative timestamp popup: \(identifier)") + #expect(abs((popup?.frame.height ?? 0) - 30) <= 0.5) + } + } else if title == "History" { + let toggle = descendants.first { $0.identifier?.rawValue == "BoardManTimestampShortcutEnabledButton" } as? NSButton + let delay = descendants.first { $0.identifier?.rawValue == "BoardManTimestampShortcutDelayField" } as? NSTextField + let preset = descendants.first { $0.identifier?.rawValue == "BoardManTimedPinPresetPopup" } as? NSPopUpButton + let add = descendants.first { $0.identifier?.rawValue == "BoardManTimedPinPresetAddButton" } as? NSButton + let remove = descendants.first { $0.identifier?.rawValue == "BoardManTimedPinPresetRemoveButton" } as? NSButton + let field = descendants.first { $0.identifier?.rawValue == "BoardManTimedPinDurationField" } as? NSTextField + let stepper = descendants.first { $0.identifier?.rawValue == "BoardManTimedPinDurationStepper" } as? NSStepper + #expect(toggle?.isHidden == false && toggle?.target != nil && toggle?.action != nil) + #expect(delay?.isHidden == false) + #expect(preset?.isHidden == false && (preset?.numberOfItems ?? 0) >= 1) + #expect(add?.target != nil && add?.action != nil) + #expect(remove?.target != nil && remove?.action != nil) + if let field, let stepper { + #expect(field.frame.minX < stepper.frame.minX, + "Pin duration input should remain on the left of its stepper.") + #expect(abs(field.frame.midY - stepper.frame.midY) <= 0.5) + } + } else if title == "Snippets" { + let manage = descendants.first { $0.identifier?.rawValue == "BoardManManageSnippetsButton" } as? NSButton + let scroll = descendants.first { $0.identifier?.rawValue == "BoardManSnippetShortcutScrollView" } as? NSScrollView + #expect(manage?.isHidden == false && scroll?.isHidden == false) + if let manage, let scroll { + let gap = scroll.frame.minY - manage.frame.maxY + #expect(gap >= 10 && gap <= 18, + "Manage Snippets should sit directly below the shortcut list with deliberate spacing.") + #expect(manage.frame.minY >= 20, "Manage Snippets is still pinned against the bottom edge.") + } + } + } + private func settlePanelLayout(_ panel: BoardManPanel) async { await withCheckedContinuation { continuation in DispatchQueue.main.async {