Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,6 @@ enum KeyboardGlyphCatalog {

static let tab = UnicodeToken.tab.string

/// Physical key codes for left and right command, shift, option, and control keys.
static let modifierKeyCodes: Set<KeyboardKeyCode> = Set(KeyboardModifierKey.Kind.allCases.flatMap(\.keyCodes))

static func isModifierKeyCode(_ rawValue: UInt16) -> Bool {
guard let keyCode = KeyboardKeyCode(rawValue: rawValue) else { return false }
return modifierKeyCodes.contains(keyCode)
}

/// Returns the glyph we show for a typed keyboard key when it has a semantic visual representation.
static func symbol(for key: KeyboardSpecialKey) -> String {
key.displayText
Expand Down
22 changes: 22 additions & 0 deletions Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardKeyCode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,25 @@ enum KeyboardKeyCode: UInt16 {
case dictation = 0xB0
case doNotDisturb = 0xB2
}

extension KeyboardKeyCode {
/// Keys whose press/release lifecycle is visualized from modifier-state updates
/// rather than the normal keystroke path.
var isFlagsChangedDriven: Bool {
switch self {
case .commandRight, .commandLeft,
.shiftLeft, .shiftRight,
.optionLeft, .optionRight,
.controlLeft, .controlRight,
.function:
return true
default:
return false
}
}

static func isFlagsChangedDriven(_ rawValue: UInt16) -> Bool {
guard let keyCode = Self(rawValue: rawValue) else { return false }
return keyCode.isFlagsChangedDriven
}
}
22 changes: 19 additions & 3 deletions Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import AppKit

/// A physical left or right key for a paired keyboard modifier.
/// A physical key for a keyboard modifier.
public struct KeyboardModifierKey: Hashable {
let kind: Kind
let location: Location
Expand All @@ -20,17 +20,20 @@ public struct KeyboardModifierKey: Hashable {
}

extension KeyboardModifierKey {
/// The physical side of a paired keyboard modifier key.
/// The physical position of a modifier key.
enum Location: CaseIterable, Hashable {
case left
case right
case single

var canonicalDisplayOrderIndex: Int {
switch self {
case .left:
return 0
case .right:
return 1
case .single:
return 2
}
}
}
Expand All @@ -41,6 +44,8 @@ extension KeyboardModifierKey {
return .right
case .right:
return .left
case .single:
return .center
}
}
}
Expand All @@ -57,12 +62,17 @@ extension KeyboardModifierKey {
case .optionRight: return .rightOption
case .controlLeft: return .leftControl
case .controlRight: return .rightControl
case .function: return .function
default: return nil
}
}

static func keys(in flags: NSEvent.ModifierFlags) -> Set<KeyboardModifierKey> {
Set(Self.all.filter { flags.rawValue & $0.deviceMask != 0 })
var keys = Set(Self.all.filter { flags.rawValue & $0.deviceMask != 0 })
if flags.contains(.function) {
keys.insert(.function)
}
return keys
}
}

Expand All @@ -76,6 +86,7 @@ extension KeyboardModifierKey {
static let rightOption = KeyboardModifierKey(.option, location: .right)
static let leftControl = KeyboardModifierKey(.control, location: .left)
static let rightControl = KeyboardModifierKey(.control, location: .right)
static let function = KeyboardModifierKey(.function, location: .single)

static let all: [KeyboardModifierKey] = [
.leftCommand, .rightCommand,
Expand All @@ -95,6 +106,11 @@ extension KeyboardModifierKey {
case (.option, .right): return UInt(NX_DEVICERALTKEYMASK)
case (.control, .left): return UInt(NX_DEVICELCTLKEYMASK)
case (.control, .right): return UInt(NX_DEVICERCTLKEYMASK)
case (.function, .single): return 0
case (.command, .single), (.shift, .single), (.option, .single), (.control, .single):
return 0
case (.function, .left), (.function, .right):
return 0
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,19 @@ extension KeyboardModifierKey {
case shift
case option
case control
case function

/// Modifier order guidance from Apple style guide:
/// https://support.apple.com/guide/applestyleguide/k-apsgf9067ae8/1.0/web/1.0
static let canonicalDisplayOrder: [Self] = [.control, .option, .shift, .command]
static let canonicalDisplayOrder: [Self] = [.control, .option, .shift, .command, .function]

var glyph: String {
switch self {
case .command: return UnicodeToken.command.string
case .shift: return UnicodeToken.shift.string
case .option: return UnicodeToken.option.string
case .control: return UnicodeToken.control.string
case .function: return ""
}
}

Expand All @@ -35,6 +37,7 @@ extension KeyboardModifierKey {
case .shift: return "shift"
case .option: return "option"
case .control: return "control"
case .function: return "fn"
}
}

Expand All @@ -44,16 +47,18 @@ extension KeyboardModifierKey {
case .shift: return .shift
case .option: return .option
case .control: return .control
case .function: return .function
}
}

/// Physical key codes for the left and right keys for this modifier.
/// Physical key codes for this modifier.
var keyCodes: Set<KeyboardKeyCode> {
switch self {
case .command: return [.commandLeft, .commandRight]
case .shift: return [.shiftLeft, .shiftRight]
case .option: return [.optionLeft, .optionRight]
case .control: return [.controlLeft, .controlRight]
case .function: return [.function]
}
}

Expand All @@ -67,6 +72,11 @@ extension KeyboardModifierKey {
case (.option, .right): return .optionRight
case (.control, .left): return .controlLeft
case (.control, .right): return .controlRight
case (.function, .single): return .function
case (.command, .single), (.shift, .single), (.option, .single), (.control, .single):
preconditionFailure("Single-location modifier requested for a two-sided modifier")
case (.function, .left), (.function, .right):
preconditionFailure("Side-specific location requested for the single fn key")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Carbon
import Combine

final class KeyboardVisualizer {
private static let trackedModifierFlags: NSEvent.ModifierFlags = [.command, .shift, .option, .control, .function]
private static let trackedModifierFlags: NSEvent.ModifierFlags = [.command, .shift, .option, .control]

var isPresentationActive: Bool = false {
didSet {
Expand Down Expand Up @@ -145,9 +145,9 @@ extension KeyboardVisualizer {
// MARK: - Event Display
private extension KeyboardVisualizer {
private func displayKeystroke(_ keystroke: StandardKeyEvent) {
// Track command/shift/option/control exclusively through flagsChanged so a modifier
// Track modifier-driven keys exclusively through flagsChanged so a modifier
// release does not create a separate keystroke group after the chord ends.
if KeyboardGlyphCatalog.isModifierKeyCode(keystroke.keyCode) {
if KeyboardKeyCode.isFlagsChangedDriven(keystroke.keyCode) {
return
}

Expand All @@ -161,9 +161,12 @@ private extension KeyboardVisualizer {
return
}

let legend = self.legendResolver.legend(for: .keystroke(keystroke))
let items = KeycapItemFactory.keycapItems(
for: keystroke,
legend: self.legendResolver.legend(for: .keystroke(keystroke)),
keyCode: keystroke.keyCode,
legend: legend,
modifierFlags: keystroke.modifierFlags.subtracting(.function),
isPressed: keystroke.type != .keyUp,
palette: self.visualizerSettings.palette
)
guard !items.isEmpty else { return }
Expand All @@ -188,6 +191,8 @@ private extension KeyboardVisualizer {
let previousTrackedFlags = self.lastModifierFlags.intersection(Self.trackedModifierFlags)
let releasedTrackedFlags = previousTrackedFlags.subtracting(currentTrackedFlags)
let releasedModifierFlags = self.lastModifierFlags.subtracting(modifierFlags)
let functionNow = modifierFlags.contains(.function)
let functionWas = self.lastModifierFlags.contains(.function)

// Caps Lock: one-shot flash, lit when turning on, dim when turning off
let capsNow = modifierFlags.contains(.capsLock)
Expand All @@ -208,6 +213,20 @@ private extension KeyboardVisualizer {
self.finalizeGroupIfNeeded(group)
}

if self.visualizerSettings.showSpecialKeys, functionNow != functionWas {
let group = self.eventCoordinator.handleIndependentTrackedKey(
keyCode: KeyboardKeyCode.function.rawValue,
isKeyDown: functionNow,
items: [self.functionKeycapItem(isPressed: functionNow)],
appendGroup: { self.visualizerWindow.appendGroup(with: $0, defersMaxCount: self.visualizerSettings.collapseRepeatedGroups) },
updateGroup: { group, items in self.visualizerWindow.updateGroup(group, with: items) }
)
self.collapseActiveRepeatIfNeeded(group)
if !functionNow {
self.finalizeGroupIfNeeded(group)
}
}

self.lastModifierFlags = modifierFlags

let items = KeycapItemFactory.modifierItems(
Expand All @@ -216,7 +235,7 @@ private extension KeyboardVisualizer {
palette: self.visualizerSettings.palette
)
guard !items.isEmpty else {
if currentTrackedFlags.isEmpty {
if currentTrackedFlags.isEmpty, !functionNow {
self.eventCoordinator.reset()
}
return
Expand All @@ -240,6 +259,19 @@ private extension KeyboardVisualizer {
self.finalizeGroupIfNeeded(group)
}
}

private func functionKeycapItem(isPressed: Bool) -> KeycapItem {
KeycapItemFactory.keycapItems(
keyCode: KeyboardKeyCode.function.rawValue,
legend: EventLegend(
text: KeyboardSpecialKey.function.displayText,
label: KeyboardSpecialKey.function.label
),
modifierFlags: [],
isPressed: isPressed,
palette: self.visualizerSettings.palette
).first!
}
}

// MARK: - State
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,41 @@ final class KeycapEventCoordinator<GroupView: AnyObject, Item: KeycapGroupItem>
return group
}

@discardableResult
func handleIndependentTrackedKey(
keyCode: UInt16,
isKeyDown: Bool,
items: [Item],
appendGroup: ([Item]) -> GroupView,
updateGroup: (GroupView, [Item]) -> Void
) -> GroupView? {
guard !items.isEmpty else { return nil }

if let activeGroup = self.activeKeyGroups[keyCode] {
let existingItems = self.storedItems(for: activeGroup)
let permittedItems = self.permittedTrackedKeyItems(items, forExistingItems: existingItems)
let merged = self.ordered(items: self.merged(items: permittedItems, into: existingItems))
self.groupItems[ObjectIdentifier(activeGroup)] = merged
updateGroup(activeGroup, merged)
self.pendingModifierGroup = activeGroup
self.completedModifierGroup = activeGroup
if !isKeyDown {
self.activeKeyGroups[keyCode] = nil
}
return activeGroup
}

let orderedItems = self.ordered(items: items)
let group = appendGroup(orderedItems)
self.groupItems[ObjectIdentifier(group)] = orderedItems
self.pendingModifierGroup = group
self.completedModifierGroup = group
if isKeyDown {
self.activeKeyGroups[keyCode] = group
}
return group
}

@discardableResult
func handleMouseButton(
kind: MouseEvent.Kind,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ extension KeycapItemFactory {
releasedFlags: NSEvent.ModifierFlags,
palette: KeycapThemePalette
) -> [KeycapItem] {
let currentFlags = currentFlags.subtracting(.function)
let releasedFlags = releasedFlags.subtracting(.function)
var items: [KeycapItem] = []
let currentModifierKeys = KeyboardModifierKey.keys(in: currentFlags)
let releasedModifierKeys = KeyboardModifierKey.keys(in: releasedFlags)
Expand Down Expand Up @@ -44,10 +46,6 @@ extension KeycapItemFactory {
))
}
}

if currentFlags.contains(.function) || releasedFlags.contains(.function) {
items.append(Self.functionItem(isPressed: currentFlags.contains(.function), palette: palette))
}
return items
}

Expand All @@ -59,7 +57,7 @@ extension KeycapItemFactory {
let identity = KeycapIdentity.modifier(modifierKey)
return KeycapItem(
identity: identity,
legend: KeycapLegend(symbol: modifierKey.kind.glyph, label: modifierKey.kind.label),
legend: .modifier(modifierKey.kind),
state: KeycapState(isPressed: isPressed),
layoutHints: KeycapLayoutHints(alignment: modifierKey.legendAlignment),
appearance: palette.appearance(for: identity)
Expand All @@ -76,17 +74,4 @@ extension KeycapItemFactory {
.map { KeyboardModifierKey(modifier, location: $0) }
.filter { keys.contains($0) }
}

private static func functionItem(
isPressed: Bool,
palette: KeycapThemePalette
) -> KeycapItem {
let identity = KeycapIdentity.keyCode(KeyboardKeyCode.function.rawValue)
return KeycapItem(
identity: identity,
legend: .function,
state: KeycapState(isPressed: isPressed),
appearance: palette.appearance(for: identity)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,7 @@ enum KeycapCategory: CaseIterable {
case .mouse:
self = .mouse
case let .keyCode(code):
// `fn` is emitted as a keyCode from the modifier path and is shown beside the
// modifiers, so it follows the modifier theme rather than the special theme.
if code == KeyboardKeyCode.function.rawValue {
self = .modifier
} else if KeyboardSpecialKeyResolver.specialKey(for: code) != nil {
if KeyboardSpecialKeyResolver.specialKey(for: code) != nil {
self = .special
} else {
self = .regular
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ extension KeycapLegend {
}

static func modifier(_ modifier: KeyboardModifierKey.Kind) -> KeycapLegend {
KeycapLegend(symbol: modifier.glyph, label: modifier.label)
switch modifier {
case .function:
return .function
case .command, .shift, .option, .control:
return KeycapLegend(symbol: modifier.glyph, label: modifier.label)
}
}

static func character(_ symbol: String) -> KeycapLegend {
Expand Down
Loading
Loading