Skip to content
50 changes: 50 additions & 0 deletions apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,33 @@ final class NativeGridKeyboardRouter {
return true
}

if keyEvent.modifiers.contains(.shift), shiftArrowKeyboardName(for: keyEvent) != nil {
let keyWindow = event.window ?? NSApp.keyWindow
if (keyWindow?.firstResponder as? NSTextView)?.isEditable == true
|| dispatcher.isTextInputFocused
{
return false
}
if model.isEventFormVisible {
return false
}
if model.draftStore.gridDraft != nil {
if handleDraftKeys(keyEvent) { return true }
return false
}
if model.focusStore.focusedEventId != nil {
if attemptShiftArrowEdgeNudge(keyEvent) {
return true
}
if let focused = model.focusedEvent(), model.isReadOnly(event: focused) {
return false
}
return false
}
if handleDraftKeys(keyEvent) { return true }
return false
}

if handleDraftKeys(keyEvent) {
return true
}
Expand Down Expand Up @@ -1196,4 +1223,27 @@ final class NativeGridKeyboardRouter {
return DayJumpPrefixes.dayPrefix(forShiftedLetter: letter) != nil
}

private func shiftArrowKeyboardName(for keyEvent: KeyEvent) -> String? {
switch keyEvent.key {
case .named(.arrowUp): return "ArrowUp"
case .named(.arrowDown): return "ArrowDown"
case .named(.arrowLeft): return "ArrowLeft"
case .named(.arrowRight): return "ArrowRight"
default: return nil
}
}

private func attemptShiftArrowEdgeNudge(_ keyEvent: KeyEvent) -> Bool {
guard keyEvent.modifiers.contains(.shift),
let arrow = shiftArrowKeyboardName(for: keyEvent)
else {
return false
}
return model.nudgeDraftOrPlace(
key: arrow,
shiftKey: true,
altKey: keyEvent.modifiers.contains(.option)
)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import CompassKit
import Foundation

extension EventEdge {
var scheduleEdge: DraftNudge.ScheduleEdge {
switch self {
case .startDate: return .start
case .endDate: return .end
}
}

init(_ edge: DraftNudge.ScheduleEdge) {
switch edge {
case .start: self = .startDate
case .end: self = .endDate
}
}
}

enum EventScheduleDraftMapping {
static func eventSchedule(from draft: DraftSchedule, event: Event) -> EventSchedule {
let timeZone: IANATimeZone? = {
if case .timed(let timed) = event.schedule { return timed.timeZone }
return nil
}()
switch draft.kind {
case .timed:
return .timed(
EventSchedule_TimedPayload(
end: DateTime(rawValue: CompassDateParsing.formatLikeDayjs(draft.end)),
kind: "timed",
start: DateTime(rawValue: CompassDateParsing.formatLikeDayjs(draft.start)),
timeZone: timeZone ?? IANATimeZone(rawValue: EffectiveTimeZone.identifier)
)
)
case .allDay:
return .allDay(
EventSchedule_AllDayPayload(
end: CompassDateParsing.formatCalendarDay(draft.end),
kind: "allDay",
start: CompassDateParsing.formatCalendarDay(draft.start)
)
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,11 @@ extension NativeCalendarRootModel {
if moved { rebuildPresentation() }
return moved
}
if focusStore.focusedEventId != nil { return false }
if focusStore.focusedEventId != nil {
return nudgeFocusedSavedEvent(key: key, altKey: altKey)
}
placeTimedDraft()
return true
return draftStore.gridDraft != nil
}

if draftStore.gridDraft != nil {
Expand All @@ -110,6 +112,30 @@ extension NativeCalendarRootModel {

private func nudgeDraft(key: String, altKey: Bool) -> Bool {
guard let draft = draftStore.gridDraft, !isScheduleLocked(for: draft) else { return false }

if let edge = focusStore.edge,
let focusedId = focusStore.focusedEventId,
focusedId == draft.clientId || focusedId == draft.sourceEventId
{
let step = DraftNudge.nudgeStepFromKeyboard(altKey: altKey)
guard let result = DraftNudge.nudgeScheduleEdgeByKeyboard(
schedule: draft.schedule,
edge: edge.scheduleEdge,
key: key,
step: step
) else {
return false
}
draftStore.setGridSchedule(result.schedule)
focusStore.setEdge(
eventId: focusedId,
edge: EventEdge(result.edge),
announcement: focusStore.edgeAnnouncement,
isAllDay: focusStore.edgeFocusIsAllDay
)
return true
}

return draftStore.nudgeByKeyboard(
key: key,
altKey: altKey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ extension NativeCalendarRootModel {

public func undoLastChangeAndWait() async {
let revision = sessionRevision
await awaitFocusedEventNudgePersistenceBeforeUndo()
guard !undoStore.isRestoringHistory() else { return }
guard let entry = undoStore.peekUndo() else {
statusToastStore.show(id: "undo-status", message: "Nothing to undo")
Expand Down Expand Up @@ -363,11 +364,11 @@ extension NativeCalendarRootModel {
}
}

private func replaceEvent(with event: Event) async {
func replaceEvent(with event: Event, scheduleBaseline: Event? = nil) async {
guard case .details(let details) = event.content else { return }
let current = loadedEvents.first { $0.id == event.id }
let reference = scheduleBaseline ?? loadedEvents.first { $0.id == event.id }
let scheduleUnchanged =
current.map { EventScheduleEqual.matches(event.schedule, $0.schedule) } ?? false
reference.map { EventScheduleEqual.matches(event.schedule, $0.schedule) } ?? false
let input = ReplaceEventInput(
calendarId: eventsStore.source == .local ? event.calendarId : nil,
content: .init(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import CompassKit
import Foundation

extension NativeCalendarRootModel {
/// Shift+arrow nudge for a focused saved event (no grid draft).
@discardableResult
func nudgeFocusedSavedEvent(key: String, altKey: Bool) -> Bool {
guard viewStore.view != .life else { return false }
guard draftStore.gridDraft == nil else { return false }
guard !isEventFormVisible else { return false }
guard let event = focusedEvent(), case .details = event.content else { return false }
guard !isReadOnly(event: event) else { return false }
guard !isFocusedSavedEventScheduleLocked(event) else { return false }
guard let current = GridEventDraftFromEvent.editDraft(from: event)?.schedule else { return false }

let step = DraftNudge.nudgeStepFromKeyboard(altKey: altKey)
let isStartAllowed: (Date) -> Bool = { [weak self] nextStart in
guard let self else { return false }
let calendar = EffectiveTimeZone.calendar
let start = calendar.startOfDay(for: nextStart)
let viewStart = calendar.startOfDay(for: self.startOfView)
let viewEnd = calendar.startOfDay(for: self.endOfView)
return start >= viewStart && start <= viewEnd
}

let baseline = event
let nextSchedule: DraftSchedule
let nextEdge: EventEdge?

if let edge = focusStore.edge {
guard let result = DraftNudge.nudgeScheduleEdgeByKeyboard(
schedule: current,
edge: edge.scheduleEdge,
key: key,
step: step
) else {
return false
}
nextSchedule = result.schedule
nextEdge = EventEdge(result.edge)
} else {
guard let moved = DraftNudge.nudgeScheduleByKeyboard(
schedule: current,
key: key,
isStartAllowed: isStartAllowed,
step: step
) else {
return false
}
nextSchedule = moved
nextEdge = nil
}

let nudged = LocalEventExpansion.copy(
baseline,
schedule: EventScheduleDraftMapping.eventSchedule(from: nextSchedule, event: baseline)
)

if let index = loadedEvents.firstIndex(where: { $0.id == nudged.id }) {
loadedEvents[index] = nudged
}

if let nextEdge {
focusStore.setEdge(
eventId: nudged.id,
edge: nextEdge,
announcement: focusStore.edgeAnnouncement,
isAllDay: focusStore.edgeFocusIsAllDay
)
}

if !undoStore.isRestoringHistory() {
if UndoStore.isUndoableRecurrence(baseline), UndoStore.isUndoableRecurrence(nudged) {
undoStore.record(.edit(id: baseline.id, before: baseline, after: nudged))
} else {
undoStore.record(.unrecorded)
}
}

let predecessor = focusedEventNudgePersistTask
let revision = sessionRevision
focusedEventNudgePersistSerial += 1
let persistSerial = focusedEventNudgePersistSerial
focusedEventNudgePersistTask = Task { [weak self] in
await predecessor?.value
guard let self else { return }
guard revision == self.sessionRevision,
persistSerial == self.focusedEventNudgePersistSerial
else { return }
await self.replaceEvent(with: nudged, scheduleBaseline: baseline)
if persistSerial == self.focusedEventNudgePersistSerial {
self.focusedEventNudgePersistTask = nil
}
}
rebuildPresentation()
return true
}

/// Undo must run after the in-flight nudge replace (and its server round-trip) so inverse order stays coherent.
func awaitFocusedEventNudgePersistenceBeforeUndo() async {
await focusedEventNudgePersistTask?.value
while eventsStore.inFlightMutations > 0 {
try? await Task.sleep(for: .milliseconds(25))
}
}

/// Lets unit tests wait for async replace without reaching into internal task state.
@discardableResult
public func waitForPendingFocusedEventNudgeForTests(
timeout: Duration = .seconds(3)
) async -> Bool {
let deadline = ContinuousClock.now + timeout
while ContinuousClock.now < deadline {
if focusedEventNudgePersistTask == nil, eventsStore.inFlightMutations == 0 {
return true
}
try? await Task.sleep(for: .milliseconds(25))
}
return false
}

func isFocusedSavedEventScheduleLocked(_ event: Event) -> Bool {
let isBusy: Bool = {
if case .busy = event.content { return true }
return false
}()
let lookup = CalendarLookupBuilder.build(calendars)
return GridEventAccess.isGridEventScheduleLocked(
lookup: lookup,
calendarId: event.calendarId,
isBusy: isBusy,
isProviderManaged: event.providerManaged == true
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ public final class NativeCalendarRootModel {
didSet { syncSidebandWithLoadedEvents() }
}
private var refreshTask: Task<Void, Never>?
var focusedEventNudgePersistTask: Task<Void, Never>?
var focusedEventNudgePersistSerial = 0
var focusLayoutCards: [FocusLayoutCard] = []
var eventJumpHintLabels: [EventJumpChipHint] = []
var quickTimeSlotHintLabels: [QuickTimeSlotHint] = []
Expand Down
Loading
Loading