Skip to content
Closed
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import AppKit
import CompassKit

/// AppKit mirror of `EventJumpChipsOverlay`. SwiftUI chip overlays are not
/// reliably exposed to XCUITest in CI; this probe carries the same identifiers.
@MainActor
final class EventJumpChipsAccessibilityProbeView: AccessibilityProbeView {
private var chipFields: [NSTextField] = []

func update(hints: [EventJumpChipHint]) {
chipFields.forEach { $0.removeFromSuperview() }
chipFields = []

if hints.isEmpty {
isHidden = true
setAccessibilityElement(false)
setAccessibilityIdentifier(nil)
setAccessibilityLabel(nil)
return
}

isHidden = false
setAccessibilityElement(true)
setAccessibilityHidden(false)
setAccessibilityIdentifier("compass-event-jump-chips")
setAccessibilityRole(.group)
setAccessibilityLabel("Event jump chips")

var x: CGFloat = 0
for hint in hints {
let field = NSTextField(labelWithString: hint.label)
field.isAccessibilityElement = true
field.setAccessibilityIdentifier("compass-event-jump-chip")
field.setAccessibilityLabel(hint.label)
field.setAccessibilityRole(.staticText)
field.frame = NSRect(x: x, y: 0, width: max(28, field.intrinsicContentSize.width + 8), height: 22)
addSubview(field)
chipFields.append(field)
x += field.frame.width + 6
}
setAccessibilityChildren(chipFields)
frame = NSRect(x: 200, y: 200, width: max(x, 40), height: 24)
syncAccessibilityFrame()
}
}

@MainActor
enum EventJumpChipsAccessibilityProbe {
private static weak var probe: EventJumpChipsAccessibilityProbeView?

static func attach(to window: NSWindow) {
guard let host = window.contentView else { return }
attach(to: host)
}

static func attach(to hostView: NSView) {
probe = AccessibilityProbeView.attach(existing: probe, to: hostView) {
EventJumpChipsAccessibilityProbeView(frame: .zero)
}
}

static func publish(hints: [EventJumpChipHint]) {
if probe == nil, let window = NSApp.keyWindow ?? NSApp.mainWindow {
attach(to: window)
}
guard let probe else { return }
let wasInactive = probe.isHidden
probe.update(hints: hints)
if !hints.isEmpty {
if wasInactive {
NSAccessibility.post(element: probe, notification: .created)
}
NSAccessibility.post(element: probe, notification: .focusedUIElementChanged)
} else if !wasInactive {
NSAccessibility.post(element: probe, notification: .uiElementDestroyed)
}
probe.postLayoutChanged()
}
}
24 changes: 23 additions & 1 deletion apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ final class NativeGridKeyboardRouter {
.navGoToDate,
.otherSidebar,
.focusSidebar,
.focusShiftHold,
.focusNotice,
.otherSubscribe,
]
Expand Down Expand Up @@ -366,6 +367,21 @@ final class NativeGridKeyboardRouter {
return true
}

if keyEvent.modifiers.isEmpty,
case .character(let char) = keyEvent.key,
char.lowercased() == "f",
model.viewStore.view != .life
{
if model.eventJumpSelection.modeActive {
if model.handleEventJumpKeystroke("f") {
return true
}
} else {
model.activateEventJumpMode()
return true
}
}

if handleDraftKeys(keyEvent) {
return true
}
Expand Down Expand Up @@ -596,8 +612,14 @@ final class NativeGridKeyboardRouter {
return true
}

if keyEvent.modifiers.isEmpty, case .character(let char) = keyEvent.key, char.isLetter {
if model.eventJumpSelection.modeActive, model.handleEventJumpKeystroke(char) {
return true
}
}

if keyEvent.modifiers.isEmpty, case .character(let digit) = keyEvent.key, digit.isNumber {
if model.eventJumpSelection.modeActive, model.focusEventJumpIfAvailable(digit: digit) {
if model.eventJumpSelection.modeActive, model.handleEventJumpKeystroke(digit) {
return true
}
model.handleQuickTimeDigit(digit)
Expand Down
9 changes: 9 additions & 0 deletions apps/calendar-macos/Compass/NativeRootController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ final class NativeRootController: NSHostingController<ThemedRootView> {
model.onShortcutsLegendAccessibilityProbeChanged = { [weak self] open in
Self.publish(NativeOverlayProbes.shortcutsLegend, visible: open, hostingView: self?.view)
}
model.onEventJumpChipsAccessibilityProbeChanged = { [weak self] hints in
let window = Self.compassHostWindow(hostingView: self?.view) ?? NSApp.mainWindow
if let window {
EventJumpChipsAccessibilityProbe.attach(to: window)
}
EventJumpChipsAccessibilityProbe.publish(hints: hints)
}
applyTheme()
deepLinkRouter.onDeliver = { [weak self] url in
self?.deliverDeepLink(url)
Expand Down Expand Up @@ -138,6 +145,8 @@ final class NativeRootController: NSHostingController<ThemedRootView> {
)
NativeOverlayProbes.statusToast.attach(to: window)
NativeOverlayProbes.shortcutsLegend.attach(to: window)
EventJumpChipsAccessibilityProbe.attach(to: window)
EventJumpChipsAccessibilityProbe.publish(hints: model.timeGridState.eventJumpHints)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,97 @@ extension NativeCalendarRootModel {
return "Event jump · Esc"
}

public func activateEventJumpMode() {
guard !eventJumpSelection.modeActive else { return }
eventJumpSelection.setModeActive(true)
recordShortcut(.focusShiftHold)
refreshEventJumpPresentation()
}

public func toggleEventJumpMode() {
if eventJumpSelection.modeActive {
clearEventJumpSelection()
draftStore.setQuickTimeDigits("")
return
}
eventJumpSelection.setModeActive(true)
recordShortcut(.focusShiftHold)
refreshEventJumpPresentation()
activateEventJumpMode()
}

@discardableResult
public func handleEventJumpKeystroke(_ key: Character) -> Bool {
guard eventJumpSelection.modeActive else { return false }
let dayKeyByPrefix = dayKeyByPrefixForJump()
guard
let match = DayJumpKeyAssignment.matchKeystroke(
assignments: eventJumpAssignments,
dayKeyByPrefix: dayKeyByPrefix,
key: key,
buffer: eventJumpSelection.dayJumpBuffer
)
else { return false }
applyEventJumpMatch(match)
return true
}

private func dayKeyByPrefixForJump() -> [String: String] {
let calendar = EffectiveTimeZone.calendar
var map: [String: String] = [:]
for dayKey in visibleDateKeys() {
guard let date = CompassDateParsing.calendarDateInEffectiveTimeZone(dayKey) else { continue }
let weekday = calendar.component(.weekday, from: date) - 1
let prefix = DayJumpPrefixes.prefix(forWeekday: weekday)
map[prefix] = dayKey
}
return map
}

private func applyEventJumpMatch(_ match: DayJumpKeyAssignment.MatchResult) {
eventJumpAmbiguousTask?.cancel()
eventJumpAmbiguousTask = nil

switch match {
case let .selectDay(dayPrefix, dayKey, firstEventId, buffer):
let name = DayJumpPrefixes.displayName(forPrefix: dayPrefix)
eventJumpSelection.selectDay(
key: dayKey,
announcement: "\(name) selected",
buffer: buffer
)
recordShortcut(.focusWeekDay)
if let firstEventId {
focusEvent(eventId: firstEventId)
}
refreshEventJumpPresentation()

case let .prefix(buffer, dayKeys, pendingExactEventId):
if buffer == "s", dayKeys.isEmpty {
eventJumpSelection.setWeekendDisambiguation(announcement: "Weekend")
} else {
eventJumpSelection.setPrefixBuffer(buffer, dayKeys: dayKeys, announcement: nil)
}
if let pendingExactEventId {
scheduleAmbiguousEventJumpCommit(eventId: pendingExactEventId, buffer: buffer)
}
refreshEventJumpPresentation()

case let .focus(eventId, _, buffer):
eventJumpSelection.setPrefixBuffer(buffer, dayKeys: eventJumpSelection.activeDayKeys, announcement: nil)
focusEvent(eventId: eventId)
refreshEventJumpPresentation()
}
}

private func scheduleAmbiguousEventJumpCommit(eventId: String, buffer: String) {
eventJumpAmbiguousTask?.cancel()
eventJumpAmbiguousTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(400))
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self, self.eventJumpSelection.dayJumpBuffer == buffer else { return }
self.focusEvent(eventId: eventId)
self.refreshEventJumpPresentation()
}
}
}

func refreshEventJumpPresentation() {
Expand All @@ -44,6 +126,7 @@ extension NativeCalendarRootModel {
eventJumpHintLabels = []
quickTimeSlotHintLabels = []
}
onEventJumpChipsAccessibilityProbeChanged?(eventJumpHintLabels)
applyFocusPresentation()
}

Expand Down Expand Up @@ -100,7 +183,10 @@ extension NativeCalendarRootModel {
}

public func clearEventJumpSelection() {
eventJumpAmbiguousTask?.cancel()
eventJumpAmbiguousTask = nil
eventJumpSelection.clear()
eventJumpAssignments = []
eventJumpHintLabels = []
quickTimeSlotHintLabels = []
applyFocusPresentation()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public final class NativeCalendarRootModel {
public var onRecurrenceScopeAccessibilityProbeChanged: ((Bool) -> Void)?
public var onStatusToastAccessibilityProbeChanged: ((Bool) -> Void)?
public var onShortcutsLegendAccessibilityProbeChanged: ((Bool) -> Void)?
public var onEventJumpChipsAccessibilityProbeChanged: (([EventJumpChipHint]) -> Void)?
public var monthPickerMonth: Date
public var pendingScroll: TimeGridScrollRequest?
/// True for 700ms after an already-at-now teach pulse. Matches web `NOW_CUE_PULSE_MS`.
Expand Down Expand Up @@ -120,6 +121,8 @@ public final class NativeCalendarRootModel {
private var refreshTask: Task<Void, Never>?
private var focusLayoutCards: [FocusLayoutCard] = []
var eventJumpHintLabels: [EventJumpChipHint] = []
var eventJumpAssignments: [DayJumpKeyAssignment.Assignment] = []
var eventJumpAmbiguousTask: Task<Void, Never>?
var quickTimeSlotHintLabels: [QuickTimeSlotHint] = []
public var focusedDayColumnCalendarId: String?
public var pendingFocusDayColumnCalendarId: String?
Expand Down Expand Up @@ -674,7 +677,11 @@ public final class NativeCalendarRootModel {
case .focusNotice:
focusNextNotice()
case .focusShiftHold:
toggleEventJumpMode()
if eventJumpSelection.modeActive {
_ = handleEventJumpKeystroke("f")
} else {
activateEventJumpMode()
}
case .otherSubscribe:
openUpgradeConfirmation()
case .navGoToDate:
Expand Down Expand Up @@ -792,17 +799,7 @@ public final class NativeCalendarRootModel {
}

public func focusEventJump(digit: Character) {
guard let hint = eventJumpHintLabels.first(where: { $0.label == String(digit) }) else { return }
focusEvent(eventId: hint.eventId)
}

@discardableResult
public func focusEventJumpIfAvailable(digit: Character) -> Bool {
guard let hint = eventJumpHintLabels.first(where: { $0.label == String(digit) }) else {
return false
}
focusEvent(eventId: hint.eventId)
return true
_ = handleEventJumpKeystroke(digit)
}

public func consumePendingScroll() -> TimeGridScrollRequest? {
Expand Down Expand Up @@ -1238,17 +1235,64 @@ public final class NativeCalendarRootModel {
}

func buildEventJumpHints() -> [EventJumpChipHint] {
let digits = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
let timed = focusLayoutCards.filter { !$0.isAllDay }.sorted { $0.frame.top < $1.frame.top }
return timed.prefix(digits.count).enumerated().map { index, card in
EventJumpChipHint(
eventId: card.eventId,
label: digits[index],
frame: card.frame
let targets = buildEventJumpTargets()
eventJumpAssignments = DayJumpKeyAssignment.assign(targets)
let filtered = DayJumpKeyAssignment.filter(
eventJumpAssignments,
prefix: eventJumpSelection.dayJumpBuffer
)
let layoutCards = currentGridCards()
let frameByEventId = Dictionary(
uniqueKeysWithValues: layoutCards.map { ($0.eventId, $0.frame) }
)
return filtered.compactMap { assignment in
guard let frame = frameByEventId[assignment.eventId] else { return nil }
return EventJumpChipHint(
eventId: assignment.eventId,
label: assignment.hint,
frame: frame
)
}
}

private func buildEventJumpTargets() -> [DayJumpKeyAssignment.Target] {
let calendar = EffectiveTimeZone.calendar
let eventsById = Dictionary(uniqueKeysWithValues: loadedEvents.map { ($0.id.rawValue, $0) })
var targets: [DayJumpKeyAssignment.Target] = []
for card in currentGridCards() {
guard let event = eventsById[card.eventId] else { continue }
switch event.schedule {
case .timed(let timed):
let start = CompassDateParsing.parseInEffectiveTimeZone(timed.start.rawValue) ?? referenceNow
let dayKey = CompassDateParsing.formatCalendarDay(start)
let weekday = calendar.component(.weekday, from: start) - 1
targets.append(
DayJumpKeyAssignment.Target(
eventId: card.eventId,
startMs: Int64(start.timeIntervalSince1970 * 1000),
eventType: .timed,
dayKey: dayKey,
weekday: weekday
)
)
case .allDay(let allDay):
let start = CompassDateParsing.calendarDateInEffectiveTimeZone(allDay.start) ?? referenceNow
let dayKey = CompassDateParsing.formatCalendarDay(start)
let weekday = calendar.component(.weekday, from: start) - 1
targets.append(
DayJumpKeyAssignment.Target(
eventId: card.eventId,
startMs: Int64(start.timeIntervalSince1970 * 1000),
eventType: .allDay,
dayKey: dayKey,
weekday: weekday
)
)
}
}
return targets
}

public func visibleCalendars() -> [CompassCalendar] {
calendars.filter { $0.isActive && calendarVisibilityStore.isVisible($0.id) }
}
Expand Down
Loading
Loading