diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift index 679e513f..584a996f 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift @@ -198,9 +198,25 @@ struct KeyboardSettingsPane: View { Divider() - SettingsControlRow(title: L10n.KeyboardVisualizer.axisLabel, subtitle: L10n.KeyboardVisualizer.axisSubtitle) { + SettingsControlRow( + title: L10n.KeyboardVisualizer.axisLabel, + subtitle: L10n.KeyboardVisualizer.axisSubtitle + ) { self.axisPicker } + + Divider() + + SettingsControlRow( + title: L10n.KeyboardVisualizer.collapseRepeatedGroupsLabel, + subtitle: L10n.KeyboardVisualizer.collapseRepeatedGroupsSubtitle + ) { + Toggle("", isOn: self.$model.collapseRepeatedGroups) + .labelsHidden() + .accessibilityLabel(L10n.KeyboardVisualizer.collapseRepeatedGroupsLabel) + .toggleStyle(.switch) + .controlSize(.small) + } } .disabled(!self.model.isEnabled) diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPaneViewModel.swift index 97d9edf6..f4a2681d 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPaneViewModel.swift @@ -108,6 +108,10 @@ final class KeyboardSettingsPaneViewModel: ObservableObject { didSet { self.settings.onlyShowModifiedKeystrokes = self.onlyShowModifiedKeystrokes } } + @Published var collapseRepeatedGroups: Bool { + didSet { self.settings.collapseRepeatedGroups = self.collapseRepeatedGroups } + } + @Published var showSpecialKeys: Bool { didSet { self.settings.showSpecialKeys = self.showSpecialKeys } } @@ -140,6 +144,7 @@ final class KeyboardSettingsPaneViewModel: ObservableObject { self.style = settings.style self.scale = Double(settings.scale) self.onlyShowModifiedKeystrokes = settings.onlyShowModifiedKeystrokes + self.collapseRepeatedGroups = settings.collapseRepeatedGroups self.showSpecialKeys = settings.showSpecialKeys self.showMediaKeyButtons = settings.showMediaKeyButtons self.showMouseEvents = settings.showMouseEvents @@ -162,6 +167,7 @@ final class KeyboardSettingsPaneViewModel: ObservableObject { "\(self.anchor.rawValue)", "\(self.maxCount)", "\(self.onlyShowModifiedKeystrokes)", + "\(self.collapseRepeatedGroups)", "\(self.showSpecialKeys)", "\(self.showMediaKeyButtons)", "\(self.showMouseEvents)", @@ -191,6 +197,7 @@ final class KeyboardSettingsPaneViewModel: ObservableObject { settings.scale = Self.previewScale settings.windowPadding = self.settings.windowPadding settings.onlyShowModifiedKeystrokes = self.onlyShowModifiedKeystrokes + settings.collapseRepeatedGroups = self.collapseRepeatedGroups settings.showSpecialKeys = self.showSpecialKeys settings.showMediaKeyButtons = self.showMediaKeyButtons settings.showMouseEvents = self.showMouseEvents diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift index e6e30e37..14b1b2fd 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift @@ -37,6 +37,7 @@ final class KeyboardVisualizer { private var currentModifierFlags: NSEvent.ModifierFlags = [] private var lastModifierFlags: NSEvent.ModifierFlags = [] private var hasPendingGroupBreak = false + private var lastFinalizedGroup: FinalizedGroup? convenience init() { self.init(store: UserDefaultsStore()) @@ -51,6 +52,7 @@ final class KeyboardVisualizer { self.visualizerWindow = KeyboardVisualizerWindow(settings: settings) self.visualizerWindow.onGroupRemoved = { [weak self] group in self?.eventCoordinator.removeGroup(group) + self?.clearFinalizedGroupIfNeeded(for: group) } settings.isEnabledChanges .sink { [weak self] isEnabled in @@ -62,7 +64,9 @@ final class KeyboardVisualizer { } .store(in: &self.cancellables) } +} +extension KeyboardVisualizer { func activate() { self.updatePresentationState() } @@ -100,13 +104,17 @@ final class KeyboardVisualizer { palette: self.visualizerSettings.palette ) let keycap = KeycapItemFactory.mouseItem(for: mouseEvent, palette: self.visualizerSettings.palette) - self.eventCoordinator.handleMouseButton( + let group = self.eventCoordinator.handleMouseButton( kind: mouseEvent.kind, isPressed: keycap.isPressed, items: modifierItems + [keycap], - appendGroup: { self.visualizerWindow.appendGroup(with: $0) }, + appendGroup: { self.visualizerWindow.appendGroup(with: $0, defersMaxCount: self.visualizerSettings.collapseRepeatedGroups) }, updateGroup: { group, items in self.visualizerWindow.updateGroup(group, with: items) } ) + self.collapseActiveRepeatIfNeeded(group) + if !keycap.isPressed, mouseEvent.modifierFlags.intersection(Self.trackedModifierFlags).isEmpty { + self.finalizeGroupIfNeeded(group) + } return case .mediaKey(let mediaKey): @@ -114,13 +122,17 @@ final class KeyboardVisualizer { self.prepareForNextContentEvent() let keycap = KeycapItemFactory.mediaKeyItem(for: mediaKey, palette: self.visualizerSettings.palette) - self.eventCoordinator.handleMediaKey( + let group = self.eventCoordinator.handleMediaKey( kind: mediaKey.kind, isPressed: keycap.isPressed, items: [keycap], - appendGroup: { self.visualizerWindow.appendGroup(with: $0) }, + appendGroup: { self.visualizerWindow.appendGroup(with: $0, defersMaxCount: self.visualizerSettings.collapseRepeatedGroups) }, updateGroup: { group, items in self.visualizerWindow.updateGroup(group, with: items) } ) + self.collapseActiveRepeatIfNeeded(group) + if !keycap.isPressed { + self.finalizeGroupIfNeeded(group) + } return case .keystroke(let keystroke): @@ -128,7 +140,10 @@ final class KeyboardVisualizer { return } } +} +// MARK: - Event Display +private extension KeyboardVisualizer { private func displayKeystroke(_ keystroke: StandardKeyEvent) { // Track command/shift/option/control exclusively through flagsChanged so a modifier // release does not create a separate keystroke group after the chord ends. @@ -155,13 +170,17 @@ final class KeyboardVisualizer { self.prepareForNextContentEvent() - self.eventCoordinator.handleTrackedKey( + let group = self.eventCoordinator.handleTrackedKey( keyCode: keystroke.keyCode, isKeyDown: keystroke.type != .keyUp, items: items, - appendGroup: { self.visualizerWindow.appendGroup(with: $0) }, + appendGroup: { self.visualizerWindow.appendGroup(with: $0, defersMaxCount: self.visualizerSettings.collapseRepeatedGroups) }, updateGroup: { group, items in self.visualizerWindow.updateGroup(group, with: items) } ) + self.collapseActiveRepeatIfNeeded(group) + if keystroke.type == .keyUp, keystroke.modifierFlags.intersection(Self.trackedModifierFlags).isEmpty { + self.finalizeGroupIfNeeded(group) + } } private func displayModifierPreview(_ modifierFlags: NSEvent.ModifierFlags) { @@ -174,7 +193,7 @@ final class KeyboardVisualizer { let capsNow = modifierFlags.contains(.capsLock) let capsWas = self.lastModifierFlags.contains(.capsLock) if self.visualizerSettings.showSpecialKeys, capsNow != capsWas { - self.eventCoordinator.handleStandalone( + let group = self.eventCoordinator.handleStandalone( items: [KeycapItem( identity: .keyCode(KeyboardKeyCode.capsLock.rawValue), legend: .capsLock, @@ -182,9 +201,11 @@ final class KeyboardVisualizer { layoutHints: KeycapLayoutHints(alignment: .left), appearance: self.visualizerSettings.palette.appearance(for: .keyCode(KeyboardKeyCode.capsLock.rawValue)) )], - appendGroup: { self.visualizerWindow.appendGroup(with: $0) }, + appendGroup: { self.visualizerWindow.appendGroup(with: $0, defersMaxCount: self.visualizerSettings.collapseRepeatedGroups) }, updateGroup: { group, items in self.visualizerWindow.updateGroup(group, with: items) } ) + self.collapseActiveRepeatIfNeeded(group) + self.finalizeGroupIfNeeded(group) } self.lastModifierFlags = modifierFlags @@ -201,7 +222,7 @@ final class KeyboardVisualizer { return } - self.eventCoordinator.handleFlagsChanged( + let group = self.eventCoordinator.handleFlagsChanged( currentTrackedFlags: currentTrackedFlags, releasedTrackedFlags: releasedTrackedFlags, buildItems: { _, _ in @@ -211,11 +232,18 @@ final class KeyboardVisualizer { palette: self.visualizerSettings.palette ) }, - appendGroup: { visualizerWindow.appendGroup(with: $0) }, + appendGroup: { visualizerWindow.appendGroup(with: $0, defersMaxCount: self.visualizerSettings.collapseRepeatedGroups) }, updateGroup: { group, items in visualizerWindow.updateGroup(group, with: items) } ) + self.collapseActiveRepeatIfNeeded(group) + if currentTrackedFlags.isEmpty { + self.finalizeGroupIfNeeded(group) + } } +} +// MARK: - State +private extension KeyboardVisualizer { private func prepareForNextContentEvent() { guard self.hasPendingGroupBreak else { return } self.finishCurrentGroup(retaining: self.currentModifierFlags) @@ -232,6 +260,7 @@ final class KeyboardVisualizer { self.currentModifierFlags = [] self.lastModifierFlags = [] self.hasPendingGroupBreak = false + self.lastFinalizedGroup = nil self.visualizerWindow.removeAllGroups() } @@ -249,6 +278,107 @@ final class KeyboardVisualizer { } } +// MARK: - Repeat Collapse Models +private extension KeyboardVisualizer { + /// Tracks the last completed visible group and its repeat count for collapse decisions. + struct FinalizedGroup { + let group: KeyboardVisualizerGroupView + let identity: GroupIdentity + let repeatCount: Int + } + + /// Semantic identity of a rendered group, used to detect consecutive repeats. + struct GroupIdentity: Hashable { + let items: [ItemIdentity] + } + + /// Stable comparison shape for a rendered keycap within a grouped overlay. + struct ItemIdentity: Hashable { + let identity: KeycapIdentity + let symbol: String + let imageBadgeText: String? + let sfSymbolName: String? + let label: String? + let rendersSymbolWithLabel: Bool + let fixedWidth: CGFloat? + } +} + +// MARK: - Repeat Collapse +private extension KeyboardVisualizer { + func finalizeGroupIfNeeded(_ group: KeyboardVisualizerGroupView?) { + guard let group else { return } + let items = self.eventCoordinator.items(for: group) + guard !items.isEmpty else { return } + + let identity = self.groupIdentity(for: items) + + guard self.visualizerSettings.collapseRepeatedGroups else { + self.lastFinalizedGroup = FinalizedGroup(group: group, identity: identity, repeatCount: 1) + self.visualizerWindow.enforceMaxCount() + return + } + + if let previous = self.lastFinalizedGroup, previous.group === group { + let repeatCount = previous.identity == identity ? previous.repeatCount : 1 + self.lastFinalizedGroup = FinalizedGroup(group: group, identity: identity, repeatCount: repeatCount) + self.visualizerWindow.enforceMaxCount() + return + } + + guard let previous = self.lastFinalizedGroup, + previous.group !== group, + previous.identity == identity else { + self.lastFinalizedGroup = FinalizedGroup(group: group, identity: identity, repeatCount: 1) + self.visualizerWindow.enforceMaxCount() + return + } + + let nextRepeatCount = previous.repeatCount + 1 + self.visualizerWindow.updateRepeatCount(nextRepeatCount, for: previous.group) + self.visualizerWindow.removeGroup(group) + self.lastFinalizedGroup = FinalizedGroup(group: previous.group, identity: identity, repeatCount: nextRepeatCount) + self.visualizerWindow.enforceMaxCount() + } + + func collapseActiveRepeatIfNeeded(_ group: KeyboardVisualizerGroupView?) { + guard self.visualizerSettings.collapseRepeatedGroups, let group else { return } + guard let previous = self.lastFinalizedGroup, previous.group !== group else { return } + + let items = self.eventCoordinator.items(for: group) + guard !items.isEmpty else { return } + + let identity = self.groupIdentity(for: items) + guard previous.identity == identity else { return } + + let nextRepeatCount = previous.repeatCount + 1 + self.visualizerWindow.updateGroup(previous.group, with: items, repeatCount: nextRepeatCount) + self.eventCoordinator.replaceGroup(group, with: previous.group, items: items) + self.visualizerWindow.removeGroup(group) + self.lastFinalizedGroup = FinalizedGroup(group: previous.group, identity: identity, repeatCount: nextRepeatCount) + self.visualizerWindow.enforceMaxCount() + } + + func clearFinalizedGroupIfNeeded(for group: KeyboardVisualizerGroupView) { + guard self.lastFinalizedGroup?.group === group else { return } + self.lastFinalizedGroup = nil + } + + func groupIdentity(for items: [KeycapItem]) -> GroupIdentity { + GroupIdentity(items: items.map { + ItemIdentity( + identity: $0.identity, + symbol: $0.symbol, + imageBadgeText: $0.imageBadgeText, + sfSymbolName: $0.sfSymbolName, + label: $0.label, + rendersSymbolWithLabel: $0.rendersSymbolWithLabel, + fixedWidth: $0.fixedWidth + ) + }) + } +} + // MARK: - Legend Resolution private extension KeyboardVisualizer { var legendResolver: EventLegendResolver { diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerGroupView.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerGroupView.swift index f81a32c5..25240b94 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerGroupView.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerGroupView.swift @@ -11,52 +11,17 @@ import AppKit final class KeyboardVisualizerGroupView: NSView { private var items: [KeycapItem] private var settings: KeyboardVisualizerSettings + private var repeatCount: Int private var fadeWorkItem: DispatchWorkItem? - /// Uniform scale applied to the keycap drawing. Combines the user-selected size with a - /// per-style normalization factor so every style renders at a comparable size. - private var scale: CGFloat { settings.style.sizeNormalization * max(0.1, settings.scale) } - - /// Group size in base (unscaled) drawing coordinates. - private var baseSize: NSSize { - let sizes = itemSizes() - let keysWidth = sizes.reduce(CGFloat(0)) { $0 + $1.width } - + groupSpacing * CGFloat(max(0, sizes.count - 1)) - let fallbackHeight: CGFloat - switch settings.style { - case .minimal: - fallbackHeight = MinimalKeycapMetrics.height - case .retro: - fallbackHeight = RetroKeycapMetrics.height - case .apple, .pbt: - fallbackHeight = AppleKeycapMetrics.height - } - let height = sizes.map(\.height).max() ?? fallbackHeight - return NSSize( - width: groupPadding.left + groupPadding.right + keysWidth, - height: groupPadding.top + groupPadding.bottom + height - ) - } - - var preferredSize: NSSize { - NSSize(width: baseSize.width * scale, height: baseSize.height * scale) - } - - override var intrinsicContentSize: NSSize { - self.preferredSize - } - - override var fittingSize: NSSize { - self.preferredSize - } - - override var mouseDownCanMoveWindow: Bool { - true - } - - init(items: [KeycapItem], settings: KeyboardVisualizerSettings = KeyboardVisualizerSettings()) { + init( + items: [KeycapItem], + settings: KeyboardVisualizerSettings = KeyboardVisualizerSettings(), + repeatCount: Int = 1 + ) { self.items = items self.settings = settings + self.repeatCount = max(1, repeatCount) super.init(frame: .zero) self.alphaValue = 1 self.setContentHuggingPriority(.required, for: .horizontal) @@ -68,18 +33,27 @@ final class KeyboardVisualizerGroupView: NSView { required init?(coder: NSCoder) { fatalError("Use init(items:) instead.") } +} - func configure(items: [KeycapItem], settings: KeyboardVisualizerSettings) { +extension KeyboardVisualizerGroupView { + func configure(items: [KeycapItem], settings: KeyboardVisualizerSettings, repeatCount: Int? = nil) { self.items = items self.settings = settings + if let repeatCount { + self.repeatCount = max(1, repeatCount) + } self.invalidateIntrinsicContentSize() self.needsDisplay = true } + var currentItems: [KeycapItem] { + self.items + } + func scheduleFadeOut(onCompletion completion: @escaping () -> Void) { - fadeWorkItem?.cancel() - let delay = max(0.1, TimeInterval(settings.fadeDelay)) - let duration = max(0, TimeInterval(settings.fadeDuration)) + self.fadeWorkItem?.cancel() + let delay = max(0.1, TimeInterval(self.settings.fadeDelay)) + let duration = max(0, TimeInterval(self.settings.fadeDuration)) let workItem = DispatchWorkItem { [weak self] in guard let self else { return } NSAnimationContext.runAnimationGroup { context in @@ -89,46 +63,97 @@ final class KeyboardVisualizerGroupView: NSView { completion() } } - fadeWorkItem = workItem + self.fadeWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } +} + +// MARK: - NSView +extension KeyboardVisualizerGroupView { + var preferredSize: NSSize { + NSSize(width: self.baseSize.width * self.scale, height: self.baseSize.height * self.scale) + } + + override var intrinsicContentSize: NSSize { + self.preferredSize + } + + override var fittingSize: NSSize { + self.preferredSize + } + + override var mouseDownCanMoveWindow: Bool { + true + } override func draw(_ dirtyRect: NSRect) { NSGraphicsContext.saveGraphicsState() let transform = NSAffineTransform() - transform.scale(by: scale) + transform.scale(by: self.scale) transform.concat() - drawBackground() + self.drawBackground() - let sizes = itemSizes() - var x = groupPadding.left - for (item, size) in zip(items, sizes) { + let sizes = self.itemSizes() + var x = self.groupPadding.left + for (item, size) in zip(self.items, sizes) { let rect = NSRect( x: x, - y: groupPadding.bottom, + y: self.groupPadding.bottom, width: size.width, height: size.height ) - KeycapRendererFactory.makeRenderer(for: item, settings: settings).draw( - context: KeycapContext(item: item, settings: settings), + KeycapRendererFactory.makeRenderer(for: item, settings: self.settings).draw( + context: KeycapContext(item: item, settings: self.settings), in: rect ) - x += size.width + groupSpacing + x += size.width + self.groupSpacing + } + + if self.repeatCount > 1 { + self.drawRepeatBadge() } NSGraphicsContext.restoreGraphicsState() } +} + +// MARK: - Layout +private extension KeyboardVisualizerGroupView { + /// Uniform scale applied to the keycap drawing. Combines the user-selected size with a + /// per-style normalization factor so every style renders at a comparable size. + var scale: CGFloat { self.settings.style.sizeNormalization * max(0.1, self.settings.scale) } + + /// Group size in base (unscaled) drawing coordinates. + var baseSize: NSSize { + let sizes = self.itemSizes() + let keysWidth = sizes.reduce(CGFloat(0)) { $0 + $1.width } + + self.groupSpacing * CGFloat(max(0, sizes.count - 1)) + let fallbackHeight: CGFloat + switch self.settings.style { + case .minimal: + fallbackHeight = MinimalKeycapMetrics.height + case .retro: + fallbackHeight = RetroKeycapMetrics.height + case .apple, .pbt: + fallbackHeight = AppleKeycapMetrics.height + } + let height = sizes.map(\.height).max() ?? fallbackHeight + return NSSize( + width: self.groupPadding.left + self.groupPadding.right + keysWidth, + height: self.groupPadding.top + self.groupPadding.bottom + height + ) + } private func itemSizes() -> [CGSize] { self.items.map { - KeycapRendererFactory.makeRenderer(for: $0, settings: settings) - .size(for: KeycapContext(item: $0, settings: settings)) + KeycapRendererFactory.makeRenderer(for: $0, settings: self.settings) + .size(for: KeycapContext(item: $0, settings: self.settings)) } } private var groupPadding: NSEdgeInsets { - switch settings.style { + switch self.settings.style { case .minimal: return MinimalKeycapMetrics.groupPadding case .retro: @@ -141,7 +166,7 @@ final class KeyboardVisualizerGroupView: NSView { } private var groupSpacing: CGFloat { - switch settings.style { + switch self.settings.style { case .minimal: return MinimalKeycapMetrics.itemSpacing case .retro: @@ -150,12 +175,15 @@ final class KeyboardVisualizerGroupView: NSView { return AppleKeycapMetrics.itemSpacing } } +} +// MARK: - Drawing +private extension KeyboardVisualizerGroupView { private func drawBackground() { - let baseBounds = NSRect(origin: .zero, size: baseSize) + let baseBounds = NSRect(origin: .zero, size: self.baseSize) let insetBounds = baseBounds.insetBy(dx: 1, dy: 1) let radius: CGFloat - switch settings.style { + switch self.settings.style { case .minimal: radius = insetBounds.height / 2 case .retro: @@ -164,11 +192,64 @@ final class KeyboardVisualizerGroupView: NSView { radius = 18 } let path = NSBezierPath(roundedRect: insetBounds, xRadius: radius, yRadius: radius) - let appearance = settings.groupAppearance + let appearance = self.settings.groupAppearance appearance.groupBackgroundColor.setFill() path.fill() appearance.groupStrokeColor.setStroke() path.lineWidth = StrokeWidth.standard path.stroke() } + + private func drawRepeatBadge() { + let badgeText = String(self.repeatCount) + let font = NSFont.systemFont(ofSize: 15, weight: .semibold) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + let appearance = self.settings.groupAppearance + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: appearance.badgeTextColor, + .paragraphStyle: paragraphStyle, + ] + let labelSize = badgeText.size(withAttributes: attributes) + let badgeHeight = max(26, labelSize.height + 8) + let badgeWidth = max(badgeHeight, labelSize.width + 14) + let badgeRect = NSRect( + x: self.baseSize.width - badgeWidth - 6, + y: self.baseSize.height - badgeHeight - 6, + width: badgeWidth, + height: badgeHeight + ) + + let badgePath = NSBezierPath( + roundedRect: badgeRect, + xRadius: badgeHeight / 2, + yRadius: badgeHeight / 2 + ) + appearance.badgeFillColor.setFill() + badgePath.fill() + appearance.badgeStrokeColor.setStroke() + badgePath.lineWidth = StrokeWidth.standard + badgePath.stroke() + + let highlightRect = badgeRect.insetBy(dx: 1, dy: 1) + let highlightPath = NSBezierPath( + roundedRect: highlightRect, + xRadius: highlightRect.height / 2, + yRadius: highlightRect.height / 2 + ) + appearance.badgeHighlightColor.setStroke() + highlightPath.lineWidth = 1 + highlightPath.stroke() + + badgeText.draw( + in: NSRect( + x: badgeRect.minX, + y: badgeRect.midY - labelSize.height / 2, + width: badgeRect.width, + height: labelSize.height + ), + withAttributes: attributes + ) + } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift index c279a319..3b9944cd 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift @@ -46,6 +46,7 @@ protocol KeyboardVisualizerSettingsProtocol: AnyObject { var windowPadding: CGFloat { get set } var style: KeycapStyle { get set } var onlyShowModifiedKeystrokes: Bool { get set } + var collapseRepeatedGroups: Bool { get set } var showSpecialKeys: Bool { get set } var showMediaKeyButtons: Bool { get set } var showMouseEvents: Bool { get set } @@ -314,6 +315,10 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS @Stored(.bool(KeyboardVisualizerSettingsKeys.onlyShowModifiedKeystrokes, default: false)) var onlyShowModifiedKeystrokes: Bool + /// Whether identical consecutive finalized groups should reuse the previous group and show a repeat badge. + @Stored(.bool(KeyboardVisualizerSettingsKeys.collapseRepeatedGroups, default: false)) + var collapseRepeatedGroups: Bool + /// Whether non-text keyboard keys (tab, return, arrows, fn, F-keys, etc.) should be rendered. @Stored(.bool(KeyboardVisualizerSettingsKeys.showSpecialKeys, default: true)) var showSpecialKeys: Bool diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettingsKeys.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettingsKeys.swift index 4489fe97..11bbf26e 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettingsKeys.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettingsKeys.swift @@ -37,6 +37,7 @@ enum KeyboardVisualizerSettingsKeys { static let windowPadding = "keyboard_visualizer.windowPadding" static let style = "keyboard_visualizer.style" static let onlyShowModifiedKeystrokes = "keyboard_visualizer.onlyShowModifiedKeystrokes" + static let collapseRepeatedGroups = "keyboard_visualizer.collapseRepeatedGroups" static let showSpecialKeys = "keyboard_visualizer.showSpecialKeys" static let showMediaKeyButtons = "keyboard_visualizer.showMediaKeyButtons" static let showMouseEvents = "keyboard_visualizer.showMouseEvents" diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerTheme.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerTheme.swift index c069f83f..313ad0aa 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerTheme.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerTheme.swift @@ -47,14 +47,38 @@ extension KeyboardVisualizerTheme { // MARK: - Tokens extension KeyboardVisualizerTheme { + private static func badgeTokens(swatchColor: NSColor, textColor: NSColor) -> (fill: NSColor, stroke: NSColor, highlight: NSColor, text: NSColor) { + let baseFill = swatchColor.relativeLuminance > 0.6 + ? swatchColor.darkened(by: 0.12) + : swatchColor.lightened(by: 0.08) + let stroke = baseFill.relativeLuminance > 0.5 + ? baseFill.darkened(by: 0.12) + : baseFill.lightened(by: 0.18) + + return ( + fill: baseFill.withAlphaComponent(0.96), + stroke: stroke.withAlphaComponent(0.56), + highlight: NSColor.white.withAlphaComponent(baseFill.relativeLuminance > 0.5 ? 0.12 : 0.18), + text: textColor + ) + } + var tokens: KeycapThemeTokens { switch self { case .white: + let textColor = NSColor(calibratedRed: 0.388, green: 0.384, blue: 0.365, alpha: 1) + let groupBackgroundColor = NSColor(white: 0.90, alpha: 0.92) + let swatchColor = NSColor(white: 0.96, alpha: 1) + let badge = Self.badgeTokens(swatchColor: swatchColor, textColor: textColor) return KeycapThemeTokens( - swatchColor: NSColor(white: 0.96, alpha: 1), - textColor: NSColor(calibratedRed: 0.388, green: 0.384, blue: 0.365, alpha: 1), - groupBackgroundColor: NSColor(white: 0.90, alpha: 0.92), + swatchColor: swatchColor, + textColor: textColor, + groupBackgroundColor: groupBackgroundColor, groupStrokeColor: NSColor(white: 0.75, alpha: 0.7), + badgeFillColor: badge.fill, + badgeStrokeColor: badge.stroke, + badgeHighlightColor: badge.highlight, + badgeTextColor: badge.text, surfaceHighlightColor: NSColor(white: 0.99, alpha: 1), surfaceBaseColor: NSColor(white: 0.94, alpha: 1), surfaceShadowColor: NSColor(white: 0.88, alpha: 1), @@ -64,11 +88,19 @@ extension KeyboardVisualizerTheme { undersideCenterColor: nil ) case .black: + let textColor = NSColor.white + let groupBackgroundColor = NSColor(white: 0.09, alpha: 0.95) + let swatchColor = NSColor(white: 0.08, alpha: 1) + let badge = Self.badgeTokens(swatchColor: swatchColor, textColor: textColor) return KeycapThemeTokens( - swatchColor: NSColor(white: 0.08, alpha: 1), - textColor: .white, - groupBackgroundColor: NSColor(white: 0.09, alpha: 0.95), + swatchColor: swatchColor, + textColor: textColor, + groupBackgroundColor: groupBackgroundColor, groupStrokeColor: NSColor(white: 0.24, alpha: 0.62), + badgeFillColor: badge.fill, + badgeStrokeColor: badge.stroke, + badgeHighlightColor: badge.highlight, + badgeTextColor: badge.text, surfaceHighlightColor: NSColor(white: 0.22, alpha: 1), surfaceBaseColor: NSColor(white: 0.035, alpha: 1), surfaceShadowColor: NSColor(white: 0.05, alpha: 1), @@ -78,11 +110,19 @@ extension KeyboardVisualizerTheme { undersideCenterColor: NSColor(white: 0.11, alpha: 1) ) case .citrus: + let textColor = NSColor(calibratedRed: 0.388, green: 0.384, blue: 0.365, alpha: 1) + let groupBackgroundColor = NSColor(calibratedRed: 0.804, green: 0.804, blue: 0.522, alpha: 0.9) + let swatchColor = NSColor(calibratedRed: 0.945, green: 0.937, blue: 0.749, alpha: 1) + let badge = Self.badgeTokens(swatchColor: swatchColor, textColor: textColor) return KeycapThemeTokens( - swatchColor: NSColor(calibratedRed: 0.945, green: 0.937, blue: 0.749, alpha: 1), - textColor: NSColor(calibratedRed: 0.388, green: 0.384, blue: 0.365, alpha: 1), - groupBackgroundColor: NSColor(calibratedRed: 0.804, green: 0.804, blue: 0.522, alpha: 0.9), + swatchColor: swatchColor, + textColor: textColor, + groupBackgroundColor: groupBackgroundColor, groupStrokeColor: NSColor(calibratedRed: 0.682, green: 0.675, blue: 0.400, alpha: 0.65), + badgeFillColor: badge.fill, + badgeStrokeColor: badge.stroke, + badgeHighlightColor: badge.highlight, + badgeTextColor: badge.text, surfaceHighlightColor: NSColor(calibratedRed: 0.980, green: 0.976, blue: 0.824, alpha: 1), surfaceBaseColor: NSColor(calibratedRed: 0.945, green: 0.937, blue: 0.749, alpha: 1), surfaceShadowColor: NSColor(calibratedRed: 0.902, green: 0.890, blue: 0.659, alpha: 1), @@ -92,11 +132,19 @@ extension KeyboardVisualizerTheme { undersideCenterColor: nil ) case .green: + let textColor = NSColor(calibratedWhite: 0.98, alpha: 1) + let groupBackgroundColor = NSColor(calibratedRed: 0.120, green: 0.145, blue: 0.060, alpha: 0.95) + let swatchColor = NSColor(calibratedRed: 0.470, green: 0.905, blue: 0.000, alpha: 1) + let badge = Self.badgeTokens(swatchColor: swatchColor, textColor: textColor) return KeycapThemeTokens( - swatchColor: NSColor(calibratedRed: 0.470, green: 0.905, blue: 0.000, alpha: 1), - textColor: NSColor(calibratedWhite: 0.98, alpha: 1), - groupBackgroundColor: NSColor(calibratedRed: 0.120, green: 0.145, blue: 0.060, alpha: 0.95), + swatchColor: swatchColor, + textColor: textColor, + groupBackgroundColor: groupBackgroundColor, groupStrokeColor: NSColor(calibratedRed: 0.360, green: 0.650, blue: 0.060, alpha: 0.60), + badgeFillColor: badge.fill, + badgeStrokeColor: badge.stroke, + badgeHighlightColor: badge.highlight, + badgeTextColor: badge.text, surfaceHighlightColor: NSColor(calibratedRed: 0.570, green: 0.965, blue: 0.060, alpha: 1), surfaceBaseColor: NSColor(calibratedRed: 0.460, green: 0.900, blue: 0.000, alpha: 1), surfaceShadowColor: NSColor(calibratedRed: 0.350, green: 0.720, blue: 0.000, alpha: 1), @@ -106,11 +154,19 @@ extension KeyboardVisualizerTheme { undersideCenterColor: nil ) case .blue: + let textColor = NSColor(calibratedWhite: 0.98, alpha: 1) + let groupBackgroundColor = NSColor(calibratedRed: 0.080, green: 0.100, blue: 0.165, alpha: 0.95) + let swatchColor = NSColor(calibratedRed: 0.220, green: 0.420, blue: 1.000, alpha: 1) + let badge = Self.badgeTokens(swatchColor: swatchColor, textColor: textColor) return KeycapThemeTokens( - swatchColor: NSColor(calibratedRed: 0.220, green: 0.420, blue: 1.000, alpha: 1), - textColor: NSColor(calibratedWhite: 0.98, alpha: 1), - groupBackgroundColor: NSColor(calibratedRed: 0.080, green: 0.100, blue: 0.165, alpha: 0.95), + swatchColor: swatchColor, + textColor: textColor, + groupBackgroundColor: groupBackgroundColor, groupStrokeColor: NSColor(calibratedRed: 0.245, green: 0.430, blue: 0.900, alpha: 0.62), + badgeFillColor: badge.fill, + badgeStrokeColor: badge.stroke, + badgeHighlightColor: badge.highlight, + badgeTextColor: badge.text, surfaceHighlightColor: NSColor(calibratedRed: 0.330, green: 0.570, blue: 1.000, alpha: 1), surfaceBaseColor: NSColor(calibratedRed: 0.230, green: 0.455, blue: 1.000, alpha: 1), surfaceShadowColor: NSColor(calibratedRed: 0.110, green: 0.315, blue: 0.940, alpha: 1), @@ -120,11 +176,19 @@ extension KeyboardVisualizerTheme { undersideCenterColor: nil ) case .indigo: + let textColor = NSColor(calibratedRed: 0.231, green: 0.247, blue: 0.267, alpha: 1) + let groupBackgroundColor = NSColor(calibratedRed: 0.220, green: 0.251, blue: 0.314, alpha: 0.92) + let swatchColor = NSColor(calibratedRed: 0.565, green: 0.596, blue: 0.690, alpha: 1) + let badge = Self.badgeTokens(swatchColor: swatchColor, textColor: textColor) return KeycapThemeTokens( - swatchColor: NSColor(calibratedRed: 0.565, green: 0.596, blue: 0.690, alpha: 1), - textColor: NSColor(calibratedRed: 0.231, green: 0.247, blue: 0.267, alpha: 1), - groupBackgroundColor: NSColor(calibratedRed: 0.220, green: 0.251, blue: 0.314, alpha: 0.92), + swatchColor: swatchColor, + textColor: textColor, + groupBackgroundColor: groupBackgroundColor, groupStrokeColor: NSColor(calibratedRed: 0.471, green: 0.518, blue: 0.631, alpha: 0.65), + badgeFillColor: badge.fill, + badgeStrokeColor: badge.stroke, + badgeHighlightColor: badge.highlight, + badgeTextColor: badge.text, surfaceHighlightColor: NSColor(calibratedRed: 0.624, green: 0.655, blue: 0.749, alpha: 1), surfaceBaseColor: NSColor(calibratedRed: 0.565, green: 0.596, blue: 0.690, alpha: 1), surfaceShadowColor: NSColor(calibratedRed: 0.490, green: 0.522, blue: 0.616, alpha: 1), @@ -134,11 +198,19 @@ extension KeyboardVisualizerTheme { undersideCenterColor: nil ) case .rose: + let textColor = NSColor(calibratedRed: 0.349, green: 0.337, blue: 0.333, alpha: 1) + let groupBackgroundColor = NSColor(calibratedRed: 0.761, green: 0.686, blue: 0.671, alpha: 0.9) + let swatchColor = NSColor(calibratedRed: 0.941, green: 0.878, blue: 0.878, alpha: 1) + let badge = Self.badgeTokens(swatchColor: swatchColor, textColor: textColor) return KeycapThemeTokens( - swatchColor: NSColor(calibratedRed: 0.941, green: 0.878, blue: 0.878, alpha: 1), - textColor: NSColor(calibratedRed: 0.349, green: 0.337, blue: 0.333, alpha: 1), - groupBackgroundColor: NSColor(calibratedRed: 0.761, green: 0.686, blue: 0.671, alpha: 0.9), + swatchColor: swatchColor, + textColor: textColor, + groupBackgroundColor: groupBackgroundColor, groupStrokeColor: NSColor(calibratedRed: 0.804, green: 0.725, blue: 0.729, alpha: 0.65), + badgeFillColor: badge.fill, + badgeStrokeColor: badge.stroke, + badgeHighlightColor: badge.highlight, + badgeTextColor: badge.text, surfaceHighlightColor: NSColor(calibratedRed: 0.973, green: 0.918, blue: 0.922, alpha: 1), surfaceBaseColor: NSColor(calibratedRed: 0.941, green: 0.878, blue: 0.878, alpha: 1), surfaceShadowColor: NSColor(calibratedRed: 0.894, green: 0.816, blue: 0.824, alpha: 1), @@ -148,11 +220,19 @@ extension KeyboardVisualizerTheme { undersideCenterColor: nil ) case .red: + let textColor = NSColor(calibratedWhite: 0.99, alpha: 1) + let groupBackgroundColor = NSColor(calibratedRed: 0.170, green: 0.085, blue: 0.075, alpha: 0.95) + let swatchColor = NSColor(calibratedRed: 1.000, green: 0.278, blue: 0.251, alpha: 1) + let badge = Self.badgeTokens(swatchColor: swatchColor, textColor: textColor) return KeycapThemeTokens( - swatchColor: NSColor(calibratedRed: 1.000, green: 0.278, blue: 0.251, alpha: 1), - textColor: NSColor(calibratedWhite: 0.99, alpha: 1), - groupBackgroundColor: NSColor(calibratedRed: 0.170, green: 0.085, blue: 0.075, alpha: 0.95), + swatchColor: swatchColor, + textColor: textColor, + groupBackgroundColor: groupBackgroundColor, groupStrokeColor: NSColor(calibratedRed: 0.500, green: 0.185, blue: 0.150, alpha: 0.62), + badgeFillColor: badge.fill, + badgeStrokeColor: badge.stroke, + badgeHighlightColor: badge.highlight, + badgeTextColor: badge.text, surfaceHighlightColor: NSColor(calibratedRed: 1.000, green: 0.390, blue: 0.355, alpha: 1), surfaceBaseColor: NSColor(calibratedRed: 1.000, green: 0.295, blue: 0.265, alpha: 1), surfaceShadowColor: NSColor(calibratedRed: 0.925, green: 0.215, blue: 0.200, alpha: 1), @@ -162,11 +242,19 @@ extension KeyboardVisualizerTheme { undersideCenterColor: nil ) case .purple: + let textColor = NSColor(calibratedWhite: 0.98, alpha: 1) + let groupBackgroundColor = NSColor(calibratedRed: 0.12, green: 0.01, blue: 0.25, alpha: 0.94) + let swatchColor = NSColor(calibratedRed: 0.36, green: 0.18, blue: 0.69, alpha: 1) + let badge = Self.badgeTokens(swatchColor: swatchColor, textColor: textColor) return KeycapThemeTokens( - swatchColor: NSColor(calibratedRed: 0.36, green: 0.18, blue: 0.69, alpha: 1), - textColor: NSColor(calibratedWhite: 0.98, alpha: 1), - groupBackgroundColor: NSColor(calibratedRed: 0.12, green: 0.01, blue: 0.25, alpha: 0.94), + swatchColor: swatchColor, + textColor: textColor, + groupBackgroundColor: groupBackgroundColor, groupStrokeColor: NSColor(calibratedRed: 0.25, green: 0.12, blue: 0.46, alpha: 0.72), + badgeFillColor: badge.fill, + badgeStrokeColor: badge.stroke, + badgeHighlightColor: badge.highlight, + badgeTextColor: badge.text, surfaceHighlightColor: NSColor(calibratedRed: 0.53, green: 0.31, blue: 0.86, alpha: 1), surfaceBaseColor: NSColor(calibratedRed: 0.45, green: 0.24, blue: 0.79, alpha: 1), surfaceShadowColor: NSColor(calibratedRed: 0.39, green: 0.20, blue: 0.73, alpha: 1), @@ -176,11 +264,19 @@ extension KeyboardVisualizerTheme { undersideCenterColor: nil ) case .yellow: + let textColor = NSColor(calibratedWhite: 0.99, alpha: 1) + let groupBackgroundColor = NSColor(calibratedRed: 0.160, green: 0.120, blue: 0.050, alpha: 0.95) + let swatchColor = NSColor(calibratedRed: 0.995, green: 0.724, blue: 0.192, alpha: 1) + let badge = Self.badgeTokens(swatchColor: swatchColor, textColor: textColor) return KeycapThemeTokens( - swatchColor: NSColor(calibratedRed: 0.995, green: 0.724, blue: 0.192, alpha: 1), - textColor: NSColor(calibratedWhite: 0.99, alpha: 1), - groupBackgroundColor: NSColor(calibratedRed: 0.160, green: 0.120, blue: 0.050, alpha: 0.95), + swatchColor: swatchColor, + textColor: textColor, + groupBackgroundColor: groupBackgroundColor, groupStrokeColor: NSColor(calibratedRed: 0.435, green: 0.322, blue: 0.112, alpha: 0.62), + badgeFillColor: badge.fill, + badgeStrokeColor: badge.stroke, + badgeHighlightColor: badge.highlight, + badgeTextColor: badge.text, surfaceHighlightColor: NSColor(calibratedRed: 1.000, green: 0.801, blue: 0.290, alpha: 1), surfaceBaseColor: NSColor(calibratedRed: 0.994, green: 0.714, blue: 0.182, alpha: 1), surfaceShadowColor: NSColor(calibratedRed: 0.925, green: 0.610, blue: 0.120, alpha: 1), @@ -190,11 +286,19 @@ extension KeyboardVisualizerTheme { undersideCenterColor: nil ) case .orange: + let textColor = NSColor(calibratedWhite: 0.99, alpha: 1) + let groupBackgroundColor = NSColor(calibratedRed: 0.175, green: 0.105, blue: 0.050, alpha: 0.95) + let swatchColor = NSColor(calibratedRed: 0.996, green: 0.446, blue: 0.120, alpha: 1) + let badge = Self.badgeTokens(swatchColor: swatchColor, textColor: textColor) return KeycapThemeTokens( - swatchColor: NSColor(calibratedRed: 0.996, green: 0.446, blue: 0.120, alpha: 1), - textColor: NSColor(calibratedWhite: 0.99, alpha: 1), - groupBackgroundColor: NSColor(calibratedRed: 0.175, green: 0.105, blue: 0.050, alpha: 0.95), + swatchColor: swatchColor, + textColor: textColor, + groupBackgroundColor: groupBackgroundColor, groupStrokeColor: NSColor(calibratedRed: 0.520, green: 0.255, blue: 0.095, alpha: 0.62), + badgeFillColor: badge.fill, + badgeStrokeColor: badge.stroke, + badgeHighlightColor: badge.highlight, + badgeTextColor: badge.text, surfaceHighlightColor: NSColor(calibratedRed: 1.000, green: 0.520, blue: 0.195, alpha: 1), surfaceBaseColor: NSColor(calibratedRed: 1.000, green: 0.455, blue: 0.135, alpha: 1), surfaceShadowColor: NSColor(calibratedRed: 0.925, green: 0.340, blue: 0.085, alpha: 1), @@ -204,11 +308,19 @@ extension KeyboardVisualizerTheme { undersideCenterColor: nil ) case .pink: + let textColor = NSColor(calibratedWhite: 0.99, alpha: 1) + let groupBackgroundColor = NSColor(calibratedRed: 0.160, green: 0.075, blue: 0.145, alpha: 0.95) + let swatchColor = NSColor(calibratedRed: 0.900, green: 0.205, blue: 0.785, alpha: 1) + let badge = Self.badgeTokens(swatchColor: swatchColor, textColor: textColor) return KeycapThemeTokens( - swatchColor: NSColor(calibratedRed: 0.900, green: 0.205, blue: 0.785, alpha: 1), - textColor: NSColor(calibratedWhite: 0.99, alpha: 1), - groupBackgroundColor: NSColor(calibratedRed: 0.160, green: 0.075, blue: 0.145, alpha: 0.95), + swatchColor: swatchColor, + textColor: textColor, + groupBackgroundColor: groupBackgroundColor, groupStrokeColor: NSColor(calibratedRed: 0.455, green: 0.165, blue: 0.395, alpha: 0.62), + badgeFillColor: badge.fill, + badgeStrokeColor: badge.stroke, + badgeHighlightColor: badge.highlight, + badgeTextColor: badge.text, surfaceHighlightColor: NSColor(calibratedRed: 0.935, green: 0.305, blue: 0.835, alpha: 1), surfaceBaseColor: NSColor(calibratedRed: 0.905, green: 0.225, blue: 0.800, alpha: 1), surfaceShadowColor: NSColor(calibratedRed: 0.820, green: 0.155, blue: 0.710, alpha: 1), @@ -228,12 +340,20 @@ extension KeyboardVisualizerTheme { guard let legendColorOverride else { return tokens } + let badge = Self.badgeTokens( + swatchColor: tokens.swatchColor, + textColor: legendColorOverride + ) return KeycapThemeTokens( swatchColor: tokens.swatchColor, textColor: legendColorOverride, groupBackgroundColor: tokens.groupBackgroundColor, groupStrokeColor: tokens.groupStrokeColor, + badgeFillColor: badge.fill, + badgeStrokeColor: badge.stroke, + badgeHighlightColor: badge.highlight, + badgeTextColor: badge.text, surfaceHighlightColor: tokens.surfaceHighlightColor, surfaceBaseColor: tokens.surfaceBaseColor, surfaceShadowColor: tokens.surfaceShadowColor, diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift index ff3ed376..30a47542 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift @@ -54,24 +54,42 @@ final class KeyboardVisualizerWindow: NSWindow { } @discardableResult - func appendGroup(with items: [KeycapItem]) -> KeyboardVisualizerGroupView { - let groupView = KeyboardVisualizerGroupView(items: items, settings: self.settings) + func appendGroup( + with items: [KeycapItem], + repeatCount: Int = 1, + defersMaxCount: Bool = false + ) -> KeyboardVisualizerGroupView { + let groupView = KeyboardVisualizerGroupView(items: items, settings: self.settings, repeatCount: repeatCount) self.rootView.addSubview(groupView) self.groupViews.append(groupView) - self.enforceMaxCount() + if !defersMaxCount { + self.enforceMaxCount() + } self.layoutGroups() self.scheduleFadeOut(for: groupView) return groupView } - func updateGroup(_ groupView: KeyboardVisualizerGroupView, with items: [KeycapItem]) { + func updateGroup(_ groupView: KeyboardVisualizerGroupView, with items: [KeycapItem], repeatCount: Int? = nil) { guard self.groupViews.contains(where: { $0 === groupView }) else { return } - groupView.configure(items: items, settings: self.settings) + groupView.configure(items: items, settings: self.settings, repeatCount: repeatCount) groupView.alphaValue = 1 self.layoutGroups() self.scheduleFadeOut(for: groupView) } + func updateRepeatCount(_ repeatCount: Int, for groupView: KeyboardVisualizerGroupView) { + self.updateGroup(groupView, with: groupView.currentItems, repeatCount: repeatCount) + } + + func removeGroup(_ groupView: KeyboardVisualizerGroupView) { + guard self.groupViews.contains(where: { $0 === groupView }) else { return } + self.groupViews.removeAll { $0 === groupView } + groupView.removeFromSuperview() + self.onGroupRemoved?(groupView) + self.layoutGroups() + } + func removeAllGroups() { let removedGroups = self.groupViews self.groupViews.removeAll() @@ -93,7 +111,7 @@ final class KeyboardVisualizerWindow: NSWindow { } } - private func enforceMaxCount() { + func enforceMaxCount() { let maxCount = self.settings.maxCount while self.groupViews.count > maxCount { let view = self.groupViews.removeFirst() diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapAppearance.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapAppearance.swift index b35035b4..a1e1c5e2 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapAppearance.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapAppearance.swift @@ -11,12 +11,20 @@ import AppKit struct KeycapThemeTokens { let swatchColor: NSColor let textColor: NSColor + let groupBackgroundColor: NSColor let groupStrokeColor: NSColor + + let badgeFillColor: NSColor + let badgeStrokeColor: NSColor + let badgeHighlightColor: NSColor + let badgeTextColor: NSColor + let surfaceHighlightColor: NSColor let surfaceBaseColor: NSColor let surfaceShadowColor: NSColor let surfaceBorderColor: NSColor + let recessColor: NSColor /// Optional Apple-style underside edge color override. Falls back to `recessColor`. let undersideEdgeColor: NSColor? @@ -60,6 +68,42 @@ enum KeycapAppearance { } } + var badgeFillColor: NSColor { + switch self { + case .apple(let appearance): appearance.badgeFillColor + case .pbt(let appearance): appearance.badgeFillColor + case .minimal(let appearance): appearance.badgeFillColor + case .retro(let appearance): appearance.badgeFillColor + } + } + + var badgeStrokeColor: NSColor { + switch self { + case .apple(let appearance): appearance.badgeStrokeColor + case .pbt(let appearance): appearance.badgeStrokeColor + case .minimal(let appearance): appearance.badgeStrokeColor + case .retro(let appearance): appearance.badgeStrokeColor + } + } + + var badgeHighlightColor: NSColor { + switch self { + case .apple(let appearance): appearance.badgeHighlightColor + case .pbt(let appearance): appearance.badgeHighlightColor + case .minimal(let appearance): appearance.badgeHighlightColor + case .retro(let appearance): appearance.badgeHighlightColor + } + } + + var badgeTextColor: NSColor { + switch self { + case .apple(let appearance): appearance.badgeTextColor + case .pbt(let appearance): appearance.badgeTextColor + case .minimal(let appearance): appearance.badgeTextColor + case .retro(let appearance): appearance.badgeTextColor + } + } + var apple: Apple? { guard case .apple(let appearance) = self else { return nil } return appearance @@ -87,6 +131,10 @@ extension KeycapAppearance { let textColor: NSColor let groupBackgroundColor: NSColor let groupStrokeColor: NSColor + let badgeFillColor: NSColor + let badgeStrokeColor: NSColor + let badgeHighlightColor: NSColor + let badgeTextColor: NSColor let strokeColor: NSColor let undersideGradient: NSGradient? let mainGradient: NSGradient? @@ -95,6 +143,10 @@ extension KeycapAppearance { self.textColor = tokens.textColor self.groupBackgroundColor = tokens.groupBackgroundColor self.groupStrokeColor = tokens.groupStrokeColor + self.badgeFillColor = tokens.badgeFillColor + self.badgeStrokeColor = tokens.badgeStrokeColor + self.badgeHighlightColor = tokens.badgeHighlightColor + self.badgeTextColor = tokens.badgeTextColor self.strokeColor = tokens.surfaceBorderColor let undersideEdgeColor = tokens.undersideEdgeColor ?? tokens.recessColor let undersideCenterColor = tokens.undersideCenterColor ?? tokens.surfaceBaseColor @@ -114,6 +166,10 @@ extension KeycapAppearance { let textColor: NSColor let groupBackgroundColor: NSColor let groupStrokeColor: NSColor + let badgeFillColor: NSColor + let badgeStrokeColor: NSColor + let badgeHighlightColor: NSColor + let badgeTextColor: NSColor let bodyGradient: NSGradient? let bodyStrokeColor: NSColor let underDishColor: NSColor @@ -130,6 +186,10 @@ extension KeycapAppearance { self.textColor = tokens.textColor self.groupBackgroundColor = tokens.groupBackgroundColor self.groupStrokeColor = tokens.groupStrokeColor + self.badgeFillColor = tokens.badgeFillColor + self.badgeStrokeColor = tokens.badgeStrokeColor + self.badgeHighlightColor = tokens.badgeHighlightColor + self.badgeTextColor = tokens.badgeTextColor self.bodyGradient = NSGradient(colorsAndLocations: (bodyTop, 0.0), (bodyMid, 0.48), @@ -149,11 +209,19 @@ extension KeycapAppearance { let textColor: NSColor let groupBackgroundColor: NSColor let groupStrokeColor: NSColor + let badgeFillColor: NSColor + let badgeStrokeColor: NSColor + let badgeHighlightColor: NSColor + let badgeTextColor: NSColor init(tokens: KeycapThemeTokens) { self.textColor = tokens.textColor self.groupBackgroundColor = tokens.groupBackgroundColor self.groupStrokeColor = tokens.groupStrokeColor + self.badgeFillColor = tokens.badgeFillColor + self.badgeStrokeColor = tokens.badgeStrokeColor + self.badgeHighlightColor = tokens.badgeHighlightColor + self.badgeTextColor = tokens.badgeTextColor } } @@ -161,6 +229,10 @@ extension KeycapAppearance { let textColor: NSColor let groupBackgroundColor: NSColor let groupStrokeColor: NSColor + let badgeFillColor: NSColor + let badgeStrokeColor: NSColor + let badgeHighlightColor: NSColor + let badgeTextColor: NSColor let lipGradient: NSGradient? let bodyShadowColor: NSColor let bodyGradient: NSGradient? @@ -191,6 +263,10 @@ extension KeycapAppearance { self.textColor = tokens.textColor self.groupBackgroundColor = groupBackgroundColor self.groupStrokeColor = groupStrokeColor + self.badgeFillColor = tokens.badgeFillColor + self.badgeStrokeColor = tokens.badgeStrokeColor + self.badgeHighlightColor = tokens.badgeHighlightColor + self.badgeTextColor = tokens.badgeTextColor self.lipGradient = NSGradient(colorsAndLocations: (lipEdge, 0.0), (lipCenter, 0.5), diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift index c7ec10ac..e418ed56 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift @@ -44,34 +44,56 @@ final class KeycapEventCoordinator self.groupItems[ObjectIdentifier(group)] = nil } + func items(for group: GroupView) -> [Item] { + self.groupItems[ObjectIdentifier(group)] ?? [] + } + + func replaceGroup(_ sourceGroup: GroupView, with targetGroup: GroupView, items: [Item]) { + if self.pendingModifierGroup === sourceGroup { + self.pendingModifierGroup = targetGroup + } + if self.completedModifierGroup === sourceGroup { + self.completedModifierGroup = targetGroup + } + + self.activeKeyGroups = self.activeKeyGroups.mapValues { $0 === sourceGroup ? targetGroup : $0 } + self.activeButtonGroups = self.activeButtonGroups.mapValues { $0 === sourceGroup ? targetGroup : $0 } + self.groupItems[ObjectIdentifier(targetGroup)] = items + self.groupItems[ObjectIdentifier(sourceGroup)] = nil + } + + @discardableResult func handleFlagsChanged( currentTrackedFlags: NSEvent.ModifierFlags, releasedTrackedFlags: NSEvent.ModifierFlags, buildItems: (NSEvent.ModifierFlags, NSEvent.ModifierFlags) -> [Item], appendGroup: ([Item]) -> GroupView, updateGroup: (GroupView, [Item]) -> Void - ) { + ) -> GroupView? { let items = buildItems(currentTrackedFlags, releasedTrackedFlags) guard !items.isEmpty else { if currentTrackedFlags.isEmpty { self.pendingModifierGroup = nil self.completedModifierGroup = nil } - return + return nil } + let group: GroupView if let pendingModifierGroup, self.canAppendModifiers(to: pendingModifierGroup) { let merged = self.ordered(items: self.merged(items: items, into: self.storedItems(for: pendingModifierGroup))) self.groupItems[ObjectIdentifier(pendingModifierGroup)] = merged updateGroup(pendingModifierGroup, merged) self.completedModifierGroup = pendingModifierGroup + group = pendingModifierGroup } else if let completedModifierGroup, self.canRefreshModifiers(in: completedModifierGroup, with: items) { let merged = self.ordered(items: self.merged(items: items, into: self.storedItems(for: completedModifierGroup))) self.groupItems[ObjectIdentifier(completedModifierGroup)] = merged updateGroup(completedModifierGroup, merged) + group = completedModifierGroup } else { let orderedItems = self.ordered(items: items) - let group = appendGroup(orderedItems) + group = appendGroup(orderedItems) self.groupItems[ObjectIdentifier(group)] = orderedItems self.pendingModifierGroup = group self.completedModifierGroup = group @@ -81,16 +103,18 @@ final class KeycapEventCoordinator self.pendingModifierGroup = nil self.completedModifierGroup = nil } + return group } + @discardableResult func handleTrackedKey( keyCode: UInt16, isKeyDown: Bool, items: [Item], appendGroup: ([Item]) -> GroupView, updateGroup: (GroupView, [Item]) -> Void - ) { - guard !items.isEmpty else { return } + ) -> GroupView? { + guard !items.isEmpty else { return nil } if let activeGroup = self.activeKeyGroups[keyCode] { let existingItems = self.storedItems(for: activeGroup) @@ -103,7 +127,7 @@ final class KeycapEventCoordinator if !isKeyDown { self.activeKeyGroups[keyCode] = nil } - return + return activeGroup } if let pendingModifierGroup, self.canAbsorbIntoPendingChord(pendingModifierGroup) { @@ -116,7 +140,7 @@ final class KeycapEventCoordinator if !isKeyDown { self.activeKeyGroups[keyCode] = nil } - return + return pendingModifierGroup } let orderedItems = self.ordered(items: items) @@ -127,15 +151,17 @@ final class KeycapEventCoordinator if isKeyDown { self.activeKeyGroups[keyCode] = group } + return group } + @discardableResult func handleMouseButton( kind: MouseEvent.Kind, isPressed: Bool, items: [Item], appendGroup: ([Item]) -> GroupView, updateGroup: (GroupView, [Item]) -> Void - ) { + ) -> GroupView? { self.handleTrackedButton( identifier: .mouse(kind), isPressed: isPressed, @@ -145,32 +171,35 @@ final class KeycapEventCoordinator ) } + @discardableResult func handleStandalone( items: [Item], appendGroup: ([Item]) -> GroupView, updateGroup: (GroupView, [Item]) -> Void - ) { - guard !items.isEmpty else { return } + ) -> GroupView? { + guard !items.isEmpty else { return nil } if let pendingModifierGroup, self.canAbsorbIntoPendingChord(pendingModifierGroup) { let merged = self.ordered(items: self.merged(items: items, into: self.storedItems(for: pendingModifierGroup))) self.groupItems[ObjectIdentifier(pendingModifierGroup)] = merged updateGroup(pendingModifierGroup, merged) self.completedModifierGroup = pendingModifierGroup - return + return pendingModifierGroup } let group = appendGroup(items) self.groupItems[ObjectIdentifier(group)] = items + return group } + @discardableResult func handleMediaKey( kind: MediaKeyEvent.Kind, isPressed: Bool, items: [Item], appendGroup: ([Item]) -> GroupView, updateGroup: (GroupView, [Item]) -> Void - ) { + ) -> GroupView? { self.handleTrackedButton( identifier: .media(kind), isPressed: isPressed, @@ -193,8 +222,8 @@ private extension KeycapEventCoordinator { items: [Item], appendGroup: ([Item]) -> GroupView, updateGroup: (GroupView, [Item]) -> Void - ) { - guard !items.isEmpty else { return } + ) -> GroupView? { + guard !items.isEmpty else { return nil } if let activeGroup = self.activeButtonGroups[identifier] { let merged = self.ordered(items: self.merged(items: items, into: self.storedItems(for: activeGroup))) @@ -205,7 +234,7 @@ private extension KeycapEventCoordinator { if !isPressed { self.activeButtonGroups[identifier] = nil } - return + return activeGroup } if let pendingModifierGroup, self.canAbsorbIntoPendingChord(pendingModifierGroup) { @@ -216,7 +245,7 @@ private extension KeycapEventCoordinator { if isPressed { self.activeButtonGroups[identifier] = pendingModifierGroup } - return + return pendingModifierGroup } let orderedItems = self.ordered(items: items) @@ -226,6 +255,7 @@ private extension KeycapEventCoordinator { if isPressed { self.activeButtonGroups[identifier] = group } + return group } /// Only pure modifier previews can absorb later modifier-only updates. diff --git a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings index a4f6b1a7..2e0cd57c 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings @@ -129,6 +129,8 @@ "keyboard_visualizer.fade_duration.slow" = "Langsam"; "keyboard_visualizer.only_show_modified_keystrokes_label" = "Nur Tastenkombinationen anzeigen"; "keyboard_visualizer.only_show_modified_keystrokes_subtitle" = "Nur Tasten anzeigen, die mit Befehl, Control, Wahltaste oder Umschalttaste gedrückt werden."; +"keyboard_visualizer.collapse_repeated_groups_label" = "Wiederholte Gruppen zusammenfassen"; +"keyboard_visualizer.collapse_repeated_groups_subtitle" = "Zeigt eine Zählmarke an, wenn dieselbe Taste oder Tastenkombination mehrfach hintereinander gedrückt wird."; "keyboard_visualizer.show_special_keys_label" = "Sondertasten"; "keyboard_visualizer.show_special_keys_subtitle" = "Tabulator, Eingabetaste, Pfeile, fn, Funktionstasten und ähnliche Tasten."; "keyboard_visualizer.show_media_key_buttons_label" = "Medientasten"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings index 4ac5246c..628fc3a9 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings @@ -129,6 +129,8 @@ "keyboard_visualizer.fade_duration.slow" = "Slow"; "keyboard_visualizer.only_show_modified_keystrokes_label" = "Show key combinations only"; "keyboard_visualizer.only_show_modified_keystrokes_subtitle" = "Only show keys pressed with Command, Control, Option, or Shift."; +"keyboard_visualizer.collapse_repeated_groups_label" = "Collapse repeated groups"; +"keyboard_visualizer.collapse_repeated_groups_subtitle" = "Show a count badge when the same key or key combination is pressed repeatedly in a row."; "keyboard_visualizer.show_special_keys_label" = "Special keys"; "keyboard_visualizer.show_special_keys_subtitle" = "Tab, return, arrows, fn, function keys, and similar keys."; "keyboard_visualizer.show_media_key_buttons_label" = "Media key buttons"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings index baed33b6..86a40724 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings @@ -129,6 +129,8 @@ "keyboard_visualizer.fade_duration.slow" = "Lento"; "keyboard_visualizer.only_show_modified_keystrokes_label" = "Mostrar solo combinaciones"; "keyboard_visualizer.only_show_modified_keystrokes_subtitle" = "Muestra solo las teclas pulsadas con Comando, Control, Opción o Mayúsculas."; +"keyboard_visualizer.collapse_repeated_groups_label" = "Agrupar repeticiones consecutivas"; +"keyboard_visualizer.collapse_repeated_groups_subtitle" = "Muestra una insignia con el recuento cuando la misma tecla o combinación se pulsa repetidamente en una fila."; "keyboard_visualizer.show_special_keys_label" = "Teclas especiales"; "keyboard_visualizer.show_special_keys_subtitle" = "Tabulador, Retorno, flechas, fn, teclas de función y otras similares."; "keyboard_visualizer.show_media_key_buttons_label" = "Teclas multimedia"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings index 75c844ad..0cadea16 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings @@ -129,6 +129,8 @@ "keyboard_visualizer.fade_duration.slow" = "Lente"; "keyboard_visualizer.only_show_modified_keystrokes_label" = "Afficher uniquement les combinaisons"; "keyboard_visualizer.only_show_modified_keystrokes_subtitle" = "Afficher uniquement les touches utilisées avec Commande, Contrôle, Option ou Majuscule."; +"keyboard_visualizer.collapse_repeated_groups_label" = "Regrouper les répétitions consécutives"; +"keyboard_visualizer.collapse_repeated_groups_subtitle" = "Affiche un badge de compteur lorsque la même touche ou combinaison est pressée plusieurs fois de suite."; "keyboard_visualizer.show_special_keys_label" = "Touches spéciales"; "keyboard_visualizer.show_special_keys_subtitle" = "Tabulation, Retour, flèches, fn, touches de fonction et touches similaires."; "keyboard_visualizer.show_media_key_buttons_label" = "Touches multimédias"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings index 43aaf970..f1b25790 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings @@ -129,6 +129,8 @@ "keyboard_visualizer.fade_duration.slow" = "遅い"; "keyboard_visualizer.only_show_modified_keystrokes_label" = "キーの組み合わせのみ表示"; "keyboard_visualizer.only_show_modified_keystrokes_subtitle" = "Command、Control、Option、Shiftのいずれかと同時に押したキーのみ表示します。"; +"keyboard_visualizer.collapse_repeated_groups_label" = "連続する同一グループをまとめる"; +"keyboard_visualizer.collapse_repeated_groups_subtitle" = "同じキーまたはキーの組み合わせが連続して押されたときに、回数バッジを表示します。"; "keyboard_visualizer.show_special_keys_label" = "特殊キー"; "keyboard_visualizer.show_special_keys_subtitle" = "Tab、Return、矢印、fn、ファンクションキーなどを表示します。"; "keyboard_visualizer.show_media_key_buttons_label" = "メディアキー"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings index 8f957b2d..3f5f48b3 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings @@ -129,6 +129,8 @@ "keyboard_visualizer.fade_duration.slow" = "Langzaam"; "keyboard_visualizer.only_show_modified_keystrokes_label" = "Alleen toetscombinaties tonen"; "keyboard_visualizer.only_show_modified_keystrokes_subtitle" = "Toon alleen toetsen die met Command, Control, Option of Shift worden ingedrukt."; +"keyboard_visualizer.collapse_repeated_groups_label" = "Herhaalde groepen samenvoegen"; +"keyboard_visualizer.collapse_repeated_groups_subtitle" = "Toon een telbadge wanneer dezelfde toets of toetscombinatie meerdere keren achter elkaar wordt ingedrukt."; "keyboard_visualizer.show_special_keys_label" = "Speciale toetsen"; "keyboard_visualizer.show_special_keys_subtitle" = "Tab, Return, pijlen, fn, functietoetsen en vergelijkbare toetsen."; "keyboard_visualizer.show_media_key_buttons_label" = "Mediatoetsen"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings index f245c60f..48340f24 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings @@ -129,6 +129,8 @@ "keyboard_visualizer.fade_duration.slow" = "Wolno"; "keyboard_visualizer.only_show_modified_keystrokes_label" = "Pokazuj tylko kombinacje klawiszy"; "keyboard_visualizer.only_show_modified_keystrokes_subtitle" = "Pokazuj tylko klawisze naciśnięte z Command, Control, Option lub Shift."; +"keyboard_visualizer.collapse_repeated_groups_label" = "Scalaj powtarzające się grupy"; +"keyboard_visualizer.collapse_repeated_groups_subtitle" = "Pokazuje plakietkę z liczbą, gdy ten sam klawisz lub kombinacja klawiszy jest naciskana kolejno wiele razy."; "keyboard_visualizer.show_special_keys_label" = "Klawisze specjalne"; "keyboard_visualizer.show_special_keys_subtitle" = "Tab, return, strzałki, fn, klawisze funkcyjne i podobne."; "keyboard_visualizer.show_media_key_buttons_label" = "Klawisze multimedialne"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/pt-BR.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/pt-BR.lproj/Localizable.strings index c53c5a94..5e77ddc5 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/pt-BR.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/pt-BR.lproj/Localizable.strings @@ -129,6 +129,8 @@ "keyboard_visualizer.fade_duration.slow" = "Lenta"; "keyboard_visualizer.only_show_modified_keystrokes_label" = "Mostrar apenas combinações de teclas"; "keyboard_visualizer.only_show_modified_keystrokes_subtitle" = "Mostre apenas teclas pressionadas com Command, Control, Option ou Shift."; +"keyboard_visualizer.collapse_repeated_groups_label" = "Agrupar repetições consecutivas"; +"keyboard_visualizer.collapse_repeated_groups_subtitle" = "Mostra um selo de contagem quando a mesma tecla ou combinação é pressionada repetidamente em sequência."; "keyboard_visualizer.show_special_keys_label" = "Teclas especiais"; "keyboard_visualizer.show_special_keys_subtitle" = "Tab, Return, setas, fn, teclas de função e semelhantes."; "keyboard_visualizer.show_media_key_buttons_label" = "Teclas de mídia"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings index 1bf956df..76afc31f 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings @@ -129,6 +129,8 @@ "keyboard_visualizer.fade_duration.slow" = "Повільно"; "keyboard_visualizer.only_show_modified_keystrokes_label" = "Лише комбінації клавіш"; "keyboard_visualizer.only_show_modified_keystrokes_subtitle" = "Показувати лише клавіші, натиснуті разом із Command, Control, Option або Shift."; +"keyboard_visualizer.collapse_repeated_groups_label" = "Об’єднувати повторювані групи"; +"keyboard_visualizer.collapse_repeated_groups_subtitle" = "Показує бейдж із кількістю, коли ту саму клавішу або комбінацію клавіш натискають кілька разів поспіль."; "keyboard_visualizer.show_special_keys_label" = "Спеціальні клавіші"; "keyboard_visualizer.show_special_keys_subtitle" = "Tab, Return, стрілки, fn, функціональні та подібні клавіші."; "keyboard_visualizer.show_media_key_buttons_label" = "Мультимедійні клавіші"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings index daa2b83b..0ea8e9b0 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings @@ -129,6 +129,8 @@ "keyboard_visualizer.fade_duration.slow" = "慢"; "keyboard_visualizer.only_show_modified_keystrokes_label" = "仅显示组合键"; "keyboard_visualizer.only_show_modified_keystrokes_subtitle" = "仅显示与 Command、Control、Option 或 Shift 同时按下的按键。"; +"keyboard_visualizer.collapse_repeated_groups_label" = "合并连续重复分组"; +"keyboard_visualizer.collapse_repeated_groups_subtitle" = "当同一按键或组合键连续重复按下时,显示计数徽标。"; "keyboard_visualizer.show_special_keys_label" = "特殊按键"; "keyboard_visualizer.show_special_keys_subtitle" = "Tab、Return、箭头、fn、功能键及类似按键。"; "keyboard_visualizer.show_media_key_buttons_label" = "媒体按键"; diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift index 55d6216a..857df03c 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift @@ -45,6 +45,7 @@ final class KeyboardVisualizerSettingsTests: XCTestCase { XCTAssertEqual(self.store.integer(forKey: KeyboardVisualizerSettingsKeys.customVerticalAlignment), KeyboardVisualizerAlignment.center.rawValue) XCTAssertEqual(self.store.double(forKey: KeyboardVisualizerSettingsKeys.windowPadding), Double(Size.KeyboardVisualizer.windowPadding), accuracy: 0.0001) XCTAssertEqual(self.store.bool(forKey: KeyboardVisualizerSettingsKeys.onlyShowModifiedKeystrokes), false) + XCTAssertEqual(self.store.bool(forKey: KeyboardVisualizerSettingsKeys.collapseRepeatedGroups), false) XCTAssertEqual(self.store.bool(forKey: KeyboardVisualizerSettingsKeys.showSpecialKeys), true) XCTAssertEqual(self.store.bool(forKey: KeyboardVisualizerSettingsKeys.showMediaKeyButtons), true) XCTAssertEqual(self.store.bool(forKey: KeyboardVisualizerSettingsKeys.showMouseEvents), true) @@ -291,6 +292,13 @@ final class KeyboardVisualizerSettingsTests: XCTestCase { XCTAssertTrue(self.store.bool(forKey: KeyboardVisualizerSettingsKeys.onlyShowModifiedKeystrokes)) } + func testPersistsCollapseRepeatedGroups() { + self.settings.collapseRepeatedGroups = true + + XCTAssertTrue(self.settings.collapseRepeatedGroups) + XCTAssertTrue(self.store.bool(forKey: KeyboardVisualizerSettingsKeys.collapseRepeatedGroups)) + } + func testPersistsShowSpecialKeys() { self.settings.showSpecialKeys = false diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift index f204a69d..fe7cfa01 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift @@ -92,4 +92,112 @@ final class KeyboardVisualizerTests: XCTestCase { XCTAssertEqual(self.visualizer.visibleGroupCount, 0) } + + func testCollapseRepeatedGroupsReusesPreviousStandaloneKeyGroup() { + self.settings.collapseRepeatedGroups = true + self.visualizer.isPresentationActive = true + + self.pressAndReleaseA() + self.visualizer.display(.keystroke(.stub( + keyCode: .a, + type: .keyDown, + characters: "a", + charactersIgnoringModifiers: "a" + ))) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 1) + + self.visualizer.display(.keystroke(.stub( + keyCode: .a, + type: .keyUp, + characters: "a", + charactersIgnoringModifiers: "a" + ))) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 1) + + self.visualizer.display(.keystroke(.stub( + keyCode: .a, + type: .keyDown, + characters: "a", + charactersIgnoringModifiers: "a" + ))) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 1) + } + + func testCollapseRepeatedGroupsReusesPreviousChordGroup() { + self.settings.collapseRepeatedGroups = true + self.visualizer.isPresentationActive = true + + let command: NSEvent.ModifierFlags = .recorded([.command]) + + self.pressAndReleaseCommandK(command) + self.visualizer.display(.modifierStateChanged(command)) + self.visualizer.display(.keystroke(.stub( + keyCode: .k, + type: .keyDown, + modifiers: command, + characters: "k", + charactersIgnoringModifiers: "k" + ))) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 1) + + self.visualizer.display(.keystroke(.stub( + keyCode: .k, + type: .keyUp, + modifiers: command, + characters: "k", + charactersIgnoringModifiers: "k" + ))) + self.visualizer.display(.modifierStateChanged([])) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 1) + + self.visualizer.display(.modifierStateChanged(command)) + self.visualizer.display(.keystroke(.stub( + keyCode: .k, + type: .keyDown, + modifiers: command, + characters: "k", + charactersIgnoringModifiers: "k" + ))) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 1) + } + + private func pressAndReleaseA() { + self.visualizer.display(.keystroke(.stub( + keyCode: .a, + type: .keyDown, + characters: "a", + charactersIgnoringModifiers: "a" + ))) + self.visualizer.display(.keystroke(.stub( + keyCode: .a, + type: .keyUp, + characters: "a", + charactersIgnoringModifiers: "a" + ))) + } + + private func pressAndReleaseCommandK(_ command: NSEvent.ModifierFlags) { + self.visualizer.display(.modifierStateChanged(command)) + self.visualizer.display(.keystroke(.stub( + keyCode: .k, + type: .keyDown, + modifiers: command, + characters: "k", + charactersIgnoringModifiers: "k" + ))) + self.visualizer.display(.keystroke(.stub( + keyCode: .k, + type: .keyUp, + modifiers: command, + characters: "k", + charactersIgnoringModifiers: "k" + ))) + self.visualizer.display(.modifierStateChanged([])) + } } diff --git a/Apps/Keyty/Tests/KeytyTests/Support/Fixtures/StandardKeyEvent+Stub.swift b/Apps/Keyty/Tests/KeytyTests/Support/Fixtures/StandardKeyEvent+Stub.swift index 44c48bac..fa0ee644 100644 --- a/Apps/Keyty/Tests/KeytyTests/Support/Fixtures/StandardKeyEvent+Stub.swift +++ b/Apps/Keyty/Tests/KeytyTests/Support/Fixtures/StandardKeyEvent+Stub.swift @@ -15,12 +15,13 @@ extension StandardKeyEvent { // itself — Help versus Insert, and the arrow function keys. static func stub( keyCode: KeyboardKeyCode, + type: NSEvent.EventType = .keyDown, modifiers: NSEvent.ModifierFlags = [], characters: String = "", charactersIgnoringModifiers: String = "" ) -> StandardKeyEvent { let event = NSEvent.keyEvent( - with: .keyDown, + with: type, location: .zero, modifierFlags: modifiers, timestamp: NSDate.timeIntervalSinceReferenceDate,