Skip to content
Merged
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
@@ -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.setAccessibilityElement(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()
}
}
14 changes: 4 additions & 10 deletions apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -383,13 +383,6 @@ final class NativeGridKeyboardRouter {
return true
}

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

if keyEvent.modifiers.isEmpty, keyEvent.key == .named(.escape), model.eventJumpSelection.isActive {
model.clearEventJumpSelection()
return true
Expand Down Expand Up @@ -641,11 +634,12 @@ final class NativeGridKeyboardRouter {
return true
}

if keyEvent.modifiers.isEmpty, case .character(let digit) = keyEvent.key, digit.isNumber {
if model.eventJumpSelection.modeActive, model.handleEventJumpDigit(digit) {
if keyEvent.modifiers.isEmpty, case .character(let char) = keyEvent.key {
if model.eventJumpSelection.modeActive, model.handleEventJumpKeystroke(char) {
return true
}
model.handleQuickTimeDigit(digit)
guard char.isNumber else { return false }
model.handleQuickTimeDigit(char)
if !model.draftStore.quickTimeDigits.isEmpty {
return true
}
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,20 @@ 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()
}

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

Expand Down Expand Up @@ -76,18 +82,83 @@ extension NativeCalendarRootModel {
selectJumpDay(prefix: prefix)
}

/// Web jump mode takes day letters bare (W, then 2). F still toggles the
/// mode off and E / C keep their edit and create shortcuts, so those fall
/// through (false); every other letter is swallowed so J/K cannot fire.
public func handleEventJumpLetter(_ letter: Character) -> Bool {
switch letter.lowercased() {
case "f", "e", "c":
/// Web jump mode: lowercase day prefixes plus digits (`f4`, `su8`).
/// Uppercase letters are accepted. `e` / `c` fall through; other unknown
/// letters are swallowed so leader keys cannot fire.
@discardableResult
public func handleEventJumpKeystroke(_ key: Character) -> Bool {
guard eventJumpSelection.modeActive else { return false }
let normalized = Character(String(key).lowercased())
if normalized.isNumber {
return handleEventJumpDigit(normalized)
}
switch normalized.lowercased() {
case "e", "c":
return false
case "m", "t", "w", "r", "s", "u", "a":
handleShiftDayJump(letter: letter)
return true
default:
return true
break
}
guard
let match = DayJumpKeys.matchKeystroke(
assignments: dayJumpAssignments(),
dayKeyByPrefix: dayKeyByPrefixForJump(),
key: normalized,
buffer: eventJumpSelection.dayJumpBuffer
)
else {
return normalized.isLetter
}
applyEventJumpKeystrokeMatch(match)
return true
}

public func handleEventJumpLetter(_ letter: Character) -> Bool {
handleEventJumpKeystroke(letter)
}

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
map[DayJumpPrefixes.prefix(forWeekday: weekday)] = dayKey
}
return map
}

private func applyEventJumpKeystrokeMatch(_ match: DayJumpKeys.KeystrokeMatch) {
eventJumpCommitTask?.cancel()
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, pendingExactEventId):
if buffer == "s" {
eventJumpSelection.setWeekendDisambiguation(announcement: "Weekend")
} else {
eventJumpSelection.setDayJumpBuffer(buffer)
}
refreshEventJumpPresentation()
if let pendingExactEventId {
eventJumpCommitTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: DayJumpKeys.ambiguousCommitDelay)
guard !Task.isCancelled, let self, self.eventJumpSelection.dayJumpBuffer == buffer else { return }
self.commitEventJump(eventId: pendingExactEventId)
}
}
case let .focus(eventId, buffer):
eventJumpSelection.setDayJumpBuffer(buffer)
commitEventJump(eventId: eventId)
}
}

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 @@ -388,6 +389,9 @@ public final class NativeCalendarRootModel {
if UITestLaunchPolicy.showPageJumpHintsForUITest {
setPageJumpHintsVisible(true)
}
if UITestLaunchPolicy.activateEventJumpForUITest {
activateEventJumpMode()
}
if isSignedIn {
startEventStream()
}
Expand Down Expand Up @@ -679,7 +683,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 @@ -1241,31 +1249,32 @@ public final class NativeCalendarRootModel {
/// Web `ShiftHintOverlay`: W1, SU2, ... on every grid card, narrowed to
/// the typed day prefix.
func buildEventJumpHints() -> [EventJumpChipHint] {
let cards = Dictionary(focusLayoutCards.map { ($0.eventId, $0) }, uniquingKeysWith: { first, _ in first })
let layoutCards = currentGridCards()
let frameByEventId = Dictionary(uniqueKeysWithValues: layoutCards.map { ($0.eventId, $0.frame) })
let allDayByEventId = Dictionary(uniqueKeysWithValues: layoutCards.map { ($0.eventId, $0.kind == .allDay) })
let assignments = DayJumpKeys.filter(dayJumpAssignments(), prefix: eventJumpSelection.dayJumpBuffer)
return assignments.compactMap { assignment in
cards[assignment.eventId].map { card in
EventJumpChipHint(
eventId: assignment.eventId,
label: assignment.hint.uppercased(),
frame: card.frame,
isAllDay: card.isAllDay
)
}
guard let frame = frameByEventId[assignment.eventId] else { return nil }
return EventJumpChipHint(
eventId: assignment.eventId,
label: assignment.hint,
frame: frame,
isAllDay: allDayByEventId[assignment.eventId] ?? false
)
}
}

func dayJumpAssignments() -> [DayJumpKeys.Assignment] {
let eventsById = Dictionary(loadedEvents.map { ($0.id.rawValue, $0) }, uniquingKeysWith: { first, _ in first })
var seen = Set<String>()
let targets: [DayJumpKeys.Target] = focusLayoutCards.compactMap { card in
let targets: [DayJumpKeys.Target] = currentGridCards().compactMap { card in
guard seen.insert(card.eventId).inserted, let event = eventsById[card.eventId] else { return nil }
let dayKey = eventJumpDayKey(for: event)
guard let day = CompassDateParsing.calendarDateInEffectiveTimeZone(dayKey) else { return nil }
return DayJumpKeys.Target(
eventId: card.eventId,
start: eventStart(event),
isAllDay: card.isAllDay,
isAllDay: card.kind == .allDay,
dayKey: dayKey,
weekday: EffectiveTimeZone.calendar.component(.weekday, from: day) - 1
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ public enum UITestLaunchPolicy {
ProcessInfo.processInfo.arguments.contains("-COMPASS_UI_TEST_SHOW_PAGE_JUMP_HINTS")
}

/// Turns on event jump mode after the demo grid layout is ready (XCUITest).
public static var activateEventJumpForUITest: Bool {
ProcessInfo.processInfo.arguments.contains("-COMPASS_UI_TEST_ACTIVATE_EVENT_JUMP")
}

/// Opens the start-date popover when the event form appears (parity PNG capture).
public static var openEventFormDatePickerForUITest: Bool {
ProcessInfo.processInfo.arguments.contains("-COMPASS_UI_TEST_OPEN_EVENT_FORM_DATE_PICKER")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,28 +56,40 @@ final class NativeCalendarRootModelEventJumpTests: XCTestCase {
model.viewStore.view = .week
model.toggleEventJumpMode()

XCTAssertTrue(model.handleEventJumpLetter("w"))
XCTAssertTrue(model.handleEventJumpKeystroke("w"))
XCTAssertEqual(model.eventJumpSelection.announcement, "Wednesday selected")

XCTAssertTrue(model.handleEventJumpLetter("s"))
XCTAssertTrue(model.handleEventJumpKeystroke("s"))
XCTAssertEqual(model.eventJumpSelection.dayJumpBuffer, "s")
XCTAssertTrue(model.handleEventJumpLetter("u"))
XCTAssertTrue(model.handleEventJumpKeystroke("u"))
XCTAssertEqual(model.eventJumpSelection.announcement, "Sunday selected")
}

func testJumpModeSwallowsOtherLettersButLeavesToggleEditAndCreate() async throws {
func testJumpModeSwallowsOtherLettersButLeavesEditAndCreate() async throws {
let fixture = try DemoSeedFixture.load()
let environment = try NativeCalendarEnvironment(inMemoryDatabase: true)
let model = NativeCalendarRootModel(environment: environment, demoPresentation: fixture)
await model.start()
model.viewStore.view = .week
model.toggleEventJumpMode()

XCTAssertTrue(model.handleEventJumpLetter("j"))
XCTAssertTrue(model.handleEventJumpKeystroke("j"))
XCTAssertTrue(model.eventJumpSelection.activeDayKeys.isEmpty)
XCTAssertFalse(model.handleEventJumpLetter("f"))
XCTAssertFalse(model.handleEventJumpLetter("e"))
XCTAssertFalse(model.handleEventJumpLetter("c"))
XCTAssertTrue(model.handleEventJumpKeystroke("f"))
XCTAssertEqual(model.eventJumpSelection.dayJumpBuffer, "f")
XCTAssertFalse(model.handleEventJumpKeystroke("e"))
XCTAssertFalse(model.handleEventJumpKeystroke("c"))
}

func testUppercaseDayLettersMatchLowercaseHints() async throws {
let fixture = try DemoSeedFixture.load()
let environment = try NativeCalendarEnvironment(inMemoryDatabase: true)
let model = NativeCalendarRootModel(environment: environment, demoPresentation: fixture)
await model.start()
model.viewStore.view = .week
model.activateEventJumpMode()
XCTAssertTrue(model.handleEventJumpKeystroke("W"))
XCTAssertEqual(model.eventJumpSelection.dayJumpBuffer, "w")
}

/// Web: Shift+T then 1230 creates at 12:30 on Tuesday, not today.
Expand Down
Loading
Loading