Improve native sheet text undo, range selection and context actions
12 files changed,
+895
−199
docs/superpowers/specs/2026-09-25-native-interaction-parity.md+40 −0native/PARITY.md+29 −2native/apple/Sources/SheetsKit/SheetCell.swift+37 −16native/apple/Sources/SheetsKit/SheetCellEditor.swift+105 −53native/apple/Sources/SheetsKit/SheetCellInteraction.swift+97 −0native/apple/Sources/SheetsKit/SheetComments.swift+2 −2native/apple/Sources/SheetsKit/SheetGrid.swift+154 −67native/apple/Sources/SheetsKit/SheetModel.swift+34 −12native/apple/Sources/SheetsKit/SheetSelectionDecoration.swift+37 −0native/apple/Sources/SheetsKit/SheetWrites.swift+1 −1native/apple/Sources/SheetsKit/SheetsEditorCommands.swift+85 −46native/apple/Tests/SheetsKitTests/SheetQualityOfLifeTests.swift+274 −0
CI passed — run details
CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s) === vet === CGO_ENABLED=0 go vet ./... === fmt === === build === CGO_ENABLED=0 go build ./... === migrations === rastrillo migration check rastrillo migration check: models and migrations agree === go-test === CGO_ENABLED=0 go test -count=1 ./... ? amadan.net/oficina/sheets/cmd/sheets [no test files] ok amadan.net/oficina/sheets/internal/guardstest 0.086s ok amadan.net/oficina/sheets/internal/sheets 13.935s ok amadan.net/oficina/sheets/internal/sheets/calc 0.012s ok amadan.net/oficina/sheets/internal/sheets/format 0.003s ok amadan.net/oficina/sheets/internal/sheets/live 0.054s ok amadan.net/oficina/sheets/internal/sheets/seal 0.003s ok amadan.net/oficina/sheets/internal/sheets/sealtest 0.003s ok amadan.net/oficina/sheets/internal/sheets/store 3.355s ok amadan.net/oficina/sheets/internal/sheets/xlsx 0.340s ? amadan.net/oficina/sheets/tools/gencorpus [no test files] === race === go test -race -count=1 ./internal/sheets/store/... ./internal/sheets/live/... ok amadan.net/oficina/sheets/internal/sheets/store 58.048s ok amadan.net/oficina/sheets/internal/sheets/live 1.069s
diff --git a/docs/superpowers/specs/2026-09-25-native-interaction-parity.md b/docs/superpowers/specs/2026-09-25-native-interaction-parity.md| new file mode 100644 |
| index 0000000..fa63c71 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-09-25-native-interaction-parity.md |
| @@ -0,0 +1,40 @@ |
| +# Native Sheets interaction parity |
| + |
| +Scope: reconcile current main with the released Mac improvements, then improve daily cell editing and selection. Preserve main's connectivity/reconnect policy and shared chrome. No new server mutation routes or optimistic persistence changes. |
| + |
| +## Behavior contract |
| + |
| +| Interaction | Web/source behavior | Native target for this pass | |
| +| --- | --- | --- | |
| +| Undo while editing | Browser text undo; commands.js avoids text fields | Cmd-Z / Shift-Cmd-Z undo/redo local typing; never invoke sheet undo while the field owns focus | |
| +| Undo committed input | Canonical server input undo | Existing Edit > Undo route remains; no invented local/server redo stack | |
| +| Clear | Grid Delete/Backspace handler posts changes once | Both delete keys clear selected cells once; while editing they delete text | |
| +| Context menu | Vendored menu disabled in grid.js | Native secondary-click / Control-click menu: Cut, Copy, Paste, Clear, Fill Down/Right; preserve an existing range when clicked within it, select an outside cell before acting; honor access and pending edits | |
| +| Range selection | Mouse drag, Shift extension, rectangular selection | Native mouse drag, Shift-click and Shift-arrow extension with stable anchor; variable row/column sizes respected | |
| +| Selection paint | One rectangular range boundary | Outer blue outline only, with subtle range tint and normal internal grid rules | |
| +| Keyboard navigation | Arrows, Enter/Tab and modifiers | Arrows navigate; Shift-arrows extend; Tab/Shift-Tab move between cells; Return edits; editor Return/Tab commit, Shift variants move backward | |
| +| Escape | Formula edit cancellation; cancels pending clipboard move | First Escape cancels the edit and keeps the active cell. Outside editing, Escape collapses a range to the anchor and cancels clipboard provenance. Keep an active cell rather than introduce an invisible mutation target or an empty-selection model | |
| +| Clipboard | TSV and translated formula references; cut waits for paste | Existing native TSV/formula provenance retained; menu invokes the same commands as keyboard | |
| +| Resize/autofit | Boundary drag/double-click | Preserve tested native handles, thin dividers and asynchronous confirmed saves | |
| +| Selection during pending writes | Serialization/confirmation | Keep current save/discard safety. A failed save must retain draft and selection; no mouse gesture bypasses it | |
| + |
| +Apple's Numbers shortcut reference confirms Shift-arrow selection extension, Control-click shortcut menus, and Escape cancellation in the formula editor: https://support.apple.com/guide/numbers/tana45192591/mac . The Escape range-collapse choice above is this app's deliberate convention, not a claim of universal Mac behavior. |
| + |
| +## Catalogued follow-ups |
| + |
| +- Formula reference picking while editing (`formulapick.js`): add once native selection/edit focus interaction is stable; requires preserving insertion position and differentiating reference picking from committing a draft. |
| +- Deferred cut/move parity (`clipboard.js`): native currently clears on Cut; moving source cells only after paste needs a server-confirmed move transaction and failure tests. Do not silently alter data semantics in this UI pass. |
| +- Discontiguous Cmd-click ranges: current server/native format and selection APIs take one rectangle. Add only with an explicit multi-range operation contract. |
| +- Continuous edge autoscroll, Page Up/Down and populated-region Cmd-arrow navigation: assess after native drag/keyboard integration; do not claim these merely from rectangular selection tests. |
| +- Larger workbook virtualization/performance: profile real wide sheets before asserting a speedup. |
| + |
| +Verification: real NSWindow mouse/key events for typing undo/redo, delete routing, drag/Shift extension, menu targeting and permission guards; rendered selection fixture; existing failed-save/resize tests; full Swift suite and iOS build. Track actual results in native/PARITY.md. |
| + |
| +## Review invariants |
| + |
| +GPT-6 Astra approved the direction subject to these explicit invariants: store the stable anchor and moving endpoint separately from normalized range bounds; local typing undo belongs to one cell editing session and includes the initial type-to-edit character; menus are suppressed while edits/writes are pending and actions revalidate permissions and the captured selection. Native text/IME ownership must survive. Exercise reverse drag, extension across the anchor, undo across editing sessions, failed-save menu targeting, variable sizes, scrolling and A1 reverse-navigation clamping. |
| + |
| +Review: GPT-6 Astra reviewed the design and implementation. Both blocking findings |
| +(active anchor targeting and queued menu revalidation) are fixed. Corrected forward |
| +Delete coverage passes with the full suite: 12 XCTest cases (one credential-gated |
| +skip), plus 21 Swift Testing cases. iOS Simulator build passes. |
diff --git a/native/PARITY.md b/native/PARITY.md| index 2a5f6bb..f756787 100644 |
| --- a/native/PARITY.md |
| +++ b/native/PARITY.md |
| @@ -26,8 +26,35 @@ coverage does not establish that every native control has been exercised. |
| | Unsaved edit protection | Draft state, retry/discard, navigation confirmation and Mac close/quit guard | Actual iPad forced HTTP503 retains draft, disables new formula edits/selection, and Retry succeeds; Swift pending-batch/draft tests; Mac close/quit prompts compile | |
| Both simulator UI runs passed the explicitly documented flow in `REVIEW.md`. |
| -Mac executable builds passed, but the environment did not grant screen capture |
| -access, so no Mac screenshot is accepted as verification. A signed iOS device |
| +The original simulator review did not include Mac screen capture. The September 25 |
| +Mac pass below adds native event tests and an NSHostingView render fixture. A signed iOS device |
| build was produced; physical-device execution and exhaustive native interaction |
| coverage remain separate validation. |
| The implementation contains no embedded web editor. |
| + |
| + |
| +## Mac interaction pass — 2026-09-25 |
| + |
| +Behavior and remaining gaps are catalogued in |
| +[Native Sheets interaction parity](../docs/superpowers/specs/2026-09-25-native-interaction-parity.md). |
| +This branch includes current main's connectivity/reconnect changes and shared native chrome. |
| + |
| +- Real NSWindow events exercise type-to-edit, Backspace/forward Delete routing, |
| + reverse drag, Shift-click/arrows, Escape range collapse, scrolled dragging, variable |
| + sizes, and reverse navigation at A1. Existing first-character and failed-save tests remain. |
| +- The production text-undo handler exercises starter-character undo/redo, subsequent |
| + typing undo/redo, and isolation between cell editor sessions. No network requests occur. |
| +- NSMenu construction/action tests cover inside/outside range targeting, read-only |
| + permissions, dirty-edit suppression and selection changes before queued actions execute. |
| + Opening the system popup through an actual secondary mouse event is not automated. |
| +- Reverse-range formula-bar commits verify the request coordinates use the active anchor, |
| + with discard and backward commit destinations checked separately. |
| +- A light-mode NSHostingView bitmap of a scrolled 3×3 selection was inspected: one blue |
| + outer outline, neutral internal rules and subtle fill. This is an offscreen view render, |
| + not a claim of a full desktop screenshot or exhaustive dark-mode verification. |
| +- Full Swift tests and the iOS Simulator build pass. The authenticated export test remains |
| + opt-in and skipped without fixture credentials. Shift-Return's physical keyboard modifier |
| + path and full IME composition are not independently exercised in this pass. |
| + |
| +Deferred: formula reference picking, deferred cut/move, discontiguous ranges, |
| +continuous edge autoscroll, Page/Cmd-arrow navigation, and large-workbook profiling. |
diff --git a/native/apple/Sources/SheetsKit/SheetCell.swift b/native/apple/Sources/SheetsKit/SheetCell.swift| index 205f77e..acc9c55 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetCell.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetCell.swift |
| @@ -5,6 +5,9 @@ struct SheetCell: View { |
| let point: Coordinate |
| @Binding var editing: Coordinate? |
| var select: () -> Void |
| + var extendSelection: (Bool) -> Void = { _ in } |
| + var dragSelection: (CGPoint) -> Void = { _ in } |
| + var focusGrid: () -> Void = {} |
| var beginEditing: () -> Void = {} |
| var endEditing: () -> Void |
| var columnWidth: Double? = nil |
| @@ -20,28 +23,36 @@ struct SheetCell: View { |
| ? .center : style?.a == "right" || (style?.a == nil && cell?.n == true) ? .trailing : .leading |
| return Group { |
| #if os(macOS) |
| - if editing == point { |
| - SheetCellEditor(text: $model.draft, enabled: model.mayWrite && !model.busy, |
| - label: String(format: sheetsMacString("Edit %@"), point.ref), |
| - commit: { commit(right: $0) }, cancel: { |
| - guard !model.busy else { return } |
| - model.discardAll(); endEditing() |
| - }) |
| + if editing == point { |
| + SheetCellEditor( |
| + text: $model.draft, original: model.originalDraft, |
| + registerFocusOwner: { model.cellEditorHasFocus = $0 }, |
| + enabled: model.mayWrite && !model.busy, |
| + label: String(format: sheetsMacString("Edit %@"), point.ref), |
| + commit: { commit(right: $0, backwards: $1) }, |
| + cancel: { |
| + guard !model.busy else { return } |
| + model.discardAll() |
| + endEditing() |
| + } |
| + ) |
| .padding(.horizontal, 5) |
| .frame(width: width(point.c), height: height(point.r)) |
| .background(fill(style)) |
| .overlay { Rectangle().strokeBorder(Color.accentColor, lineWidth: 2) } |
| .accessibilityLabel(String(format: sheetsMacString("Edit %@"), point.ref)) |
| - } else { display(cell, style: style, alignment: alignment) } |
| + } else { |
| + display(cell, style: style, alignment: alignment) |
| + } |
| #else |
| - display(cell, style: style, alignment: alignment) |
| + display(cell, style: style, alignment: alignment) |
| #endif |
| } |
| } |
| - private func commit(right: Bool) { |
| + private func commit(right: Bool, backwards: Bool) { |
| guard !model.busy, model.selection.start == point else { return } |
| Task { |
| - await model.commit(moveRight: right) |
| + await model.commit(moveRight: right, backwards: backwards) |
| if !model.hasUnconfirmedChanges { endEditing() } |
| } |
| } |
| @@ -52,13 +63,23 @@ struct SheetCell: View { |
| .frame(width: width(point.c), height: height(point.r), alignment: alignment) |
| .foregroundStyle(ink(style)).background(fill(style)) |
| .contentShape(.rect) |
| + .overlay { SheetSelectionDecoration(model: model, point: point) } |
| + }.buttonStyle(.plain) |
| + #if os(macOS) |
| .overlay { |
| - Rectangle().strokeBorder( |
| - model.selection.contains(point) ? Color.accentColor : Color.secondary.opacity(0.2), |
| - lineWidth: model.selection.contains(point) ? 2 : 0.5) |
| + SheetCellInteraction( |
| + model: model, point: point, select: extendSelection, |
| + edit: beginEditing, drag: dragSelection, focus: focusGrid) |
| } |
| - }.buttonStyle(.plain) |
| - .simultaneousGesture(TapGesture(count: 2).onEnded { if model.mayWrite, !model.busy, !model.hasUnconfirmedChanges { model.select(point); beginEditing() } }) |
| + #else |
| + .simultaneousGesture( |
| + TapGesture(count: 2).onEnded { |
| + if model.mayWrite, !model.busy, !model.hasUnconfirmedChanges { |
| + model.select(point) |
| + beginEditing() |
| + } |
| + }) |
| + #endif |
| .accessibilityLabel("\(point.ref), \(cell?.display ?? "Empty")") |
| .accessibilityValue(style?.lc == true ? "Imported colours have low contrast" : "") |
| .accessibilityAddTraits(model.selection.contains(point) ? .isSelected : []) |
diff --git a/native/apple/Sources/SheetsKit/SheetCellEditor.swift b/native/apple/Sources/SheetsKit/SheetCellEditor.swift| index 162aef2..026567d 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetCellEditor.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetCellEditor.swift |
| @@ -1,73 +1,125 @@ |
| #if os(macOS) |
| -import AppKit |
| -import SwiftUI |
| + import AppKit |
| + import SwiftUI |
| -/// Position the field editor's caret as part of gaining focus, before another |
| -/// keyboard event can replace the character that started cell editing. |
| -struct SheetCellEditor: NSViewRepresentable { |
| + /// Position the field editor's caret as part of gaining focus, before another |
| + /// keyboard event can replace the character that started cell editing. |
| + struct SheetCellEditor: NSViewRepresentable { |
| @Binding var text: String |
| + let original: String |
| + let registerFocusOwner: (@escaping () -> Bool) -> Void |
| let enabled: Bool |
| let label: String |
| - let commit: (Bool) -> Void |
| + let commit: (Bool, Bool) -> Void |
| let cancel: () -> Void |
| func makeCoordinator() -> Coordinator { Coordinator(self) } |
| func makeNSView(context: Context) -> CellTextField { |
| - let field = CellTextField() |
| - field.isBordered = false; field.drawsBackground = false |
| - // The cell already draws its selection border around the full cell. |
| - field.focusRingType = .none |
| - field.font = .systemFont(ofSize: 13) |
| - field.delegate = context.coordinator |
| - field.stringValue = text |
| - field.setAccessibilityLabel(label) |
| - return field |
| + let field = CellTextField() |
| + field.isBordered = false |
| + field.drawsBackground = false |
| + // The cell already draws its selection border around the full cell. |
| + field.focusRingType = .none |
| + field.font = .systemFont(ofSize: 13) |
| + field.delegate = context.coordinator |
| + field.stringValue = text |
| + field.initialValue = original |
| + field.synchronize = { [weak coordinator = context.coordinator] value in |
| + coordinator?.parent.text = value |
| + } |
| + field.observeUndo() |
| + registerFocusOwner { [weak field] in |
| + guard let field, let window = field.window, let editor = field.currentEditor() else { |
| + return false |
| + } |
| + return window.firstResponder === editor |
| + } |
| + field.setAccessibilityLabel(label) |
| + return field |
| } |
| func updateNSView(_ field: CellTextField, context: Context) { |
| - context.coordinator.parent = self |
| - let regainedEditing = enabled && !field.isEnabled |
| - field.isEnabled = enabled |
| - if field.stringValue != text { |
| - field.stringValue = text |
| - if let editor = field.currentEditor() as? NSTextView { |
| - editor.setSelectedRange(NSRange(location: text.utf16.count, length: 0)) |
| - } |
| + context.coordinator.parent = self |
| + let regainedEditing = enabled && !field.isEnabled |
| + field.isEnabled = enabled |
| + if field.stringValue != text { |
| + field.stringValue = text |
| + if let editor = field.currentEditor() as? NSTextView { |
| + editor.setSelectedRange(NSRange(location: text.utf16.count, length: 0)) |
| } |
| - if regainedEditing { field.window?.makeFirstResponder(field) } |
| + } |
| + if regainedEditing { field.window?.makeFirstResponder(field) } |
| } |
| - final class CellTextField: NSTextField { |
| - override func viewDidMoveToWindow() { |
| - super.viewDidMoveToWindow() |
| - guard let window else { return } |
| - // The representable must be attached before it can become responder. |
| - DispatchQueue.main.async { [weak self, weak window] in |
| - guard let self, let window, self.window === window, self.isEnabled else { return } |
| - window.makeFirstResponder(self) |
| - } |
| + final class CellTextField: NSTextField, NSTextViewDelegate { |
| + private let typingUndo = UndoManager() |
| + private var preparedUndo = false |
| + var initialValue = "" |
| + var synchronize: ((String) -> Void)? |
| + func observeUndo() { |
| + NotificationCenter.default.addObserver( |
| + self, selector: #selector(didUndoOrRedo), name: .NSUndoManagerDidUndoChange, |
| + object: typingUndo) |
| + NotificationCenter.default.addObserver( |
| + self, selector: #selector(didUndoOrRedo), name: .NSUndoManagerDidRedoChange, |
| + object: typingUndo) |
| + } |
| + @objc private func didUndoOrRedo() { |
| + guard let editor = currentEditor() as? NSTextView else { return } |
| + synchronize?(editor.string) |
| + } |
| + func undoManager(for view: NSTextView) -> UndoManager? { typingUndo } |
| + override func viewDidMoveToWindow() { |
| + super.viewDidMoveToWindow() |
| + guard let window else { |
| + typingUndo.removeAllActions() |
| + return |
| + } |
| + // The representable must be attached before it can become responder. |
| + DispatchQueue.main.async { [weak self, weak window] in |
| + guard let self, let window, self.window === window, self.isEnabled else { return } |
| + window.makeFirstResponder(self) |
| } |
| - override func becomeFirstResponder() -> Bool { |
| - let accepted = super.becomeFirstResponder() |
| - if accepted, let editor = currentEditor() as? NSTextView { |
| - editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) |
| + } |
| + override func becomeFirstResponder() -> Bool { |
| + let accepted = super.becomeFirstResponder() |
| + if accepted, let editor = currentEditor() as? NSTextView { |
| + editor.allowsUndo = true |
| + if !preparedUndo { |
| + preparedUndo = true |
| + if stringValue != initialValue { |
| + let pending = stringValue |
| + editor.allowsUndo = false |
| + stringValue = initialValue |
| + editor.string = initialValue |
| + editor.allowsUndo = true |
| + editor.insertText( |
| + pending, replacementRange: NSRange(location: 0, length: initialValue.utf16.count)) |
| + editor.breakUndoCoalescing() |
| } |
| - return accepted |
| + } |
| + editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) |
| } |
| + return accepted |
| + } |
| } |
| @MainActor final class Coordinator: NSObject, NSTextFieldDelegate { |
| - var parent: SheetCellEditor |
| - init(_ parent: SheetCellEditor) { self.parent = parent } |
| - func controlTextDidChange(_ notification: Notification) { |
| - guard let field = notification.object as? NSTextField else { return } |
| - parent.text = field.stringValue |
| - } |
| - func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { |
| - switch selector { |
| - case #selector(NSResponder.insertNewline(_:)): parent.commit(false) |
| - case #selector(NSResponder.insertTab(_:)): parent.commit(true) |
| - case #selector(NSResponder.cancelOperation(_:)): parent.cancel() |
| - default: return false |
| - } |
| - return true |
| + var parent: SheetCellEditor |
| + init(_ parent: SheetCellEditor) { self.parent = parent } |
| + func controlTextDidChange(_ notification: Notification) { |
| + guard let field = notification.object as? NSTextField else { return } |
| + parent.text = field.stringValue |
| + } |
| + func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) |
| + -> Bool |
| + { |
| + switch selector { |
| + case #selector(NSResponder.insertNewline(_:)): |
| + parent.commit(false, NSApp.currentEvent?.modifierFlags.contains(.shift) == true) |
| + case #selector(NSResponder.insertTab(_:)): parent.commit(true, false) |
| + case #selector(NSResponder.insertBacktab(_:)): parent.commit(true, true) |
| + case #selector(NSResponder.cancelOperation(_:)): parent.cancel() |
| + default: return false |
| } |
| + return true |
| + } |
| } |
| -} |
| + } |
| #endif |
diff --git a/native/apple/Sources/SheetsKit/SheetCellInteraction.swift b/native/apple/Sources/SheetsKit/SheetCellInteraction.swift| new file mode 100644 |
| index 0000000..d382127 |
| --- /dev/null |
| +++ b/native/apple/Sources/SheetsKit/SheetCellInteraction.swift |
| @@ -0,0 +1,97 @@ |
| +#if os(macOS) |
| + import AppKit |
| + import SwiftUI |
| + |
| + /// AppKit owns mouse tracking and menu targeting; SwiftUI owns the cell content. |
| + struct SheetCellInteraction: NSViewRepresentable { |
| + let model: SheetModel |
| + let point: Coordinate |
| + let select: (Bool) -> Void |
| + let edit: () -> Void |
| + let drag: (CGPoint) -> Void |
| + let focus: () -> Void |
| + func makeNSView(context: Context) -> MouseView { MouseView() } |
| + func updateNSView(_ view: MouseView, context: Context) { view.actions = self } |
| + final class MouseView: NSControl { |
| + var actions: SheetCellInteraction? |
| + private var tracking = false |
| + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } |
| + override func mouseDown(with event: NSEvent) { |
| + guard let actions else { return } |
| + if event.modifierFlags.contains(.control) { |
| + super.rightMouseDown(with: event) |
| + return |
| + } |
| + actions.select(event.modifierFlags.contains(.shift)) |
| + tracking = !actions.model.busy && !actions.model.hasUnconfirmedChanges |
| + if event.clickCount == 2, tracking { |
| + actions.edit() |
| + tracking = false |
| + } |
| + } |
| + override func mouseDragged(with event: NSEvent) { |
| + guard tracking else { return } |
| + let p = convert(event.locationInWindow, from: nil) |
| + actions?.drag(CGPoint(x: p.x, y: bounds.height - p.y)) |
| + autoscroll(with: event) |
| + } |
| + override func mouseUp(with event: NSEvent) { tracking = false } |
| + override func menu(for event: NSEvent) -> NSMenu? { |
| + guard let actions, !actions.model.busy, !actions.model.hasUnconfirmedChanges else { |
| + return nil |
| + } |
| + if !actions.model.selection.contains(actions.point) { actions.select(false) } |
| + actions.focus() |
| + let menu = NSMenu() |
| + menu.autoenablesItems = false |
| + let writable = actions.model.mayWrite |
| + for (label, command) in [ |
| + ("Cut", "cut"), ("Copy", "copy"), ("Paste", "paste"), |
| + ("Clear contents", "clear"), ("Fill down", "down"), ("Fill right", "right"), |
| + ] { |
| + if command == "clear" || command == "down" { menu.addItem(.separator()) } |
| + let item = NSMenuItem( |
| + title: sheetsMacString(label), action: #selector(performCommand(_:)), keyEquivalent: "") |
| + item.target = self |
| + item.representedObject = MenuTarget( |
| + command: command, range: actions.model.selection, grid: actions.model.grid) |
| + item.isEnabled = command == "copy" || writable |
| + menu.addItem(item) |
| + } |
| + return menu |
| + } |
| + private struct MenuTarget { |
| + let command: String |
| + let range: CellRange |
| + let grid: String |
| + } |
| + @objc private func performCommand(_ item: NSMenuItem) { |
| + guard let model = actions?.model, !model.busy, !model.hasUnconfirmedChanges, |
| + let target = item.representedObject as? MenuTarget, target.range == model.selection, |
| + target.grid == model.grid |
| + else { return } |
| + let command = target.command |
| + if command == "copy" { |
| + model.copy() |
| + return |
| + } |
| + guard model.mayWrite else { return } |
| + Task { |
| + guard !model.busy, !model.hasUnconfirmedChanges, model.mayWrite, |
| + target.range == model.selection, target.grid == model.grid |
| + else { return } |
| + switch command { |
| + case "cut": |
| + model.copy() |
| + await model.clear() |
| + case "paste": await model.paste() |
| + case "clear": await model.clear() |
| + case "down": await model.fill(down: true) |
| + case "right": await model.fill(down: false) |
| + default: break |
| + } |
| + } |
| + } |
| + } |
| + } |
| +#endif |
diff --git a/native/apple/Sources/SheetsKit/SheetComments.swift b/native/apple/Sources/SheetsKit/SheetComments.swift| index 1e581b6..1c3ab1e 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetComments.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetComments.swift |
| @@ -22,14 +22,14 @@ struct SheetComments: View { |
| var body: some View { |
| Group { |
| if model.page?.MayComment == true { |
| - Text("Comment on " + model.selection.start.ref) |
| + Text("Comment on " + model.selectionAnchor.ref) |
| TextField("Comment", text: $bodyText, axis: .vertical) |
| Button("Add comment") { |
| Task { |
| await model.command( |
| "/comments", |
| [ |
| - "r": String(model.selection.start.r), "c": String(model.selection.start.c), |
| + "r": String(model.selectionAnchor.r), "c": String(model.selectionAnchor.c), |
| "body": bodyText, |
| ]) |
| if model.lastNotice == "added" { bodyText = "" } |
diff --git a/native/apple/Sources/SheetsKit/SheetGrid.swift b/native/apple/Sources/SheetsKit/SheetGrid.swift| index d01be06..d5822b5 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetGrid.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetGrid.swift |
| @@ -14,25 +14,9 @@ struct SheetGrid: View { |
| Section { |
| ForEach(0..<model.rows, id: \.self) { row in |
| HStack(spacing: 0) { |
| - ZStack(alignment: .bottom) { |
| - Button { selectRow(row) } label: { |
| - Text(String(row + 1)).frame(width: 48, height: height(row)).background(.quaternary) |
| - }.buttonStyle(.plain).accessibilityLabel(String(format: sheetsMacString("Select row %d"), row + 1)) |
| - #if os(macOS) |
| - resizeHandle(axis: "row", index: row).frame(width: 48, height: 8) |
| - #else |
| - Color.clear.frame(width: 48, height: 8) |
| - .contentShape(.rect).gesture(resizeGesture(axis: "row", index: row)) |
| - .accessibilityHidden(true) |
| - #endif |
| - } |
| + rowHeader(row) |
| ForEach(0..<model.cols, id: \.self) { col in |
| - SheetCell( |
| - model: model, point: Coordinate(r: row, c: col), editing: $editing, |
| - select: { select(Coordinate(r: row, c: col)) }, |
| - beginEditing: { edit(Coordinate(r: row, c: col)) }, |
| - endEditing: { editing = nil; focused = true }, |
| - columnWidth: width(col), rowHeight: height(row)) |
| + gridCell(row: row, col: col) |
| } |
| } |
| } |
| @@ -40,23 +24,7 @@ struct SheetGrid: View { |
| HStack(spacing: 0) { |
| Text("").frame(width: 48, height: 32) |
| ForEach(0..<model.cols, id: \.self) { col in |
| - ZStack(alignment: .trailing) { |
| - Button { selectColumn(col) } label: { |
| - Text(Coordinate(r: 0, c: col).ref.dropLast()) |
| - .frame(width: width(col), height: 32) |
| - .contentShape(.rect) |
| - }.buttonStyle(.plain) |
| - .accessibilityLabel(String(format: sheetsMacString("Select column %@"), String(Coordinate(r: 0, c: col).ref.dropLast()))) |
| - #if os(macOS) |
| - resizeHandle(axis: "col", index: col).frame(width: 8, height: 32) |
| - .help(sheetsMacString("Double-click to fit column to contents")) |
| - #else |
| - Color.clear.frame(width: 8, height: 32) |
| - #endif |
| - } |
| - #if os(macOS) |
| - .contextMenu { Button(sheetsMacString("Fit column to contents")) { Task { await model.fitColumn(col) } } } |
| - #endif |
| + columnHeader(col) |
| } |
| }.background(.regularMaterial) |
| } |
| @@ -68,56 +36,147 @@ struct SheetGrid: View { |
| .focused($focused) |
| .background { SheetSelectionObserver(model: model, editing: $editing) } |
| .onChange(of: focused) { _, value in model.gridFocused = value } |
| - .onDisappear { model.gridFocused = false; if !savingResize { resizing = nil } } |
| + .onDisappear { |
| + model.gridFocused = false |
| + if !savingResize { resizing = nil } |
| + } |
| .onChange(of: draggingResize) { _, active in |
| if !active, !savingResize { resizing = nil } |
| } |
| - .onKeyPress(.leftArrow) { move(0, -1) } |
| - .onKeyPress(.rightArrow) { move(0, 1) } |
| - .onKeyPress(.upArrow) { move(-1, 0) } |
| - .onKeyPress(.downArrow) { move(1, 0) } |
| - .onKeyPress(.delete) { |
| + .onKeyPress(.leftArrow, phases: [.down, .repeat]) { |
| + move(0, -1, extending: $0.modifiers.contains(.shift)) |
| + } |
| + .onKeyPress(.rightArrow, phases: [.down, .repeat]) { |
| + move(0, 1, extending: $0.modifiers.contains(.shift)) |
| + } |
| + .onKeyPress(.upArrow, phases: [.down, .repeat]) { |
| + move(-1, 0, extending: $0.modifiers.contains(.shift)) |
| + } |
| + .onKeyPress(.downArrow, phases: [.down, .repeat]) { |
| + move(1, 0, extending: $0.modifiers.contains(.shift)) |
| + } |
| + .onKeyPress(characters: CharacterSet(charactersIn: "\u{8}\u{7f}\u{f728}")) { _ in |
| guard editing == nil, model.mayWrite else { return .ignored } |
| Task { await model.clear() } |
| return .handled |
| } |
| + .onKeyPress(.escape) { |
| + guard editing == nil, !model.busy, !model.hasUnconfirmedChanges else { return .ignored } |
| + model.clearCopyProvenance() |
| + model.select(model.selectionAnchor) |
| + return .handled |
| + } |
| + .onKeyPress(.tab, phases: [.down, .repeat]) { |
| + move(0, $0.modifiers.contains(.shift) ? -1 : 1) |
| + } |
| .onKeyPress(.return) { |
| guard editing == nil, model.mayWrite else { return .ignored } |
| edit(model.selection.start) |
| return .handled |
| } |
| - .onKeyPress(characters: .alphanumerics.union(.punctuationCharacters).union(.symbols).union(.whitespaces)) { |
| + .onKeyPress( |
| + characters: .alphanumerics.union(.punctuationCharacters).union(.symbols).union(.whitespaces) |
| + ) { |
| press in |
| - guard model.mayWrite, !model.busy, !press.characters.contains(where: { $0 == "\t" || $0.isNewline }), |
| + guard model.mayWrite, !model.busy, |
| + !press.characters.contains(where: { $0 == "\t" || $0.isNewline }), |
| press.modifiers.intersection([.command, .control, .option]).isEmpty |
| else { return .ignored } |
| + if editing != nil, model.cellEditorHasFocus() { return .ignored } |
| if editing != nil { |
| // Events arriving before the cell's native field gains focus still |
| // belong to this edit. Once focused, NSTextField handles them itself. |
| model.draft += press.characters |
| } else { |
| guard !model.hasUnconfirmedChanges else { return .ignored } |
| + if model.selection.count > 1 { model.select(model.selectionAnchor) } |
| model.draft = press.characters |
| edit(model.selection.start) |
| } |
| return .handled |
| } |
| } |
| + private func columnHeader(_ col: Int) -> some View { |
| + ZStack(alignment: .trailing) { |
| + Button { |
| + selectColumn(col) |
| + } label: { |
| + Text(Coordinate(r: 0, c: col).ref.dropLast()) |
| + .frame(width: width(col), height: 32) |
| + .contentShape(.rect) |
| + }.buttonStyle(.plain) |
| + .accessibilityLabel( |
| + String( |
| + format: sheetsMacString("Select column %@"), |
| + String(Coordinate(r: 0, c: col).ref.dropLast()))) |
| + #if os(macOS) |
| + resizeHandle(axis: "col", index: col).frame(width: 8, height: 32) |
| + .help(sheetsMacString("Double-click to fit column to contents")) |
| + #else |
| + Color.clear.frame(width: 8, height: 32) |
| + #endif |
| + } |
| + #if os(macOS) |
| + .contextMenu { |
| + Button(sheetsMacString("Fit column to contents")) { Task { await model.fitColumn(col) } } |
| + } |
| + #endif |
| + } |
| + private func rowHeader(_ row: Int) -> some View { |
| + ZStack(alignment: .bottom) { |
| + Button { |
| + selectRow(row) |
| + } label: { |
| + Text(String(row + 1)).frame(width: 48, height: height(row)).background(.quaternary) |
| + }.buttonStyle(.plain).accessibilityLabel( |
| + String(format: sheetsMacString("Select row %d"), row + 1)) |
| + #if os(macOS) |
| + resizeHandle(axis: "row", index: row).frame(width: 48, height: 8) |
| + #else |
| + Color.clear.frame(width: 48, height: 8) |
| + .contentShape(.rect).gesture(resizeGesture(axis: "row", index: row)) |
| + .accessibilityHidden(true) |
| + #endif |
| + } |
| + } |
| + private func gridCell(row: Int, col: Int) -> some View { |
| + SheetCell( |
| + model: model, point: Coordinate(r: row, c: col), editing: $editing, |
| + select: { select(Coordinate(r: row, c: col)) }, |
| + extendSelection: { shift in select(Coordinate(r: row, c: col), extending: shift) }, |
| + dragSelection: { location in drag(from: Coordinate(r: row, c: col), location: location) }, |
| + focusGrid: { focused = true }, |
| + beginEditing: { edit(Coordinate(r: row, c: col)) }, |
| + endEditing: { |
| + editing = nil |
| + focused = true |
| + }, |
| + columnWidth: width(col), rowHeight: height(row)) |
| + } |
| private func edit(_ point: Coordinate) { |
| guard model.mayWrite, !model.busy, model.failedWrites.isEmpty else { return } |
| #if os(macOS) |
| - editing = point |
| + if model.selection.count > 1 { model.select(model.selectionAnchor) } |
| + editing = model.selection.start |
| #else |
| - beginEditing() |
| + beginEditing() |
| #endif |
| } |
| - private func select(_ point: Coordinate) { |
| + private func select(_ point: Coordinate, extending: Bool = false) { |
| + if extending, !model.hasUnconfirmedChanges, !model.busy { |
| + editing = nil |
| + model.extendSelection(to: point) |
| + focused = true |
| + return |
| + } |
| guard !model.busy else { return } |
| if model.hasUnconfirmedChanges { |
| Task { |
| await model.saveAll() |
| guard !model.hasUnconfirmedChanges, !model.busy else { return } |
| - editing = nil; model.select(point); focused = true |
| + editing = nil |
| + model.select(point) |
| + focused = true |
| } |
| } else { |
| if editing != point { editing = nil } |
| @@ -139,13 +198,32 @@ struct SheetGrid: View { |
| model.selectRange() |
| focused = true |
| } |
| - private func move(_ r: Int, _ c: Int) -> KeyPress.Result { |
| + private func move(_ r: Int, _ c: Int, extending: Bool = false) -> KeyPress.Result { |
| guard editing == nil else { return .ignored } |
| - let p = model.selection.start |
| - model.select( |
| - Coordinate(r: max(0, min(model.rows - 1, p.r + r)), c: max(0, min(model.cols - 1, p.c + c)))) |
| + let p = extending ? model.selectionExtent : model.selectionAnchor |
| + let point = Coordinate( |
| + r: max(0, min(model.rows - 1, p.r + r)), c: max(0, min(model.cols - 1, p.c + c))) |
| + if extending { model.extendSelection(to: point) } else { model.select(point) } |
| return .handled |
| } |
| + private func drag(from point: Coordinate, location: CGPoint) { |
| + guard editing == nil, !model.busy, !model.hasUnconfirmedChanges else { return } |
| + let x = (0..<point.c).reduce(0.0) { $0 + width($1) } + location.x |
| + let y = (0..<point.r).reduce(0.0) { $0 + height($1) } + location.y |
| + var col = 0 |
| + var row = 0 |
| + var edge = width(0) |
| + while col < model.cols - 1 && x >= edge { |
| + col += 1 |
| + edge += width(col) |
| + } |
| + edge = height(0) |
| + while row < model.rows - 1 && y >= edge { |
| + row += 1 |
| + edge += height(row) |
| + } |
| + model.extendSelection(to: Coordinate(r: row, c: col)) |
| + } |
| private func width(_ col: Int) -> Double { |
| if let resizing, resizing.axis == "col", resizing.index == col { return resizing.size } |
| return model.sizes.cols?[String(col)] ?? 110 |
| @@ -155,20 +233,24 @@ struct SheetGrid: View { |
| return model.sizes.rows?[String(row)] ?? 32 |
| } |
| #if os(macOS) |
| - private func resizeHandle(axis: String, index: Int) -> some View { |
| - SheetResizeHandle(vertical: axis == "row", |
| - canBegin: { model.mayWrite && !model.busy && !model.hasUnconfirmedChanges && !savingResize }, |
| - change: { translation in |
| - if resizing == nil { |
| - resizing = SheetResizePreview(axis: axis, index: index, |
| - original: axis == "col" ? width(index) : height(index)) |
| - } |
| - resizing?.update(translation: translation) |
| - }, finish: { finishResize(axis: axis, index: index) }, |
| - cancel: { if !savingResize { resizing = nil } }, |
| - fit: axis == "col" ? { Task { await model.fitColumn(index) } } : nil) |
| + private func resizeHandle(axis: String, index: Int) -> some View { |
| + SheetResizeHandle( |
| + vertical: axis == "row", |
| + canBegin: { |
| + model.mayWrite && !model.busy && !model.hasUnconfirmedChanges && !savingResize |
| + }, |
| + change: { translation in |
| + if resizing == nil { |
| + resizing = SheetResizePreview( |
| + axis: axis, index: index, |
| + original: axis == "col" ? width(index) : height(index)) |
| + } |
| + resizing?.update(translation: translation) |
| + }, finish: { finishResize(axis: axis, index: index) }, |
| + cancel: { if !savingResize { resizing = nil } }, |
| + fit: axis == "col" ? { Task { await model.fitColumn(index) } } : nil) |
| - } |
| + } |
| #endif |
| private func finishResize(axis: String, index: Int) { |
| guard let preview = resizing, preview.axis == axis, preview.index == index else { return } |
| @@ -185,11 +267,13 @@ struct SheetGrid: View { |
| .onChanged { value in |
| guard model.mayWrite, !model.busy, !model.hasUnconfirmedChanges else { return } |
| if resizing == nil { |
| - resizing = SheetResizePreview(axis: axis, index: index, |
| + resizing = SheetResizePreview( |
| + axis: axis, index: index, |
| original: axis == "col" ? width(index) : height(index)) |
| } |
| guard resizing?.axis == axis, resizing?.index == index else { return } |
| - resizing?.update(translation: axis == "col" ? value.translation.width : value.translation.height) |
| + resizing?.update( |
| + translation: axis == "col" ? value.translation.width : value.translation.height) |
| } |
| .onEnded { _ in finishResize(axis: axis, index: index) } |
| } |
| @@ -214,7 +298,10 @@ struct SheetResizePreview { |
| let original: Double |
| var size: Double |
| init(axis: String, index: Int, original: Double) { |
| - self.axis = axis; self.index = index; self.original = original; self.size = original |
| + self.axis = axis |
| + self.index = index |
| + self.original = original |
| + self.size = original |
| } |
| mutating func update(translation: Double) { |
| size = min(2000, max(16, (original + translation).rounded())) |
diff --git a/native/apple/Sources/SheetsKit/SheetModel.swift b/native/apple/Sources/SheetsKit/SheetModel.swift| index db3587e..51cc6d3 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetModel.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetModel.swift |
| @@ -13,8 +13,11 @@ import RastrilloApple |
| var sizes = Sizes() |
| var version: UInt64 = 0 |
| var selection = CellRange(Coordinate(r: 0, c: 0), Coordinate(r: 0, c: 0)) |
| + var selectionAnchor = Coordinate(r: 0, c: 0) |
| + var selectionExtent = Coordinate(r: 0, c: 0) |
| var rangeText = "A1" |
| var gridFocused = false |
| + @ObservationIgnored var cellEditorHasFocus: () -> Bool = { false } |
| var draft = "" |
| var originalDraft = "" |
| var dirty: Bool { draft != originalDraft } |
| @@ -49,16 +52,19 @@ import RastrilloApple |
| var cols: Int { page?.Cols ?? 26 } |
| var mayWrite: Bool { page?.MayWrite == true } |
| func loadList() async { |
| - let request = UUID(); listRequest = request |
| + let request = UUID() |
| + listRequest = request |
| listState = .loading |
| do { |
| let response: SheetList = try await client.request("/sheets") |
| guard listRequest == request, !Task.isCancelled else { return } |
| sheets = response.Sheets ?? [] |
| - listState = .loaded; error = "" |
| + listState = .loaded |
| + error = "" |
| } catch { |
| guard listRequest == request else { return } |
| - listState = .failed; self.error = error.localizedDescription |
| + listState = .failed |
| + self.error = error.localizedDescription |
| } |
| } |
| func open(_ sheet: SheetSummary) async { |
| @@ -148,7 +154,7 @@ import RastrilloApple |
| } |
| private func refreshDraftIfClean() { |
| guard !dirty else { return } |
| - originalDraft = cells[selection.start]?.raw ?? "" |
| + originalDraft = cells[selectionAnchor]?.raw ?? "" |
| draft = originalDraft |
| } |
| func select(_ point: Coordinate) { |
| @@ -156,6 +162,8 @@ import RastrilloApple |
| error = "Save or discard this edit before selecting another cell." |
| return |
| } |
| + selectionAnchor = point |
| + selectionExtent = point |
| selection = CellRange(point, point) |
| rangeText = point.ref |
| originalDraft = cells[point]?.raw ?? "" |
| @@ -168,10 +176,21 @@ import RastrilloApple |
| error = "Choose a range inside the visible sheet, after saving your edit." |
| return |
| } |
| + selectionAnchor = range.start |
| + selectionExtent = range.end |
| selection = range |
| originalDraft = cells[range.start]?.raw ?? "" |
| draft = originalDraft |
| } |
| + func extendSelection(to point: Coordinate) { |
| + guard !hasUnconfirmedChanges, !busy else { return } |
| + selectionExtent = Coordinate( |
| + r: max(0, min(rows - 1, point.r)), c: max(0, min(cols - 1, point.c))) |
| + selection = CellRange(selectionAnchor, selectionExtent) |
| + rangeText = selection.start.ref + ":" + selection.end.ref |
| + originalDraft = cells[selectionAnchor]?.raw ?? "" |
| + draft = originalDraft |
| + } |
| func discardAll() { |
| failedWrites = [] |
| discard() |
| @@ -181,19 +200,19 @@ import RastrilloApple |
| if failedWrites.isEmpty && dirty { await commit() } |
| } |
| func discard() { |
| - originalDraft = cells[selection.start]?.raw ?? "" |
| + originalDraft = cells[selectionAnchor]?.raw ?? "" |
| draft = originalDraft |
| error = "" |
| } |
| - func commit(moveRight: Bool = false) async { |
| - let point = selection.start |
| + func commit(moveRight: Bool = false, backwards: Bool = false) async { |
| + let point = selectionAnchor |
| let saved = dirty ? await write([CellWrite(r: point.r, c: point.c, raw: draft)]) : true |
| if saved { |
| originalDraft = draft |
| select( |
| Coordinate( |
| - r: moveRight ? point.r : min(rows - 1, point.r + 1), |
| - c: moveRight ? min(cols - 1, point.c + 1) : point.c)) |
| + r: moveRight ? point.r : max(0, min(rows - 1, point.r + (backwards ? -1 : 1))), |
| + c: moveRight ? max(0, min(cols - 1, point.c + (backwards ? -1 : 1))) : point.c)) |
| } |
| } |
| @@ -220,7 +239,7 @@ import RastrilloApple |
| let (bytes, status) = try await client.response( |
| grid + "/size", method: "POST", |
| body: JSONSerialization.data(withJSONObject: [ |
| - axis: index ?? (axis == "col" ? selection.start.c : selection.start.r), "size": size, |
| + axis: index ?? (axis == "col" ? selectionAnchor.c : selectionAnchor.r), "size": size, |
| ]), maxBytes: 64 << 20) |
| guard (200..<300).contains(status) else { |
| throw NativeClientError.message(sheetsMacString("The server did not confirm the resize.")) |
| @@ -280,7 +299,10 @@ import RastrilloApple |
| busy = false |
| if openCreated { await open(sheet) } |
| return sheet |
| - } catch { self.error = error.localizedDescription; return nil } |
| + } catch { |
| + self.error = error.localizedDescription |
| + return nil |
| + } |
| } |
| func loadHistory() async { |
| let sheetID = page?.Sheet.ID |
| @@ -313,7 +335,7 @@ import RastrilloApple |
| return true |
| } |
| func insertFunction(_ name: String) async { |
| - let p = selection.start |
| + let p = selectionAnchor |
| var top = p.r |
| while top > 0 && !(cells[Coordinate(r: top - 1, c: p.c)]?.raw ?? "").isEmpty { top -= 1 } |
| let arg = |
diff --git a/native/apple/Sources/SheetsKit/SheetSelectionDecoration.swift b/native/apple/Sources/SheetsKit/SheetSelectionDecoration.swift| new file mode 100644 |
| index 0000000..3e0319b |
| --- /dev/null |
| +++ b/native/apple/Sources/SheetsKit/SheetSelectionDecoration.swift |
| @@ -0,0 +1,37 @@ |
| +import SwiftUI |
| + |
| +/// Selected cells share one outside boundary; internal grid rules stay neutral. |
| +struct SheetSelectionDecoration: View { |
| + let model: SheetModel |
| + let point: Coordinate |
| + var body: some View { |
| + let range = model.selection |
| + let selected = range.contains(point) |
| + GeometryReader { geometry in |
| + let w = geometry.size.width |
| + let h = geometry.size.height |
| + Rectangle().strokeBorder(Color.secondary.opacity(0.2), lineWidth: 0.5) |
| + if selected { |
| + if range.count > 1 { Color.accentColor.opacity(0.07) } |
| + Path { path in |
| + if point.r == range.start.r { |
| + path.move(to: CGPoint(x: 0, y: 1)) |
| + path.addLine(to: CGPoint(x: w, y: 1)) |
| + } |
| + if point.r == range.end.r { |
| + path.move(to: CGPoint(x: 0, y: h - 1)) |
| + path.addLine(to: CGPoint(x: w, y: h - 1)) |
| + } |
| + if point.c == range.start.c { |
| + path.move(to: CGPoint(x: 1, y: 0)) |
| + path.addLine(to: CGPoint(x: 1, y: h)) |
| + } |
| + if point.c == range.end.c { |
| + path.move(to: CGPoint(x: w - 1, y: 0)) |
| + path.addLine(to: CGPoint(x: w - 1, y: h)) |
| + } |
| + }.stroke(Color.accentColor, lineWidth: 2) |
| + } |
| + }.allowsHitTesting(false).accessibilityHidden(true) |
| + } |
| +} |
diff --git a/native/apple/Sources/SheetsKit/SheetWrites.swift b/native/apple/Sources/SheetsKit/SheetWrites.swift| index 375e005..726a9b6 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetWrites.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetWrites.swift |
| @@ -46,7 +46,7 @@ import RastrilloApple |
| func retryWrites() async { |
| let pending = failedWrites |
| if await write(pending, retrying: true), pending.count == 1, let value = pending.first, |
| - value.r == selection.start.r, value.c == selection.start.c, value.raw == draft |
| + value.r == selectionAnchor.r, value.c == selectionAnchor.c, value.raw == draft |
| { |
| originalDraft = draft |
| } |
diff --git a/native/apple/Sources/SheetsKit/SheetsEditorCommands.swift b/native/apple/Sources/SheetsKit/SheetsEditorCommands.swift| index 9151495..6d9824c 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetsEditorCommands.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetsEditorCommands.swift |
| @@ -1,61 +1,100 @@ |
| #if os(macOS) |
| -import AppKit |
| -import SwiftUI |
| + import AppKit |
| + import SwiftUI |
| -public struct SheetsEditorCommands: Commands { |
| + public struct SheetsEditorCommands: Commands { |
| @FocusedValue(\.sheetEditor) private var editor |
| public init() {} |
| private var available: Bool { editor?.model.page != nil } |
| private var writable: Bool { |
| - guard let model = editor?.model else { return false } |
| - return model.mayWrite && !model.busy && !model.hasUnconfirmedChanges |
| + guard let model = editor?.model else { return false } |
| + return model.mayWrite && !model.busy && !model.hasUnconfirmedChanges |
| } |
| private func route(_ selector: Selector, grid: (SheetModel) -> Void) { |
| - if NSApp.keyWindow?.firstResponder is NSTextView { |
| - NSApp.sendAction(selector, to: nil, from: nil) |
| - } else if let model = editor?.model, model.gridFocused { grid(model) } |
| - else { NSApp.sendAction(selector, to: nil, from: nil) } |
| + if let text = NSApp.keyWindow?.firstResponder as? NSTextView { |
| + if selector == Selector(("undo:")) { |
| + performSheetTextUndo(text, redo: false) |
| + } else if selector == Selector(("redo:")) { |
| + performSheetTextUndo(text, redo: true) |
| + } else { |
| + NSApp.sendAction(selector, to: nil, from: nil) |
| + } |
| + } else if let model = editor?.model, model.gridFocused { |
| + grid(model) |
| + } else { |
| + NSApp.sendAction(selector, to: nil, from: nil) |
| + } |
| } |
| public var body: some Commands { |
| - CommandGroup(replacing: .undoRedo) { |
| - Button(sheetsMacString("Undo")) { route(Selector(("undo:"))) { model in Task { await model.command("/undo") } } }.keyboardShortcut("z") |
| - Button(sheetsMacString("Redo")) { NSApp.sendAction(Selector(("redo:")), to: nil, from: nil) }.keyboardShortcut("z", modifiers: [.command, .shift]) |
| - } |
| - CommandGroup(replacing: .pasteboard) { |
| - Button(sheetsMacString("Cut")) { route(#selector(NSText.cut(_:))) { model in |
| - guard model.mayWrite, !model.busy, !model.hasUnconfirmedChanges else { return } |
| - model.copy(); Task { await model.clear() } |
| - } }.keyboardShortcut("x") |
| - Button(sheetsMacString("Copy")) { route(#selector(NSText.copy(_:))) { $0.copy() } }.keyboardShortcut("c") |
| - Button(sheetsMacString("Paste")) { route(#selector(NSText.paste(_:))) { model in Task { await model.paste() } } }.keyboardShortcut("v") |
| - Button(sheetsMacString("Select all")) { route(#selector(NSText.selectAll(_:))) { model in |
| - model.rangeText = "A1:" + Coordinate(r: model.rows - 1, c: model.cols - 1).ref |
| - model.selectRange() |
| - } }.keyboardShortcut("a") |
| - } |
| - CommandGroup(after: .pasteboard) { |
| - Button(sheetsMacString("Clear contents")) { if let model = editor?.model { Task { await model.clear() } } }.disabled(!writable) |
| - Button(sheetsMacString("Fill down")) { if let model = editor?.model { Task { await model.fill(down: true) } } }.keyboardShortcut("d").disabled(!writable) |
| - Button(sheetsMacString("Fill right")) { if let model = editor?.model { Task { await model.fill(down: false) } } }.disabled(!writable) |
| - } |
| - CommandMenu(sheetsMacString("Format")) { |
| - if let model = editor?.model { |
| - SheetFormatMenu(model: model) |
| - Button(sheetsMacString("Row and column sizes…")) { editor?.panel.wrappedValue = "Sizes" }.disabled(!writable) |
| - Button(sheetsMacString("Fit column to contents")) { Task { await model.fitColumn(model.selection.start.c) } }.disabled(!writable) |
| - } |
| - } |
| - CommandMenu(sheetsMacString("Insert")) { |
| - ForEach(["SUM", "AVERAGE", "COUNT", "MIN", "MAX"], id: \.self) { name in |
| - Button(name) { if let model = editor?.model { Task { await model.insertFunction(name) } } }.disabled(!writable) |
| - } |
| + CommandGroup(replacing: .undoRedo) { |
| + Button(sheetsMacString("Undo")) { |
| + route(Selector(("undo:"))) { model in Task { await model.command("/undo") } } |
| + }.keyboardShortcut("z") |
| + Button(sheetsMacString("Redo")) { route(Selector(("redo:"))) { _ in } }.keyboardShortcut( |
| + "z", modifiers: [.command, .shift]) |
| + } |
| + CommandGroup(replacing: .pasteboard) { |
| + Button(sheetsMacString("Cut")) { |
| + route(#selector(NSText.cut(_:))) { model in |
| + guard model.mayWrite, !model.busy, !model.hasUnconfirmedChanges else { return } |
| + model.copy() |
| + Task { await model.clear() } |
| + } |
| + }.keyboardShortcut("x") |
| + Button(sheetsMacString("Copy")) { route(#selector(NSText.copy(_:))) { $0.copy() } } |
| + .keyboardShortcut("c") |
| + Button(sheetsMacString("Paste")) { |
| + route(#selector(NSText.paste(_:))) { model in Task { await model.paste() } } |
| + }.keyboardShortcut("v") |
| + Button(sheetsMacString("Select all")) { |
| + route(#selector(NSText.selectAll(_:))) { model in |
| + model.rangeText = "A1:" + Coordinate(r: model.rows - 1, c: model.cols - 1).ref |
| + model.selectRange() |
| + } |
| + }.keyboardShortcut("a") |
| + } |
| + CommandGroup(after: .pasteboard) { |
| + Button(sheetsMacString("Clear contents")) { |
| + if let model = editor?.model { Task { await model.clear() } } |
| + }.disabled(!writable) |
| + Button(sheetsMacString("Fill down")) { |
| + if let model = editor?.model { Task { await model.fill(down: true) } } |
| + }.keyboardShortcut("d").disabled(!writable) |
| + Button(sheetsMacString("Fill right")) { |
| + if let model = editor?.model { Task { await model.fill(down: false) } } |
| + }.disabled(!writable) |
| + } |
| + CommandMenu(sheetsMacString("Format")) { |
| + if let model = editor?.model { |
| + SheetFormatMenu(model: model) |
| + Button(sheetsMacString("Row and column sizes…")) { editor?.panel.wrappedValue = "Sizes" } |
| + .disabled(!writable) |
| + Button(sheetsMacString("Fit column to contents")) { |
| + Task { await model.fitColumn(model.selectionAnchor.c) } |
| + }.disabled(!writable) |
| } |
| - CommandMenu(sheetsMacString("Sheet")) { |
| - Button(sheetsMacString("Sort and filter…")) { editor?.panel.wrappedValue = "Data" }.disabled(!available) |
| - Button(sheetsMacString("Comments…")) { editor?.panel.wrappedValue = "Comments" }.disabled(!available) |
| - Button(sheetsMacString("Refresh")) { if let model = editor?.model { Task { await model.refresh() } } }.disabled(!available) |
| + } |
| + CommandMenu(sheetsMacString("Insert")) { |
| + ForEach(["SUM", "AVERAGE", "COUNT", "MIN", "MAX"], id: \.self) { name in |
| + Button(name) { |
| + if let model = editor?.model { Task { await model.insertFunction(name) } } |
| + }.disabled(!writable) |
| } |
| + } |
| + CommandMenu(sheetsMacString("Sheet")) { |
| + Button(sheetsMacString("Sort and filter…")) { editor?.panel.wrappedValue = "Data" } |
| + .disabled(!available) |
| + Button(sheetsMacString("Comments…")) { editor?.panel.wrappedValue = "Comments" }.disabled( |
| + !available) |
| + Button(sheetsMacString("Refresh")) { |
| + if let model = editor?.model { Task { await model.refresh() } } |
| + }.disabled(!available) |
| + } |
| } |
| -} |
| + } |
| + @MainActor func performSheetTextUndo(_ editor: NSTextView, redo: Bool) { |
| + editor.breakUndoCoalescing() |
| + if redo { editor.undoManager?.redo() } else { editor.undoManager?.undo() } |
| + } |
| #endif |
diff --git a/native/apple/Tests/SheetsKitTests/SheetQualityOfLifeTests.swift b/native/apple/Tests/SheetsKitTests/SheetQualityOfLifeTests.swift| new file mode 100644 |
| index 0000000..cf5d02c |
| --- /dev/null |
| +++ b/native/apple/Tests/SheetsKitTests/SheetQualityOfLifeTests.swift |
| @@ -0,0 +1,274 @@ |
| +#if os(macOS) |
| + import AppKit |
| + import SwiftUI |
| + import XCTest |
| + import RastrilloApple |
| + import RastrilloTestSupport |
| + @testable import SheetsKit |
| + |
| + @MainActor final class SheetQualityOfLifeTests: XCTestCase { |
| + @MainActor struct Fixture { |
| + let model: SheetModel |
| + let transport: ScriptedTransport |
| + let window: NSWindow |
| + let host: NSHostingView<AnyView> |
| + init(mayWrite: Bool = true) { |
| + transport = ScriptedTransport() |
| + model = SheetModel(client: transport) |
| + model.page = SheetPage( |
| + Sheet: SheetSummary(ID: 1, Title: "Fixture", TabID: 1, TabName: "Sheet 1"), Rows: 40, |
| + Cols: 8, Truncated: false, MayWrite: mayWrite, MayComment: true, MayShare: false, |
| + FillSwatches: [], InkSwatches: [], NumberFormats: [], Warned: nil) |
| + host = NSHostingView( |
| + rootView: AnyView( |
| + SheetGrid(model: model).background(Color.white).preferredColorScheme(.light))) |
| + window = NSWindow( |
| + contentRect: NSRect(x: 0, y: 0, width: 640, height: 400), styleMask: [.titled], |
| + backing: .buffered, defer: false) |
| + window.contentView = host |
| + NSApp.setActivationPolicy(.regular) |
| + window.makeKeyAndOrderFront(nil) |
| + NSApp.activate() |
| + } |
| + func pause() async throws { try await Task.sleep(for: .milliseconds(150)) } |
| + func mouse(_ type: NSEvent.EventType, _ point: NSPoint, modifiers: NSEvent.ModifierFlags = []) |
| + { |
| + window.sendEvent( |
| + NSEvent.mouseEvent( |
| + with: type, location: point, modifierFlags: modifiers, |
| + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, |
| + context: nil, eventNumber: 1, clickCount: 1, pressure: 1)!) |
| + } |
| + func click(_ point: NSPoint, modifiers: NSEvent.ModifierFlags = []) { |
| + mouse(.leftMouseDown, point, modifiers: modifiers) |
| + mouse(.leftMouseUp, point, modifiers: modifiers) |
| + } |
| + func key(_ text: String, code: UInt16, modifiers: NSEvent.ModifierFlags = []) { |
| + window.sendEvent( |
| + NSEvent.keyEvent( |
| + with: .keyDown, location: .zero, modifierFlags: modifiers, timestamp: 0, |
| + windowNumber: window.windowNumber, context: nil, characters: text, |
| + charactersIgnoringModifiers: text, isARepeat: false, keyCode: code)!) |
| + } |
| + func cell(_ r: Int, _ c: Int) -> SheetCellInteraction.MouseView? { |
| + @MainActor func find(_ view: NSView) -> SheetCellInteraction.MouseView? { |
| + if let mouse = view as? SheetCellInteraction.MouseView, |
| + mouse.actions?.point == Coordinate(r: r, c: c) |
| + { |
| + return mouse |
| + } |
| + return view.subviews.lazy.compactMap(find).first |
| + } |
| + return find(host) |
| + } |
| + func center(_ r: Int, _ c: Int) throws -> NSPoint { |
| + let view = try XCTUnwrap(cell(r, c)) |
| + return view.convert(NSPoint(x: view.bounds.midX, y: view.bounds.midY), to: nil) |
| + } |
| + } |
| + func testLocalTypingUndoRedoAndSessionIsolation() async throws { |
| + let f = Fixture() |
| + defer { f.window.orderOut(nil) } |
| + try await f.pause() |
| + f.click(try f.center(0, 0)) |
| + try await f.pause() |
| + f.key("h", code: 4) |
| + try await f.pause() |
| + let editor = try XCTUnwrap(f.window.firstResponder as? NSTextView) |
| + XCTAssertTrue(editor.allowsUndo) |
| + XCTAssertTrue( |
| + editor.undoManager?.canUndo == true, "The starter character belongs to text undo") |
| + performSheetTextUndo(editor, redo: false) |
| + try await f.pause() |
| + XCTAssertEqual(f.model.draft, "") |
| + performSheetTextUndo(editor, redo: true) |
| + try await f.pause() |
| + XCTAssertEqual(f.model.draft, "h") |
| + f.key("e", code: 14) |
| + try await f.pause() |
| + XCTAssertEqual(f.model.draft, "he") |
| + performSheetTextUndo(editor, redo: false) |
| + try await f.pause() |
| + XCTAssertEqual(f.model.draft, "h") |
| + performSheetTextUndo(editor, redo: true) |
| + try await f.pause() |
| + XCTAssertEqual(f.model.draft, "he") |
| + f.key("\u{1b}", code: 53) |
| + try await f.pause() |
| + f.click(try f.center(0, 1)) |
| + try await f.pause() |
| + f.key("x", code: 7) |
| + try await f.pause() |
| + let second = try XCTUnwrap(f.window.firstResponder as? NSTextView) |
| + performSheetTextUndo(second, redo: false) |
| + try await f.pause() |
| + XCTAssertEqual(f.model.draft, "") |
| + XCTAssertFalse( |
| + second.undoManager?.canUndo == true, "Another cell's typing must not leak into this session" |
| + ) |
| + XCTAssertTrue(f.transport.calls.isEmpty, "Typing undo is entirely local") |
| + } |
| + func testDragReverseShiftAndEscapeSelection() async throws { |
| + let f = Fixture() |
| + defer { f.window.orderOut(nil) } |
| + try await f.pause() |
| + f.model.sizes = Sizes(cols: ["0": 140, "1": 80], rows: ["0": 44, "1": 50]) |
| + try await f.pause() |
| + let start = try f.center(2, 2) |
| + let end = try f.center(0, 0) |
| + f.mouse(.leftMouseDown, start) |
| + f.mouse(.leftMouseDragged, end) |
| + f.mouse(.leftMouseUp, end) |
| + try await f.pause() |
| + XCTAssertEqual(f.model.selection, CellRange(Coordinate(r: 0, c: 0), Coordinate(r: 2, c: 2))) |
| + XCTAssertEqual(f.model.selectionAnchor, Coordinate(r: 2, c: 2)) |
| + f.key("\u{f703}", code: 124, modifiers: [.shift]) |
| + try await f.pause() |
| + XCTAssertEqual(f.model.selection.start, Coordinate(r: 0, c: 1)) |
| + f.click(try f.center(3, 3), modifiers: [.shift]) |
| + try await f.pause() |
| + XCTAssertEqual(f.model.selection, CellRange(Coordinate(r: 2, c: 2), Coordinate(r: 3, c: 3))) |
| + f.key("\u{1b}", code: 53) |
| + try await f.pause() |
| + XCTAssertEqual(f.model.selection, CellRange(Coordinate(r: 2, c: 2), Coordinate(r: 2, c: 2))) |
| + f.click(try f.center(0, 0)) |
| + f.key("\t", code: 48, modifiers: [.shift]) |
| + try await f.pause() |
| + XCTAssertEqual(f.model.selection.start, Coordinate(r: 0, c: 0)) |
| + XCTAssertTrue(f.transport.calls.isEmpty) |
| + } |
| + func testDeleteClearsOnceAndTextDeleteStaysLocal() async throws { |
| + let f = Fixture() |
| + defer { f.window.orderOut(nil) } |
| + try await f.pause() |
| + f.model.cells[Coordinate(r: 0, c: 0)] = Cell(r: 0, c: 0, raw: "old", display: "old") |
| + f.click(try f.center(0, 0)) |
| + try await f.pause() |
| + f.transport.append( |
| + .respond( |
| + status: 200, |
| + body: Data( |
| + #"{"version":1,"stamps":1,"cells":[{"r":0,"c":0,"raw":"","display":""}]}"#.utf8))) |
| + f.key("\u{7f}", code: 51) |
| + try await f.pause() |
| + XCTAssertEqual(f.transport.calls.count, 1) |
| + XCTAssertEqual(f.model.cells[Coordinate(r: 0, c: 0)]?.raw, "") |
| + f.transport.append( |
| + .respond( |
| + status: 200, |
| + body: Data( |
| + #"{"version":2,"stamps":1,"cells":[{"r":0,"c":0,"raw":"","display":""}]}"#.utf8))) |
| + f.model.cells[Coordinate(r: 0, c: 0)] = Cell(r: 0, c: 0, raw: "again", display: "again") |
| + f.model.select(Coordinate(r: 0, c: 0)) |
| + f.key("\u{f728}", code: 117) |
| + try await f.pause() |
| + XCTAssertEqual(f.transport.calls.count, 2) |
| + f.key("x", code: 7) |
| + try await f.pause() |
| + f.key("y", code: 16) |
| + try await f.pause() |
| + f.key("\u{7f}", code: 51) |
| + try await f.pause() |
| + XCTAssertEqual(f.model.draft, "x") |
| + XCTAssertEqual(f.transport.calls.count, 2) |
| + } |
| + func testReverseRangeActiveCellAndReverseCommit() async throws { |
| + let f = Fixture() |
| + defer { f.window.orderOut(nil) } |
| + let anchor = Coordinate(r: 2, c: 2) |
| + f.model.cells[anchor] = Cell(r: 2, c: 2, raw: "anchor", display: "anchor") |
| + f.model.select(anchor) |
| + f.model.extendSelection(to: Coordinate(r: 0, c: 0)) |
| + f.model.draft = "discard me" |
| + f.model.discard() |
| + XCTAssertEqual(f.model.draft, "anchor") |
| + f.model.draft = "saved" |
| + f.transport.append( |
| + .respond( |
| + status: 200, |
| + body: Data( |
| + #"{"version":1,"stamps":1,"cells":[{"r":2,"c":2,"raw":"saved","display":"saved"}]}"# |
| + .utf8))) |
| + await f.model.commit(moveRight: true, backwards: true) |
| + let payload = try XCTUnwrap(f.transport.calls.first?.body) |
| + let request = try XCTUnwrap(JSONSerialization.jsonObject(with: payload) as? [String: Any]) |
| + let writes = try XCTUnwrap(request["cells"] as? [[String: Any]]) |
| + XCTAssertEqual(writes.first?["r"] as? Int, 2) |
| + XCTAssertEqual(writes.first?["c"] as? Int, 2) |
| + XCTAssertEqual(f.model.selectionAnchor, Coordinate(r: 2, c: 1)) |
| + XCTAssertEqual(f.model.cells[anchor]?.raw, "saved") |
| + XCTAssertNil(f.model.cells[Coordinate(r: 0, c: 0)]) |
| + await f.model.commit(backwards: true) |
| + XCTAssertEqual(f.model.selectionAnchor, Coordinate(r: 1, c: 1)) |
| + } |
| + func testDragAfterScrolling() async throws { |
| + let f = Fixture() |
| + defer { f.window.orderOut(nil) } |
| + try await f.pause() |
| + let scroll = try XCTUnwrap(f.cell(0, 0)?.enclosingScrollView) |
| + scroll.contentView.scroll(to: NSPoint(x: 0, y: 180)) |
| + scroll.reflectScrolledClipView(scroll.contentView) |
| + try await f.pause() |
| + let start = try f.center(8, 1) |
| + let end = try f.center(10, 3) |
| + f.mouse(.leftMouseDown, start) |
| + f.mouse(.leftMouseDragged, end) |
| + f.mouse(.leftMouseUp, end) |
| + try await f.pause() |
| + XCTAssertEqual(f.model.selection, CellRange(Coordinate(r: 8, c: 1), Coordinate(r: 10, c: 3))) |
| + if ProcessInfo.processInfo.environment["OFICINA_CAPTURE_FIXTURES"] == "1", |
| + let bitmap = f.host.bitmapImageRepForCachingDisplay(in: f.host.bounds) |
| + { |
| + f.host.cacheDisplay(in: f.host.bounds, to: bitmap) |
| + try bitmap.representation(using: .png, properties: [:])?.write( |
| + to: URL(fileURLWithPath: "/tmp/sheets-range-selection.png")) |
| + } |
| + } |
| + func testReadOnlyMenuAndDeleteGuards() async throws { |
| + let f = Fixture(mayWrite: false) |
| + defer { f.window.orderOut(nil) } |
| + try await f.pause() |
| + f.click(try f.center(0, 0)) |
| + try await f.pause() |
| + let event = NSEvent.mouseEvent( |
| + with: .rightMouseDown, location: .zero, modifierFlags: [], timestamp: 0, |
| + windowNumber: f.window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, |
| + pressure: 1)! |
| + let menu = try XCTUnwrap(f.cell(0, 0)?.menu(for: event)) |
| + XCTAssertEqual( |
| + menu.items.filter { !$0.isSeparatorItem && $0.isEnabled }.map(\.title), |
| + [sheetsMacString("Copy")]) |
| + f.key("\u{7f}", code: 51) |
| + try await f.pause() |
| + XCTAssertTrue(f.transport.calls.isEmpty) |
| + } |
| + func testContextMenuTargetsRangeAndRejectsPendingEdits() async throws { |
| + let f = Fixture() |
| + defer { f.window.orderOut(nil) } |
| + try await f.pause() |
| + f.model.select(Coordinate(r: 0, c: 0)) |
| + f.model.extendSelection(to: Coordinate(r: 2, c: 1)) |
| + let inside = try XCTUnwrap(f.cell(1, 1)) |
| + let outside = try XCTUnwrap(f.cell(3, 2)) |
| + let event = NSEvent.mouseEvent( |
| + with: .rightMouseDown, location: .zero, modifierFlags: [], timestamp: 0, |
| + windowNumber: f.window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, |
| + pressure: 1)! |
| + let menu = try XCTUnwrap(inside.menu(for: event)) |
| + XCTAssertEqual(menu.items.filter { !$0.isSeparatorItem }.count, 6) |
| + XCTAssertEqual(f.model.selection.count, 6) |
| + let clear = try XCTUnwrap(menu.items.first { $0.title == sheetsMacString("Clear contents") }) |
| + XCTAssertTrue(NSApp.sendAction(try XCTUnwrap(clear.action), to: clear.target, from: clear)) |
| + // Change selection before the enqueued mutation gets a turn on the main actor. |
| + _ = outside.menu(for: event) |
| + try await f.pause() |
| + XCTAssertTrue(f.transport.calls.isEmpty) |
| + XCTAssertEqual(f.model.selection.start, Coordinate(r: 3, c: 2)) |
| + f.model.draft = "pending" |
| + XCTAssertNil(inside.menu(for: event)) |
| + XCTAssertEqual(f.model.selection.start, Coordinate(r: 3, c: 2)) |
| + XCTAssertEqual(f.model.draft, "pending") |
| + XCTAssertTrue(f.transport.calls.isEmpty) |
| + } |
| + } |
| +#endif |