diff --git a/Clipy/Sources/Constants.swift b/Clipy/Sources/Constants.swift index 13bb5ed..8c31e90 100644 --- a/Clipy/Sources/Constants.swift +++ b/Clipy/Sources/Constants.swift @@ -74,6 +74,7 @@ struct Constants { static let boardManShowRowNumbers = "BoardManShowRowNumbers" static let boardManHistoryUsageFilter = "BoardManHistoryUsageFilter" static let boardManTimestampFormat = "BoardManTimestampFormat" + static let boardManTimestampPosition = "BoardManTimestampPosition" static let boardManPanelHeight = "BoardManPanelHeight" static let boardManShowUsageCount = "BoardManShowUsageCount" static let boardManUsageCountStyle = "BoardManUsageCountStyle" diff --git a/Clipy/Sources/Managers/MenuManager.swift b/Clipy/Sources/Managers/MenuManager.swift index aafe793..4e2c0ad 100644 --- a/Clipy/Sources/Managers/MenuManager.swift +++ b/Clipy/Sources/Managers/MenuManager.swift @@ -145,16 +145,18 @@ extension MenuManager { fileprivate func handlePanelPaste(dataHash: String, clickStartedAt: CFAbsoluteTime?) { guard let panel = boardManPanel else { return } + let targetApplication = previousFrontmostApplication + guard let targetSnapshot = PasteCountInputService.shared.editableTargetSnapshot(for: targetApplication) else { + NSSound.beep() + panel.focusTableForKeyboard() + return + } - // V4B-15: close panel first, restore target app, then paste directly. - // Avoid dummy NSMenuItem + AppDelegate.selectClipMenuItem indirection. + // Close only after confirming that the original app still has an editable target. panel.orderOut(nil) + targetApplication?.activate(options: [.activateIgnoringOtherApps]) - if let prevApp = previousFrontmostApplication { - prevApp.activate(options: [.activateIgnoringOtherApps]) - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in guard let self else { return } if let clickStartedAt { @@ -169,14 +171,20 @@ extension MenuManager { return } - let didPaste = AppEnvironment.current.pasteService.paste(with: clip) + let pasteCountKey = PasteCountStore.shared.key(for: clip) + guard AppEnvironment.current.pasteService.paste(with: clip) else { + self.previousFrontmostApplication = nil + return + } - if didPaste { - let pasteCountKey = PasteCountStore.shared.key(for: clip) - PasteCountStore.shared.markUsed(clip: clip, in: realm) + PasteCountInputService.shared.confirmPasteChange(from: targetSnapshot) { confirmed in + guard confirmed else { return } + let confirmationRealm = try! Realm() + if let confirmedClip = confirmationRealm.object(ofType: CPYClip.self, forPrimaryKey: dataHash) { + PasteCountStore.shared.markUsed(clip: confirmedClip, in: confirmationRealm) + } PasteCountStore.shared.increment(forKey: pasteCountKey) } - self.previousFrontmostApplication = nil } } @@ -253,25 +261,26 @@ extension MenuManager { let clipped = firstLine.count > 120 ? String(firstLine.prefix(117)) + "..." : firstLine let pasteCount = PasteCountStore.shared.count(for: clip, in: pasteCounts) let isPinned = pinStore.isPinned(clip.dataHash) - var parts: [String] = [] + var contextParts: [String] = [] if showRowNumbers { - parts.append("\(index + 1).") - } - let timestamp = BoardManPanel.timestampText(for: clip.updateTime, format: timestampFormat) - if !timestamp.isEmpty { - parts.append(timestamp) + contextParts.append("\(index + 1).") } if isPinned { - parts.append("[PIN]") + contextParts.append("[PIN]") } if isImageClip { - parts.append("Image") + contextParts.append("Image") } + let timestamp = BoardManPanel.timestampText(for: clip.updateTime, format: timestampFormat) + let belowParts = contextParts + (timestamp.isEmpty ? [] : [timestamp]) + let compactTitle = (contextParts + [clipped]).joined(separator: " ") let countText = showUsageCount && pasteCount > 0 ? "\(pasteCount)" : "" - let displayTitle = (parts + [clipped]).joined(separator: " ") + let displayTitle = (belowParts + [clipped]).joined(separator: " ") return BoardManHistoryItem(title: displayTitle, primaryTitle: clipped, - metadataText: parts.joined(separator: " "), + compactTitle: compactTitle, + metadataText: belowParts.joined(separator: " "), + timestampText: timestamp, countText: countText, previewTitle: title, dataHash: clip.dataHash, @@ -307,7 +316,9 @@ extension MenuManager { let disabled = folder.enable && snippet.enable ? "" : " [OFF]" return BoardManHistoryItem(title: "\(pin)\(prefix) / \(title)", primaryTitle: title, + compactTitle: title, metadataText: "\(pin)\(prefix)\(disabled)", + timestampText: "", countText: "", previewTitle: snippet.content, dataHash: snippet.identifier, @@ -337,7 +348,9 @@ extension MenuManager { let disabled = snippet.enable ? "" : " [OFF]" return BoardManHistoryItem(title: "\(pin)Uncategorized / \(title)", primaryTitle: title, + compactTitle: title, metadataText: "\(pin)Uncategorized\(disabled)", + timestampText: "", countText: "", previewTitle: snippet.content, dataHash: snippet.identifier, @@ -1085,7 +1098,7 @@ fileprivate enum BoardManHistoryUsageFilter: String, CaseIterable { var toolTip: String { switch self { case .all: return "All — show every clipboard history item (default)." - case .unused: return "Unused — show items that have not been pasted yet." + case .unused: return "Unused — show unpasted items. While selected, Command+V pastes the next unused item in copy order." case .used: return "Used — show items pasted at least once." } } @@ -1099,28 +1112,60 @@ fileprivate enum BoardManHistoryUsageFilter: String, CaseIterable { } } -fileprivate enum BoardManFontChoice: String, CaseIterable { - case system = "System" - case rounded = "Rounded" - case serif = "Serif" - case monospaced = "Monospaced" +fileprivate enum BoardManTimestampPosition: String, CaseIterable { + case hidden = "Hidden" + case below = "Below" + case left = "Left" + case right = "Right" + + static func allowed(_ value: String?) -> BoardManTimestampPosition { + return allCases.first(where: { $0.rawValue.lowercased() == value?.lowercased() }) ?? .below + } +} + +fileprivate struct BoardManFontChoice: Equatable { + let rawValue: String + + static let system = BoardManFontChoice(rawValue: "System") + static let rounded = BoardManFontChoice(rawValue: "Rounded") + static let serif = BoardManFontChoice(rawValue: "Serif") + static let monospaced = BoardManFontChoice(rawValue: "Monospaced") + static let builtIns: [BoardManFontChoice] = [.system, .rounded, .serif, .monospaced] + + static var installedFamilies: [String] { + let builtInNames = Set(builtIns.map(\.rawValue)) + return NSFontManager.shared.availableFontFamilies + .filter { !builtInNames.contains($0) } + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } static func allowed(_ value: String?) -> BoardManFontChoice { - return allCases.first(where: { $0.rawValue == value }) ?? .system + guard let value, !value.isEmpty else { return .system } + if let builtIn = builtIns.first(where: { $0.rawValue == value }) { + return builtIn + } + return installedFamilies.contains(value) ? BoardManFontChoice(rawValue: value) : .system } func font(ofSize size: CGFloat, weight: NSFont.Weight = .regular) -> NSFont { let base = NSFont.systemFont(ofSize: size, weight: weight) - guard self != .system else { return base } - let design: NSFontDescriptor.SystemDesign - switch self { - case .rounded: design = .rounded - case .serif: design = .serif - case .monospaced: design = .monospaced - case .system: return base + switch rawValue { + case Self.system.rawValue: + return base + case Self.rounded.rawValue, Self.serif.rawValue, Self.monospaced.rawValue: + let design: NSFontDescriptor.SystemDesign = rawValue == Self.rounded.rawValue + ? .rounded + : (rawValue == Self.serif.rawValue ? .serif : .monospaced) + guard let descriptor = base.fontDescriptor.withDesign(design) else { return base } + return NSFont(descriptor: descriptor, size: size) ?? base + default: + let descriptor = NSFontDescriptor(fontAttributes: [ + .family: rawValue, + .traits: [NSFontDescriptor.TraitKey.weight: weight.rawValue] + ]) + return NSFont(descriptor: descriptor, size: size) + ?? NSFontManager.shared.convert(base, toFamily: rawValue) } - guard let descriptor = base.fontDescriptor.withDesign(design) else { return base } - return NSFont(descriptor: descriptor, size: size) ?? base } } @@ -1459,7 +1504,9 @@ fileprivate final class BoardManProLockedControlView: NSView { fileprivate struct BoardManHistoryItem { let title: String let primaryTitle: String + let compactTitle: String let metadataText: String + let timestampText: String let countText: String let previewTitle: String let dataHash: String @@ -1776,8 +1823,10 @@ final class BoardManCenteredSearchFieldCell: NSSearchFieldCell { final class BoardManHistoryCellView: NSTableCellView { private let primaryLabel = NSTextField(labelWithString: "") private let metadataLabel = NSTextField(labelWithString: "") + private let timestampAccessoryLabel = NSTextField(labelWithString: "") private let countBadge = NSTextField(labelWithString: "") private let inlineImageView = NSImageView(frame: .zero) + private var timestampPosition: BoardManTimestampPosition = .below override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -1803,6 +1852,18 @@ final class BoardManHistoryCellView: NSTableCellView { metadataLabel.drawsBackground = false metadataLabel.font = NSFont.systemFont(ofSize: 11.5, weight: .regular) + timestampAccessoryLabel.cell = BoardManCenteredTextFieldCell(textCell: "") + timestampAccessoryLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 10.5, weight: .medium) + timestampAccessoryLabel.textColor = .secondaryLabelColor + timestampAccessoryLabel.lineBreakMode = .byTruncatingTail + timestampAccessoryLabel.maximumNumberOfLines = 1 + timestampAccessoryLabel.cell?.usesSingleLineMode = true + timestampAccessoryLabel.cell?.wraps = false + timestampAccessoryLabel.isBordered = false + timestampAccessoryLabel.isEditable = false + timestampAccessoryLabel.backgroundColor = .clear + timestampAccessoryLabel.drawsBackground = false + countBadge.cell = BoardManCenteredTextFieldCell(textCell: "") countBadge.alignment = .center countBadge.lineBreakMode = .byTruncatingTail @@ -1823,7 +1884,7 @@ final class BoardManHistoryCellView: NSTableCellView { inlineImageView.layer?.masksToBounds = true inlineImageView.layer?.borderWidth = 1 - [primaryLabel, metadataLabel, countBadge].forEach { + [primaryLabel, metadataLabel, timestampAccessoryLabel, countBadge].forEach { $0.cell?.truncatesLastVisibleLine = true $0.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) } @@ -1831,6 +1892,7 @@ final class BoardManHistoryCellView: NSTableCellView { addSubview(primaryLabel) addSubview(metadataLabel) + addSubview(timestampAccessoryLabel) addSubview(inlineImageView) addSubview(countBadge) } @@ -1840,15 +1902,23 @@ final class BoardManHistoryCellView: NSTableCellView { usageStyle: String, useLiquidGlass: Bool, lightenTheme: Bool, - themePreset: BoardManThemePreset) { + themePreset: BoardManThemePreset, + timestampPosition requestedTimestampPosition: BoardManTimestampPosition) { let fontChoice = BoardManFontChoice.allowed( AppEnvironment.current.defaults.string(forKey: Constants.UserDefaults.boardManFontChoice) ) primaryLabel.font = fontChoice.font(ofSize: 13.5, weight: .medium) metadataLabel.font = fontChoice.font(ofSize: 11.5, weight: .regular) + timestampAccessoryLabel.font = fontChoice.font(ofSize: 10.5, weight: .medium) countBadge.font = fontChoice.font(ofSize: 10.5, weight: .medium) - primaryLabel.stringValue = item.primaryTitle + timestampPosition = requestedTimestampPosition + let usesCompactRow = timestampPosition != .below + primaryLabel.stringValue = usesCompactRow ? item.compactTitle : item.primaryTitle metadataLabel.stringValue = item.metadataText + metadataLabel.isHidden = usesCompactRow + timestampAccessoryLabel.stringValue = item.timestampText + timestampAccessoryLabel.isHidden = item.timestampText.isEmpty || timestampPosition == .hidden || timestampPosition == .below + timestampAccessoryLabel.alignment = timestampPosition == .right ? .right : .left let badgePrefix = usageStyle == "compact" ? "used " : "×" let shouldShowCount = item.pasteCount > 0 && !item.countText.isEmpty countBadge.stringValue = shouldShowCount ? "\(badgePrefix)\(item.countText)" : "" @@ -1859,6 +1929,7 @@ final class BoardManHistoryCellView: NSTableCellView { if isSelected { primaryLabel.textColor = .selectedMenuItemTextColor metadataLabel.textColor = NSColor.selectedMenuItemTextColor.withAlphaComponent(0.86) + timestampAccessoryLabel.textColor = NSColor.selectedMenuItemTextColor.withAlphaComponent(0.86) countBadge.textColor = .selectedMenuItemTextColor countBadge.layer?.backgroundColor = NSColor.selectedMenuItemTextColor.withAlphaComponent(useLiquidGlass ? 0.20 : 0.18).cgColor inlineImageView.layer?.backgroundColor = NSColor.selectedMenuItemTextColor.withAlphaComponent(0.10).cgColor @@ -1867,6 +1938,7 @@ final class BoardManHistoryCellView: NSTableCellView { let accentColor = themePreset.accentColor primaryLabel.textColor = .labelColor metadataLabel.textColor = useLiquidGlass ? NSColor.secondaryLabelColor.withAlphaComponent(0.92) : .secondaryLabelColor + timestampAccessoryLabel.textColor = useLiquidGlass ? NSColor.secondaryLabelColor.withAlphaComponent(0.92) : .secondaryLabelColor countBadge.textColor = .labelColor countBadge.layer?.backgroundColor = accentColor.withAlphaComponent(lightenTheme ? 0.10 : (useLiquidGlass ? 0.22 : 0.18)).cgColor inlineImageView.layer?.backgroundColor = themePreset.surfaceTintColor(useLiquidGlass: useLiquidGlass, lighten: lightenTheme).cgColor @@ -1882,7 +1954,7 @@ final class BoardManHistoryCellView: NSTableCellView { let trailingInset: CGFloat = 22 let badgeHeight: CGFloat = 20 let maxContentWidth = max(0, bounds.width - horizontalInset - trailingInset) - let badgeWidth = min(maxContentWidth, min(76, max(38, ceil(intrinsicWidth) + 20))) + let badgeWidth = min(maxContentWidth, max(56, min(64, ceil(intrinsicWidth) + 20))) return NSIntegralRect(NSRect( x: max(horizontalInset, floor(bounds.maxX - trailingInset - badgeWidth)), y: floor(bounds.midY - (badgeHeight / 2)), @@ -1899,42 +1971,81 @@ final class BoardManHistoryCellView: NSTableCellView { let titleHeight: CGFloat = 18 let metadataHeight: CGFloat = 15 let textGap: CGFloat = 4 - let textBlockHeight = titleHeight + textGap + metadataHeight - let textBottom = floor((bounds.height - textBlockHeight) / 2) - let badgeFrame = countBadge.isHidden - ? .zero - : Self.usageBadgeFrame(in: bounds, intrinsicWidth: countBadge.intrinsicContentSize.width) - let badgeWidth = badgeFrame.width - let badgeX = badgeFrame.minX - let imageSize: CGFloat = inlineImageView.isHidden ? 0 : 32 - let imageX = imageSize > 0 ? max(horizontalInset, badgeX - imageSize - accessoryGap) : 0 - let textRightLimit = imageSize > 0 - ? imageX - : (badgeWidth > 0 ? badgeX : bounds.width - trailingInset) - let textWidth = max(0, textRightLimit - horizontalInset - accessoryGap) - - metadataLabel.frame = NSIntegralRect(NSRect( - x: horizontalInset, - y: textBottom, - width: textWidth, - height: metadataHeight - )) - primaryLabel.frame = NSIntegralRect(NSRect( - x: horizontalInset, - y: textBottom + metadataHeight + textGap, - width: textWidth, - height: titleHeight - )) + let timeWidth: CGFloat = 84 + let countWidth: CGFloat = 64 + let accessoryHeight: CGFloat = 20 + var textLeft = horizontalInset + var textRight = bounds.width - trailingInset + + timestampAccessoryLabel.frame = .zero + countBadge.frame = .zero + inlineImageView.frame = .zero + + if !timestampAccessoryLabel.isHidden, timestampPosition == .left { + timestampAccessoryLabel.frame = NSIntegralRect(NSRect( + x: textLeft, + y: floor(bounds.midY - accessoryHeight / 2), + width: timeWidth, + height: accessoryHeight + )) + textLeft = timestampAccessoryLabel.frame.maxX + accessoryGap + } + + if !timestampAccessoryLabel.isHidden, timestampPosition == .right { + timestampAccessoryLabel.frame = NSIntegralRect(NSRect( + x: textRight - timeWidth, + y: floor(bounds.midY - accessoryHeight / 2), + width: timeWidth, + height: accessoryHeight + )) + textRight = timestampAccessoryLabel.frame.minX - accessoryGap + } + + if !countBadge.isHidden { + countBadge.frame = NSIntegralRect(NSRect( + x: max(textLeft, textRight - countWidth), + y: floor(bounds.midY - accessoryHeight / 2), + width: min(countWidth, max(0, textRight - textLeft)), + height: accessoryHeight + )) + textRight = countBadge.frame.minX - accessoryGap + } + if !inlineImageView.isHidden { + let imageSize: CGFloat = timestampPosition == .below ? 32 : 28 inlineImageView.frame = NSIntegralRect(NSRect( - x: imageX, + x: max(textLeft, textRight - imageSize), y: floor((bounds.height - imageSize) / 2), - width: imageSize, + width: min(imageSize, max(0, textRight - textLeft)), height: imageSize )) - } - if !countBadge.isHidden { - countBadge.frame = badgeFrame + textRight = inlineImageView.frame.minX - accessoryGap + } + + let textWidth = max(0, textRight - textLeft) + if timestampPosition == .below { + let textBlockHeight = titleHeight + textGap + metadataHeight + let textBottom = floor((bounds.height - textBlockHeight) / 2) + metadataLabel.frame = NSIntegralRect(NSRect( + x: textLeft, + y: textBottom, + width: textWidth, + height: metadataHeight + )) + primaryLabel.frame = NSIntegralRect(NSRect( + x: textLeft, + y: textBottom + metadataHeight + textGap, + width: textWidth, + height: titleHeight + )) + } else { + metadataLabel.frame = .zero + primaryLabel.frame = NSIntegralRect(NSRect( + x: textLeft, + y: floor(bounds.midY - titleHeight / 2), + width: textWidth, + height: titleHeight + )) } } } @@ -1954,6 +2065,7 @@ class BoardManPanel: NSPanel { static let cardCornerRadius: CGFloat = 14 static let sidebarSymbolLeadingInset: CGFloat = 8 static let historyRowHeight: CGFloat = 62 + static let compactHistoryRowHeight: CGFloat = 46 } private var glassBackgroundView: NSVisualEffectView? @@ -1971,6 +2083,8 @@ class BoardManPanel: NSPanel { private var rowNumbersButton: NSButton? private var timestampLabel: NSTextField? private var timestampPopup: NSPopUpButton? + private var timestampPositionLabel: NSTextField? + private var timestampPositionPopup: NSPopUpButton? private var usageCountButton: NSButton? private var usageStyleLabel: NSTextField? private var usageStylePopup: NSPopUpButton? @@ -2146,6 +2260,16 @@ class BoardManPanel: NSPanel { return BoardManFontChoice.allowed(AppEnvironment.current.defaults.string(forKey: Constants.UserDefaults.boardManFontChoice)) } + fileprivate var timestampPosition: BoardManTimestampPosition { + let format = BoardManPanel.allowedTimestampFormat( + AppEnvironment.current.defaults.string(forKey: Constants.UserDefaults.boardManTimestampFormat) + ) + if format == "none" { return .hidden } + return BoardManTimestampPosition.allowed( + AppEnvironment.current.defaults.string(forKey: Constants.UserDefaults.boardManTimestampPosition) + ) + } + fileprivate var selectedThemePreset: BoardManThemePreset { return BoardManThemePreset.allowed(AppEnvironment.current.defaults.string(forKey: Constants.UserDefaults.boardManThemePreset)) } @@ -2408,7 +2532,7 @@ class BoardManPanel: NSPanel { } let popups: [NSPopUpButton?] = [ - statusItemPopup, timestampPopup, usageStylePopup, usedItemStylePopup, + statusItemPopup, timestampPopup, timestampPositionPopup, usageStylePopup, usedItemStylePopup, themePresetPopup, appearanceModePopup, uiStylePopup, fontChoicePopup, hideRuleModePopup, snippetCategoryPopup ] @@ -2755,14 +2879,34 @@ class BoardManPanel: NSPanel { timestampLabel = timeText let popup = NSPopUpButton(frame: .zero, pullsDown: false) - popup.addItems(withTitles: ["Relative", "24-hour", "24-hour + seconds", "12-hour", "12-hour + seconds", "Date + time", "Hidden"]) - popup.selectItem(withTitle: BoardManPanel.timestampMenuTitle(for: AppEnvironment.current.defaults.string(forKey: Constants.UserDefaults.boardManTimestampFormat))) + popup.addItems(withTitles: ["Relative", "24-hour", "24-hour + seconds", "12-hour", "12-hour + seconds", "Date + time"]) + let storedTimestampTitle = BoardManPanel.timestampMenuTitle( + for: AppEnvironment.current.defaults.string(forKey: Constants.UserDefaults.boardManTimestampFormat) + ) + popup.selectItem(withTitle: storedTimestampTitle == "Hidden" ? "Relative" : storedTimestampTitle) popup.font = NSFont.systemFont(ofSize: 11) popup.target = self popup.action = #selector(timestampFormatChanged(_:)) + popup.toolTip = "時刻の表示形式を選択します。" contentView.addSubview(popup) timestampPopup = popup + let timePositionText = NSTextField(labelWithString: "位置") + timePositionText.font = NSFont.systemFont(ofSize: 11) + timePositionText.textColor = .labelColor + contentView.addSubview(timePositionText) + timestampPositionLabel = timePositionText + + let timePositionControl = NSPopUpButton(frame: .zero, pullsDown: false) + timePositionControl.addItems(withTitles: BoardManTimestampPosition.allCases.map(\.rawValue)) + timePositionControl.selectItem(withTitle: timestampPosition.rawValue) + timePositionControl.font = NSFont.systemFont(ofSize: 11) + timePositionControl.target = self + timePositionControl.action = #selector(timestampPositionChanged(_:)) + timePositionControl.toolTip = "Hiddenで1行表示、Left/Rightで固定幅の時刻列、Belowで2行表示にします。" + contentView.addSubview(timePositionControl) + timestampPositionPopup = timePositionControl + let usage = NSButton(checkboxWithTitle: "Count", target: self, action: #selector(usageCountChanged(_:))) usage.state = (AppEnvironment.current.defaults.object(forKey: Constants.UserDefaults.boardManShowUsageCount) as? Bool ?? true) ? .on : .off usage.font = NSFont.systemFont(ofSize: 11) @@ -2853,19 +2997,24 @@ class BoardManPanel: NSPanel { contentView.addSubview(uiStyleControl) uiStylePopup = uiStyleControl - let fontText = NSTextField(labelWithString: "Font") + let fontText = NSTextField(labelWithString: "Font / フォント") fontText.font = NSFont.systemFont(ofSize: 11) fontText.textColor = .labelColor contentView.addSubview(fontText) fontChoiceLabel = fontText let fontControl = NSPopUpButton(frame: .zero, pullsDown: false) - fontControl.addItems(withTitles: BoardManFontChoice.allCases.map { $0.rawValue }) + BoardManFontChoice.builtIns.forEach { fontControl.addItem(withTitle: $0.rawValue) } + fontControl.menu?.addItem(.separator()) + let installedHeader = NSMenuItem(title: "Installed Fonts / インストール済み", action: nil, keyEquivalent: "") + installedHeader.isEnabled = false + fontControl.menu?.addItem(installedHeader) + BoardManFontChoice.installedFamilies.forEach { fontControl.addItem(withTitle: $0) } fontControl.selectItem(withTitle: fontChoice.rawValue) fontControl.font = NSFont.systemFont(ofSize: 11) fontControl.target = self fontControl.action = #selector(fontChoiceChanged(_:)) - fontControl.toolTip = "Changes Board-Man panel typography immediately." + fontControl.toolTip = "Macにインストール済みのフォントを選択できます。日本語グリフはmacOSのフォールバックで補完します。" contentView.addSubview(fontControl) fontChoicePopup = fontControl @@ -3459,7 +3608,7 @@ class BoardManPanel: NSPanel { [generalSectionLabel, shortcutSectionLabel, viewSectionLabel, historySectionLabel, privacySectionLabel, storedTypesSectionLabel, filterSectionLabel, labsSectionLabel, snippetCategoryLabel, snippetEditorTitleLabel, snippetEditorContentLabel].forEach { label in label?.textColor = themePreset == .defaultPreset ? .labelColor : accentColor } - [maxHistorySizeLabel, statusItemLabel, themePresetLabel, appearanceModeLabel, uiStyleLabel, fontChoiceLabel, timestampLabel, usageStyleLabel, usedItemStyleLabel, heightControlLabel].forEach { label in + [maxHistorySizeLabel, statusItemLabel, themePresetLabel, appearanceModeLabel, uiStyleLabel, fontChoiceLabel, timestampLabel, timestampPositionLabel, usageStyleLabel, usedItemStyleLabel, heightControlLabel].forEach { label in label?.textColor = NSColor.labelColor.withAlphaComponent(useGlass ? 0.96 : 1) } globalShortcutRows.forEach { row in @@ -3764,7 +3913,7 @@ class BoardManPanel: NSPanel { settingsPageTitleLabel, settingsPageDescriptionLabel, generalSectionLabel, launchOnLoginButton, inputPasteCommandButton, maxHistorySizeLabel, maxHistorySizeStepper, maxHistorySizeValueLabel, statusItemLabel, statusItemPopup, shortcutSectionLabel, shortcutStatusLabel, snippetSettingsSectionLabel, snippetSummaryLabel, snippetFoldersLabel, snippetShortcutsLabel, snippetShortcutScrollView, manageSnippetsButton, - viewSectionLabel, rowNumbersButton, timestampLabel, timestampPopup, usageCountButton, usageStyleLabel, usageStylePopup, usedItemStyleLabel, usedItemStylePopup, themePresetLabel, themePresetPopup, appearanceModeLabel, appearanceModePopup, uiStyleLabel, uiStylePopup, fontChoiceLabel, fontChoicePopup, themeLightenButton, + viewSectionLabel, rowNumbersButton, timestampLabel, timestampPopup, timestampPositionLabel, timestampPositionPopup, usageCountButton, usageStyleLabel, usageStylePopup, usedItemStyleLabel, usedItemStylePopup, themePresetLabel, themePresetPopup, appearanceModeLabel, appearanceModePopup, uiStyleLabel, uiStylePopup, fontChoiceLabel, fontChoicePopup, themeLightenButton, historySectionLabel, dedupeButton, overwriteSameHistoryButton, reuseTopButton, clearHistoryButton, privacySectionLabel, excludedAppsButton, excludedAppsSummaryLabel, storedTypesSectionLabel, filterSectionLabel, hideRuleTextField, hideRuleModePopup, addHideRuleButton, removeLastHideRuleButton, clearHideRulesButton, hideRulesSummaryLabel, hideRulesExamplesLabel, hideRulesNoteLabel, @@ -3802,6 +3951,7 @@ class BoardManPanel: NSPanel { generalControls.append(contentsOf: globalShortcutRows.flatMap { $0.views }.map { Optional($0) }) let viewControls: [NSView?] = [ viewSectionLabel, rowNumbersButton, timestampLabel, timestampPopup, + timestampPositionLabel, timestampPositionPopup, usageCountButton, usageStyleLabel, usageStylePopup, usedItemStyleLabel, usedItemStylePopup, themePresetLabel, themePresetPopup, appearanceModeLabel, appearanceModePopup, uiStyleLabel, uiStylePopup, @@ -3874,7 +4024,10 @@ class BoardManPanel: NSPanel { func placeViewSection(originX: CGFloat, originY: CGFloat, width: CGFloat) { placeHeader(viewSectionLabel, originX: originX, originY: originY, width: width) rowNumbersButton?.frame = NSRect(x: originX, y: originY - 40, width: width, height: 20) - placeLabeledRow(label: timestampLabel, control: timestampPopup, originX: originX, originY: originY - 84, width: width) + let timeGap: CGFloat = 16 + let timeHalfWidth = max(180, floor((width - timeGap) / 2)) + placeLabeledRow(label: timestampLabel, control: timestampPopup, originX: originX, originY: originY - 84, width: timeHalfWidth) + placeLabeledRow(label: timestampPositionLabel, control: timestampPositionPopup, originX: originX + timeHalfWidth + timeGap, originY: originY - 84, width: timeHalfWidth, labelWidth: 42) usageCountButton?.frame = NSRect(x: originX, y: originY - 126, width: width, height: 20) let columnGap: CGFloat = 16 @@ -4069,6 +4222,18 @@ class BoardManPanel: NSPanel { onRefreshRequested?() } + @objc private func timestampPositionChanged(_ sender: NSPopUpButton) { + let position = BoardManTimestampPosition.allowed(sender.titleOfSelectedItem) + AppEnvironment.current.defaults.set(position.rawValue.lowercased(), + forKey: Constants.UserDefaults.boardManTimestampPosition) + if position != .hidden, + BoardManPanel.allowedTimestampFormat(AppEnvironment.current.defaults.string(forKey: Constants.UserDefaults.boardManTimestampFormat)) == "none" { + AppEnvironment.current.defaults.set("relative", forKey: Constants.UserDefaults.boardManTimestampFormat) + timestampPopup?.selectItem(withTitle: "Relative") + } + onRefreshRequested?() + } + @objc private func usageCountChanged(_ sender: NSButton) { AppEnvironment.current.defaults.set(sender.state == .on, forKey: Constants.UserDefaults.boardManShowUsageCount) onRefreshRequested?() @@ -5037,6 +5202,9 @@ class BoardManPanel: NSPanel { } else if selectedIndex >= historyItems.count { selectedIndex = historyItems.count - 1 } + placeholderList?.rowHeight = activeTab == .history && timestampPosition != .below + ? LayoutMetrics.compactHistoryRowHeight + : LayoutMetrics.historyRowHeight layoutPanelSubviews() placeholderList?.reloadData() synchronizeListGeometry() @@ -5775,11 +5943,15 @@ extension BoardManPanel: NSTableViewDataSource, NSTableViewDelegate { usageStyle: BoardManPanel.allowedUsageCountStyle(AppEnvironment.current.defaults.string(forKey: Constants.UserDefaults.boardManUsageCountStyle)), useLiquidGlass: isLiquidGlassEnabled, lightenTheme: isThemeLightenEnabled, - themePreset: themePreset) + themePreset: themePreset, + timestampPosition: activeTab == .history ? timestampPosition : .below) return cell } func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + if activeTab == .history, timestampPosition != .below { + return LayoutMetrics.compactHistoryRowHeight + } return LayoutMetrics.historyRowHeight } diff --git a/Clipy/Sources/Services/PasteCountInputService.swift b/Clipy/Sources/Services/PasteCountInputService.swift index c4246f6..a44f7dc 100644 --- a/Clipy/Sources/Services/PasteCountInputService.swift +++ b/Clipy/Sources/Services/PasteCountInputService.swift @@ -5,23 +5,152 @@ import AppKit import Carbon +import RealmSwift + +struct PasteTargetSnapshot { + let processIdentifier: pid_t + let role: String + let valueFingerprint: Int? + let selectedTextFingerprint: Int? + let selectedRange: CFRange? + let numberOfCharacters: Int? + let childrenCount: Int? +} -final class PasteCountInputService { - enum EventTapMode: Equatable { - case listenOnly - case accessibilityFallback - - var options: CGEventTapOptions { - switch self { - case .listenOnly: return .listenOnly - case .accessibilityFallback: return .defaultTap +enum PasteTargetVerifier { + private static let verificationDelays: [TimeInterval] = [0.16, 0.38, 0.70] + + static func snapshot(for application: NSRunningApplication?) -> PasteTargetSnapshot? { + guard AXIsProcessTrusted(), let application else { return nil } + let appElement = AXUIElementCreateApplication(application.processIdentifier) + var focusedObject: CFTypeRef? + guard AXUIElementCopyAttributeValue( + appElement, + kAXFocusedUIElementAttribute as CFString, + &focusedObject + ) == .success, + let focusedObject else { + return nil + } + + let focusedElement = focusedObject as! AXUIElement // swiftlint:disable:this force_cast + guard isEditable(element: focusedElement) else { return nil } + return snapshot(of: focusedElement, processIdentifier: application.processIdentifier) + } + + static func confirmChange(from snapshot: PasteTargetSnapshot, + delayIndex: Int = 0, + completion: @escaping (Bool) -> Void) { + guard delayIndex < verificationDelays.count else { + completion(false) + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + verificationDelays[delayIndex]) { + let application = NSRunningApplication(processIdentifier: snapshot.processIdentifier) + if let current = self.snapshot(for: application), changed(from: snapshot, to: current) { + completion(true) + return } + confirmChange(from: snapshot, delayIndex: delayIndex + 1, completion: completion) + } + } + + static func changed(from before: PasteTargetSnapshot, to after: PasteTargetSnapshot) -> Bool { + guard before.processIdentifier == after.processIdentifier else { return false } + if let lhs = before.valueFingerprint, let rhs = after.valueFingerprint, lhs != rhs { return true } + if let lhs = before.selectedTextFingerprint, let rhs = after.selectedTextFingerprint, lhs != rhs { return true } + if let lhs = before.selectedRange, let rhs = after.selectedRange, + lhs.location != rhs.location || lhs.length != rhs.length { return true } + if let lhs = before.numberOfCharacters, let rhs = after.numberOfCharacters, lhs != rhs { return true } + if let lhs = before.childrenCount, let rhs = after.childrenCount, lhs != rhs { return true } + return false + } + + private static func isEditable(element: AXUIElement) -> Bool { + let role = stringAttribute(kAXRoleAttribute, from: element) ?? "" + let subrole = stringAttribute(kAXSubroleAttribute, from: element) ?? "" + if boolAttribute("AXEditable", from: element) == true { return true } + let editableRoles: Set = [ + kAXTextFieldRole as String, + kAXTextAreaRole as String, + kAXComboBoxRole as String, + "AXEditableText" + ] + if editableRoles.contains(role) { return true } + return role == "AXWebArea" && subrole == "AXEditableWebArea" + } + + private static func snapshot(of element: AXUIElement, processIdentifier: pid_t) -> PasteTargetSnapshot { + return PasteTargetSnapshot( + processIdentifier: processIdentifier, + role: stringAttribute(kAXRoleAttribute, from: element) ?? "", + valueFingerprint: attributeFingerprint(kAXValueAttribute, from: element), + selectedTextFingerprint: attributeFingerprint(kAXSelectedTextAttribute, from: element), + selectedRange: rangeAttribute(kAXSelectedTextRangeAttribute, from: element), + numberOfCharacters: numberAttribute("AXNumberOfCharacters", from: element), + childrenCount: arrayCountAttribute(kAXChildrenAttribute, from: element) + ) + } + + private static func attributeFingerprint(_ attribute: String, from element: AXUIElement) -> Int? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, + let value else { return nil } + return String(describing: value).hashValue + } + + private static func rangeAttribute(_ attribute: String, from element: AXUIElement) -> CFRange? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, + let value, + CFGetTypeID(value) == AXValueGetTypeID() else { return nil } + let axValue = value as! AXValue // swiftlint:disable:this force_cast + guard AXValueGetType(axValue) == .cfRange else { return nil } + var range = CFRange() + return AXValueGetValue(axValue, .cfRange, &range) ? range : nil + } + + private static func numberAttribute(_ attribute: String, from element: AXUIElement) -> Int? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else { return nil } + return (value as? NSNumber)?.intValue + } + + private static func arrayCountAttribute(_ attribute: String, from element: AXUIElement) -> Int? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else { return nil } + return (value as? [Any])?.count + } + + private static func stringAttribute(_ attribute: String, from element: AXUIElement) -> String? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else { return nil } + return value as? String + } + + private static func boolAttribute(_ attribute: String, from element: AXUIElement) -> Bool? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else { return nil } + return value as? Bool + } +} + +enum PasteCountEventTapMode: Equatable { + case listenOnly + case accessibilityFallback + + var options: CGEventTapOptions { + switch self { + case .listenOnly: return .listenOnly + case .accessibilityFallback: return .defaultTap } } +} +final class PasteCountInputService { static let shared = PasteCountInputService() - static func eventTapMode(accessibilityTrusted: Bool, listenEventAccess: Bool) -> EventTapMode? { + static func eventTapMode(accessibilityTrusted: Bool, listenEventAccess: Bool) -> PasteCountEventTapMode? { if listenEventAccess { return .listenOnly } if accessibilityTrusted { return .accessibilityFallback } return nil @@ -39,6 +168,8 @@ final class PasteCountInputService { private var lastCountedIdentity: String? private var lastCountedAt = Date.distantPast private var lastDetectedAt = Date.distantPast + private let sequentialPasteLock = NSLock() + private var pendingSequentialHashes = Set() private let debounceInterval: TimeInterval = 0.45 private let duplicateDetectionInterval: TimeInterval = 0.12 private let boardManPasteSuppressionInterval: TimeInterval = 0.35 @@ -124,17 +255,6 @@ final class PasteCountInputService { appDidBecomeActiveObserver = nil } - func suppressNextGlobalPaste() { - suppressUntil = Date().addingTimeInterval(boardManPasteSuppressionInterval) - log("suppress next global paste") - } - - func logBoardManPerformance(_ name: String, startedAt: CFAbsoluteTime, details: String = "") { - let elapsedMs = (CFAbsoluteTimeGetCurrent() - startedAt) * 1000 - let suffix = details.isEmpty ? "" : " \(details)" - log(String(format: "perf %@ %.1fms%@", name, elapsedMs, suffix)) - } - private func startCGEventTap(reason: String) { if let eventTap { log("cg event tap already_active enabled=\(CGEvent.tapIsEnabled(tap: eventTap)) reason=\(reason)") @@ -304,11 +424,68 @@ final class PasteCountInputService { guard flags.contains(.maskCommand) else { return } guard !flags.contains(.maskControl), !flags.contains(.maskAlternate) else { return } + if prepareSequentialUnusedPasteIfNeeded() { + return + } DispatchQueue.main.async { [weak self] in self?.handleDetectedCommandV(source: "cgEventTap") } } + private func prepareSequentialUnusedPasteIfNeeded() -> Bool { + let defaults = AppEnvironment.current.defaults + guard defaults.string(forKey: Constants.UserDefaults.boardManHistoryUsageFilter) == "Unused" else { + return false + } + guard let targetApplication = NSWorkspace.shared.frontmostApplication, + targetApplication.bundleIdentifier != Bundle.main.bundleIdentifier, + let targetSnapshot = editableTargetSnapshot(for: targetApplication) else { + return false + } + + let realm = try! Realm() + let counts = PasteCountStore.shared.countsSnapshot() + sequentialPasteLock.lock() + let pending = pendingSequentialHashes + sequentialPasteLock.unlock() + let clips = realm.objects(CPYClip.self) + .sorted(byKeyPath: #keyPath(CPYClip.createdTime), ascending: false) + guard let clip = clips.first(where: { + !pending.contains($0.dataHash) && PasteCountStore.shared.count(for: $0, in: counts) == 0 + }) else { + log("unused sequence skipped reason=empty_queue") + return false + } + + let dataHash = clip.dataHash + let pasteCountKey = PasteCountStore.shared.key(for: clip) + sequentialPasteLock.lock() + pendingSequentialHashes.insert(dataHash) + sequentialPasteLock.unlock() + + AppEnvironment.current.pasteService.copyToPasteboard(with: clip) + suppressUntil = Date().addingTimeInterval(1.0) + log("unused sequence prepared hash=redacted") + + confirmPasteChange(from: targetSnapshot) { [weak self] confirmed in + guard let self else { return } + self.sequentialPasteLock.lock() + self.pendingSequentialHashes.remove(dataHash) + self.sequentialPasteLock.unlock() + guard confirmed else { + self.log("unused sequence confirmation failed") + return + } + let confirmationRealm = try! Realm() + if let confirmedClip = confirmationRealm.object(ofType: CPYClip.self, forPrimaryKey: dataHash) { + PasteCountStore.shared.markUsed(clip: confirmedClip, in: confirmationRealm) + } + PasteCountStore.shared.increment(forKey: pasteCountKey) + self.log("unused sequence confirmation success") + } + return true + } + private func handleDetectedCommandV(source: String) { let now = Date() guard now >= suppressUntil else { @@ -347,63 +524,13 @@ final class PasteCountInputService { guard AXIsProcessTrusted() else { return (false, "accessibility_untrusted") } - - guard let frontmostApplication = NSWorkspace.shared.frontmostApplication else { + guard let application = NSWorkspace.shared.frontmostApplication else { return (false, "frontmost_app_unavailable") } - - let appElement = AXUIElementCreateApplication(frontmostApplication.processIdentifier) - var focusedObject: CFTypeRef? - let focusedResult = AXUIElementCopyAttributeValue( - appElement, - kAXFocusedUIElementAttribute as CFString, - &focusedObject - ) - - guard focusedResult == .success, let focusedObject else { - return (false, "focused_element_unavailable") - } - - let focusedElement = focusedObject as! AXUIElement // swiftlint:disable:this force_cast - let role = stringAttribute(kAXRoleAttribute, from: focusedElement) ?? "" - let subrole = stringAttribute(kAXSubroleAttribute, from: focusedElement) ?? "" - let isEditable = boolAttribute("AXEditable", from: focusedElement) - - if isEditable == true { - return (true, "editable_attribute") - } - - let editableRoles: Set = [ - kAXTextFieldRole as String, - kAXTextAreaRole as String, - kAXComboBoxRole as String, - "AXEditableText" - ] - if editableRoles.contains(role) { - return (true, "editable_role_\(role)") + guard editableTargetSnapshot(for: application) != nil else { + return (false, "focused_element_not_editable") } - - if role == "AXWebArea", isEditable == true || subrole == "AXEditableWebArea" { - return (true, "editable_web_area") - } - - return (false, role.isEmpty ? "role_unavailable" : "non_editable_role_\(role)") - } - - private func stringAttribute(_ attribute: String, from element: AXUIElement) -> String? { - var value: CFTypeRef? - guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else { - return nil - } - return value as? String - } - - private func boolAttribute(_ attribute: String, from element: AXUIElement) -> Bool? { - var value: CFTypeRef? - guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else { - return nil - } - return value as? Bool + return (true, "editable_target") } private func countCurrentClipboardIfNeeded(source: String) { @@ -479,3 +606,34 @@ final class PasteCountInputService { } } } + +extension PasteCountInputService { + func editableTargetSnapshot( + for application: NSRunningApplication? = NSWorkspace.shared.frontmostApplication + ) -> PasteTargetSnapshot? { + return PasteTargetVerifier.snapshot(for: application) + } + + func confirmPasteChange(from snapshot: PasteTargetSnapshot, + completion: @escaping (Bool) -> Void) { + PasteTargetVerifier.confirmChange(from: snapshot, completion: completion) + } + + static func pasteTargetChanged(from before: PasteTargetSnapshot, + to after: PasteTargetSnapshot) -> Bool { + return PasteTargetVerifier.changed(from: before, to: after) + } + + func suppressNextGlobalPaste() { + suppressUntil = Date().addingTimeInterval(boardManPasteSuppressionInterval) + log("suppress next global paste") + } + + func logBoardManPerformance(_ name: String, + startedAt: CFAbsoluteTime, + details: String = "") { + let elapsedMs = (CFAbsoluteTimeGetCurrent() - startedAt) * 1000 + let suffix = details.isEmpty ? "" : " \(details)" + log(String(format: "perf %@ %.1fms%@", name, elapsedMs, suffix)) + } +} diff --git a/Clipy/Sources/Utility/CPYUtilities.swift b/Clipy/Sources/Utility/CPYUtilities.swift index f83bc54..2c29092 100644 --- a/Clipy/Sources/Utility/CPYUtilities.swift +++ b/Clipy/Sources/Utility/CPYUtilities.swift @@ -60,6 +60,7 @@ final class CPYUtilities { defaultValues.updateValue(NSNumber(value: true), forKey: Constants.UserDefaults.boardManShowRowNumbers) defaultValues.updateValue("All", forKey: Constants.UserDefaults.boardManHistoryUsageFilter) defaultValues.updateValue("relative", forKey: Constants.UserDefaults.boardManTimestampFormat) + defaultValues.updateValue("below", forKey: Constants.UserDefaults.boardManTimestampPosition) defaultValues.updateValue(NSNumber(value: 680), forKey: Constants.UserDefaults.boardManPanelHeight) defaultValues.updateValue(NSNumber(value: true), forKey: Constants.UserDefaults.boardManShowUsageCount) defaultValues.updateValue("badge", forKey: Constants.UserDefaults.boardManUsageCountStyle) diff --git a/ClipyTests/EntitlementGateTests.swift b/ClipyTests/EntitlementGateTests.swift index 31be465..8ac72c2 100644 --- a/ClipyTests/EntitlementGateTests.swift +++ b/ClipyTests/EntitlementGateTests.swift @@ -460,6 +460,42 @@ struct PasteCountInputServiceTests { .first?.dataHash == "newer") } + @Test + func pasteCountRequiresObservedEditableTargetChange() { + let unchanged = PasteTargetSnapshot( + processIdentifier: 100, + role: "AXTextArea", + valueFingerprint: 10, + selectedTextFingerprint: 20, + selectedRange: CFRange(location: 4, length: 0), + numberOfCharacters: 12, + childrenCount: 0 + ) + #expect(!PasteCountInputService.pasteTargetChanged(from: unchanged, to: unchanged)) + + let changedValue = PasteTargetSnapshot( + processIdentifier: 100, + role: "AXTextArea", + valueFingerprint: 11, + selectedTextFingerprint: 20, + selectedRange: CFRange(location: 4, length: 0), + numberOfCharacters: 13, + childrenCount: 0 + ) + #expect(PasteCountInputService.pasteTargetChanged(from: unchanged, to: changedValue)) + + let differentApplication = PasteTargetSnapshot( + processIdentifier: 200, + role: "AXTextArea", + valueFingerprint: 11, + selectedTextFingerprint: 21, + selectedRange: CFRange(location: 5, length: 0), + numberOfCharacters: 13, + childrenCount: 1 + ) + #expect(!PasteCountInputService.pasteTargetChanged(from: unchanged, to: differentApplication)) + } + @Test func imageFingerprintSurvivesArchiveRoundTripAndDistinguishesPixels() throws { let firstImage = testImage(color: .systemRed) @@ -495,6 +531,16 @@ final class BoardManPanelLayoutTests { Realm.Configuration.defaultConfiguration = Realm.Configuration(inMemoryIdentifier: UUID().uuidString) defer { Realm.Configuration.defaultConfiguration = originalRealmConfiguration } + let defaults = AppEnvironment.current.defaults + let originalTimestampFormat = defaults.string(forKey: Constants.UserDefaults.boardManTimestampFormat) + let originalTimestampPosition = defaults.string(forKey: Constants.UserDefaults.boardManTimestampPosition) + defaults.set("relative", forKey: Constants.UserDefaults.boardManTimestampFormat) + defaults.set("below", forKey: Constants.UserDefaults.boardManTimestampPosition) + defer { + defaults.set(originalTimestampFormat, forKey: Constants.UserDefaults.boardManTimestampFormat) + defaults.set(originalTimestampPosition, forKey: Constants.UserDefaults.boardManTimestampPosition) + } + let panel = BoardManPanel() panel.setFrame(NSRect(x: 0, y: 0, width: 680, height: 760), display: false) await settlePanelLayout(panel) @@ -552,8 +598,15 @@ final class BoardManPanelLayoutTests { "Appearance mode choices are missing.") #expect(popupTitles.contains(Set(["Default", "Simple", "Monochrome"])), "UI style choices are missing.") - #expect(popupTitles.contains(Set(["System", "Rounded", "Serif", "Monospaced"])), - "Font choices are missing.") + let builtInFonts = Set(["System", "Rounded", "Serif", "Monospaced"]) + #expect(popupTitles.contains { builtInFonts.isSubset(of: $0) }, + "Built-in font choices are missing from the installed-font picker.") + if let installedFamily = NSFontManager.shared.availableFontFamilies.first { + #expect(popupTitles.contains { $0.contains(installedFamily) }, + "Installed Finder font families are missing from the font picker.") + } + #expect(popupTitles.contains { Set(["Hidden", "Below", "Left", "Right"]).isSubset(of: $0) }, + "Timestamp position choices are missing.") #expect(popupTitles.contains { $0.contains("Scarlet") && $0.contains("Emerald") && $0.contains("Violet") }, "Expanded theme colors are missing.") #expect(popupTitles.contains { $0.contains("Teal") && $0.contains("Green") && $0.contains("Purple") && $0.contains("Indigo") },