oficina / sheets Public

Improve native sheet text undo, range selection and context actions

Paul Campbell pushed by paul@keymail.dev 38755d9ae12d9bdfdffb2f519d982ce314c864c9 parent 429943f
12 files changed, +895 −199
  • docs/superpowers/specs/2026-09-25-native-interaction-parity.md +40 −0
  • native/PARITY.md +29 −2
  • native/apple/Sources/SheetsKit/SheetCell.swift +37 −16
  • native/apple/Sources/SheetsKit/SheetCellEditor.swift +105 −53
  • native/apple/Sources/SheetsKit/SheetCellInteraction.swift +97 −0
  • native/apple/Sources/SheetsKit/SheetComments.swift +2 −2
  • native/apple/Sources/SheetsKit/SheetGrid.swift +154 −67
  • native/apple/Sources/SheetsKit/SheetModel.swift +34 −12
  • native/apple/Sources/SheetsKit/SheetSelectionDecoration.swift +37 −0
  • native/apple/Sources/SheetsKit/SheetWrites.swift +1 −1
  • native/apple/Sources/SheetsKit/SheetsEditorCommands.swift +85 −46
  • native/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