oficina / sheets Public

Preserve initial cell input and add native row and column resizing

Paul Campbell pushed by paul@keymail.dev 32546dae03d227c603d54db694f976ccec6fbf0e parent ca6f0e0
20 files changed, +444 −44
  • docs/superpowers/reviews/2026-09-22-native-input-resize.md +11 −0
  • native/apple/Sources/SheetsKit/Resources/ar.lproj/Mac.strings +2 −0
  • native/apple/Sources/SheetsKit/Resources/bn.lproj/Mac.strings +2 −0
  • native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings +2 −0
  • native/apple/Sources/SheetsKit/Resources/es.lproj/Mac.strings +2 −0
  • native/apple/Sources/SheetsKit/Resources/ga.lproj/Mac.strings +2 −0
  • native/apple/Sources/SheetsKit/Resources/hi.lproj/Mac.strings +2 −0
  • native/apple/Sources/SheetsKit/Resources/ja.lproj/Mac.strings +2 −0
  • native/apple/Sources/SheetsKit/Resources/pt.lproj/Mac.strings +2 −0
  • native/apple/Sources/SheetsKit/Resources/ru.lproj/Mac.strings +2 −0
  • native/apple/Sources/SheetsKit/Resources/vi.lproj/Mac.strings +2 −0
  • native/apple/Sources/SheetsKit/Resources/yue.lproj/Mac.strings +2 −0
  • native/apple/Sources/SheetsKit/Resources/zh-Hans.lproj/Mac.strings +2 −0
  • native/apple/Sources/SheetsKit/SheetCell.swift +10 −21
  • native/apple/Sources/SheetsKit/SheetCellEditor.swift +71 −0
  • native/apple/Sources/SheetsKit/SheetGrid.swift +106 −15
  • native/apple/Sources/SheetsKit/SheetModel.swift +16 −8
  • native/apple/Sources/SheetsKit/SheetResizeHandle.swift +60 −0
  • native/apple/Tests/SheetsKitTests/ResizeFixtureServer.swift +58 −0
  • native/apple/Tests/SheetsKitTests/SheetNativeInteractionTests.swift +88 −0

CI passed — run details

diff --git a/docs/superpowers/reviews/2026-09-22-native-input-resize.md b/docs/superpowers/reviews/2026-09-22-native-input-resize.md
new file mode 100644
index 0000000..5218156
--- /dev/null
+++ b/docs/superpowers/reviews/2026-09-22-native-input-resize.md
@@ -0,0 +1,11 @@
+# Native Sheets input and resizing
+
+Reviewed and approved by GPT-6 Astra (highest-capability model available in this session).
+
+Typing two keys 0–5 ms apart reproduced dropped/overwritten characters during the SwiftUI field focus handoff. The native field positions its caret while gaining focus, and the grid buffers printable events arriving before that handoff completes.
+
+Selection observation now lives in a leaf rather than invalidating the grid hierarchy. Sync responses without changed cells/styles no longer reassign those maps. Resize response decoding uses the existing background actor. These remove identified unnecessary UI work; no end-to-end large-workbook latency claim is made.
+
+Native row/column handles preview sizes locally, clamp to server bounds, and submit one asynchronous save on release. Escape or removal cancels unsaved previews; failures restore server-confirmed sizes. Existing menu sizing and column double-click autofit remain available. Handles accept the first mouse event in an inactive window.
+
+Validation: full Swift test run passed (4 native interaction tests, 12 Swift Testing tests; authenticated live test skipped without credentials). Native tests cover typing at 0/1/5/10/25/50 ms intervals, failed-commit draft preservation, selection, both resize axes, preview geometry, no requests during drag/cancellation, exactly one request after release, confirmed sizes, and failure rollback. Mouse drags route through NSWindow; Escape is sent to the handle directly because XCTest's window remains inactive. iOS simulator build passed. Final rerun after conditional dictionary changes passed; git diff --check passed.
diff --git a/native/apple/Sources/SheetsKit/Resources/ar.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/ar.lproj/Mac.strings
index 1542309..de44d60 100644
--- a/native/apple/Sources/SheetsKit/Resources/ar.lproj/Mac.strings
+++ b/native/apple/Sources/SheetsKit/Resources/ar.lproj/Mac.strings
@@ -68,3 +68,5 @@
"Refresh" = "تحديث";
"Import workbook" = "استيراد مصنف";
"Search spreadsheets" = "البحث في جداول البيانات";
+
+"The server did not confirm the resize." = "لم يؤكد الخادم تغيير الحجم.";
diff --git a/native/apple/Sources/SheetsKit/Resources/bn.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/bn.lproj/Mac.strings
index c89ba83..2184398 100644
--- a/native/apple/Sources/SheetsKit/Resources/bn.lproj/Mac.strings
+++ b/native/apple/Sources/SheetsKit/Resources/bn.lproj/Mac.strings
@@ -68,3 +68,5 @@
"Refresh" = "রিফ্রেশ করুন";
"Import workbook" = "ওয়ার্কবুক আমদানি করুন";
"Search spreadsheets" = "স্প্রেডশিট খুঁজুন";
+
+"The server did not confirm the resize." = "সার্ভার আকার পরিবর্তন নিশ্চিত করেনি।";
diff --git a/native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings
index fa760a7..b14897d 100644
--- a/native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings
+++ b/native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings
@@ -68,3 +68,5 @@
"Refresh" = "Refresh";
"Import workbook" = "Import workbook";
"Search spreadsheets" = "Search spreadsheets";
+
+"The server did not confirm the resize." = "The server did not confirm the resize.";
diff --git a/native/apple/Sources/SheetsKit/Resources/es.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/es.lproj/Mac.strings
index da781bb..58a89e9 100644
--- a/native/apple/Sources/SheetsKit/Resources/es.lproj/Mac.strings
+++ b/native/apple/Sources/SheetsKit/Resources/es.lproj/Mac.strings
@@ -68,3 +68,5 @@
"Refresh" = "Actualizar";
"Import workbook" = "Importar libro";
"Search spreadsheets" = "Buscar hojas de cálculo";
+
+"The server did not confirm the resize." = "El servidor no confirmó el cambio de tamaño.";
diff --git a/native/apple/Sources/SheetsKit/Resources/ga.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/ga.lproj/Mac.strings
index cb1dfa5..81724d5 100644
--- a/native/apple/Sources/SheetsKit/Resources/ga.lproj/Mac.strings
+++ b/native/apple/Sources/SheetsKit/Resources/ga.lproj/Mac.strings
@@ -68,3 +68,5 @@
"Refresh" = "Athnuaigh";
"Import workbook" = "Iompórtáil leabhar oibre";
"Search spreadsheets" = "Cuardaigh scarbhileoga";
+
+"The server did not confirm the resize." = "Níor dheimhnigh an freastalaí an t-athrú méide.";
diff --git a/native/apple/Sources/SheetsKit/Resources/hi.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/hi.lproj/Mac.strings
index 6299d2e..f80cc0c 100644
--- a/native/apple/Sources/SheetsKit/Resources/hi.lproj/Mac.strings
+++ b/native/apple/Sources/SheetsKit/Resources/hi.lproj/Mac.strings
@@ -68,3 +68,5 @@
"Refresh" = "रीफ़्रेश करें";
"Import workbook" = "कार्यपुस्तिका आयात करें";
"Search spreadsheets" = "स्प्रेडशीट खोजें";
+
+"The server did not confirm the resize." = "सर्वर ने आकार बदलने की पुष्टि नहीं की।";
diff --git a/native/apple/Sources/SheetsKit/Resources/ja.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/ja.lproj/Mac.strings
index 787ec84..4f9e3da 100644
--- a/native/apple/Sources/SheetsKit/Resources/ja.lproj/Mac.strings
+++ b/native/apple/Sources/SheetsKit/Resources/ja.lproj/Mac.strings
@@ -68,3 +68,5 @@
"Refresh" = "更新";
"Import workbook" = "ブックを読み込む";
"Search spreadsheets" = "スプレッドシートを検索";
+
+"The server did not confirm the resize." = "サーバーがサイズ変更を確認しませんでした。";
diff --git a/native/apple/Sources/SheetsKit/Resources/pt.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/pt.lproj/Mac.strings
index 7e008d6..e78f641 100644
--- a/native/apple/Sources/SheetsKit/Resources/pt.lproj/Mac.strings
+++ b/native/apple/Sources/SheetsKit/Resources/pt.lproj/Mac.strings
@@ -68,3 +68,5 @@
"Refresh" = "Atualizar";
"Import workbook" = "Importar pasta de trabalho";
"Search spreadsheets" = "Pesquisar planilhas";
+
+"The server did not confirm the resize." = "O servidor não confirmou o redimensionamento.";
diff --git a/native/apple/Sources/SheetsKit/Resources/ru.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/ru.lproj/Mac.strings
index 510d270..f2dffdc 100644
--- a/native/apple/Sources/SheetsKit/Resources/ru.lproj/Mac.strings
+++ b/native/apple/Sources/SheetsKit/Resources/ru.lproj/Mac.strings
@@ -68,3 +68,5 @@
"Refresh" = "Обновить";
"Import workbook" = "Импорт книги";
"Search spreadsheets" = "Поиск таблиц";
+
+"The server did not confirm the resize." = "Сервер не подтвердил изменение размера.";
diff --git a/native/apple/Sources/SheetsKit/Resources/vi.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/vi.lproj/Mac.strings
index 7a0f646..e4531f4 100644
--- a/native/apple/Sources/SheetsKit/Resources/vi.lproj/Mac.strings
+++ b/native/apple/Sources/SheetsKit/Resources/vi.lproj/Mac.strings
@@ -68,3 +68,5 @@
"Refresh" = "Làm mới";
"Import workbook" = "Nhập sổ làm việc";
"Search spreadsheets" = "Tìm bảng tính";
+
+"The server did not confirm the resize." = "Máy chủ chưa xác nhận thay đổi kích thước.";
diff --git a/native/apple/Sources/SheetsKit/Resources/yue.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/yue.lproj/Mac.strings
index 4af29a1..bf496f8 100644
--- a/native/apple/Sources/SheetsKit/Resources/yue.lproj/Mac.strings
+++ b/native/apple/Sources/SheetsKit/Resources/yue.lproj/Mac.strings
@@ -68,3 +68,5 @@
"Refresh" = "重新整理";
"Import workbook" = "匯入活頁簿";
"Search spreadsheets" = "搜尋試算表";
+
+"The server did not confirm the resize." = "伺服器未確認大小調整。";
diff --git a/native/apple/Sources/SheetsKit/Resources/zh-Hans.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/zh-Hans.lproj/Mac.strings
index c2abb6d..43eebfb 100644
--- a/native/apple/Sources/SheetsKit/Resources/zh-Hans.lproj/Mac.strings
+++ b/native/apple/Sources/SheetsKit/Resources/zh-Hans.lproj/Mac.strings
@@ -68,3 +68,5 @@
"Refresh" = "刷新";
"Import workbook" = "导入工作簿";
"Search spreadsheets" = "搜索电子表格";
+
+"The server did not confirm the resize." = "服务器未确认大小调整。";
diff --git a/native/apple/Sources/SheetsKit/SheetCell.swift b/native/apple/Sources/SheetsKit/SheetCell.swift
index 0c9dcfc..205f77e 100644
--- a/native/apple/Sources/SheetsKit/SheetCell.swift
+++ b/native/apple/Sources/SheetsKit/SheetCell.swift
@@ -7,11 +7,11 @@ struct SheetCell: View {
var select: () -> Void
var beginEditing: () -> Void = {}
var endEditing: () -> Void
- @State private var textSelection: TextSelection?
- @FocusState private var fieldFocused: Bool
+ var columnWidth: Double? = nil
+ var rowHeight: Double? = nil
@Environment(\.colorScheme) private var scheme
- private func width(_ col: Int) -> Double { model.sizes.cols?[String(col)] ?? 110 }
- private func height(_ row: Int) -> Double { model.sizes.rows?[String(row)] ?? 32 }
+ private func width(_ col: Int) -> Double { columnWidth ?? model.sizes.cols?[String(col)] ?? 110 }
+ private func height(_ row: Int) -> Double { rowHeight ?? model.sizes.rows?[String(row)] ?? 32 }
var body: some View {
let cell = model.cells[point]
let style = model.styles[cell?.f ?? ""]
@@ -21,26 +21,16 @@ struct SheetCell: View {
return Group {
#if os(macOS)
if editing == point {
- TextField(sheetsMacString("Cell contents"), text: $model.draft, selection: $textSelection)
- .textFieldStyle(.plain)
- .font(.system(size: 13))
+ 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()
+ })
.padding(.horizontal, 5)
.frame(width: width(point.c), height: height(point.r))
.background(fill(style))
.overlay { Rectangle().strokeBorder(Color.accentColor, lineWidth: 2) }
- .focused($fieldFocused)
- .disabled(!model.mayWrite || model.busy)
- .task {
- fieldFocused = true
- await Task.yield()
- textSelection = TextSelection(insertionPoint: model.draft.endIndex)
- }
- .onSubmit { commit(right: false) }
- .onKeyPress(.tab) { commit(right: true); return .handled }
- .onKeyPress(.escape) {
- guard !model.busy else { return .handled }
- model.discardAll(); endEditing(); return .handled
- }
.accessibilityLabel(String(format: sheetsMacString("Edit %@"), point.ref))
} else { display(cell, style: style, alignment: alignment) }
#else
@@ -53,7 +43,6 @@ struct SheetCell: View {
Task {
await model.commit(moveRight: right)
if !model.hasUnconfirmedChanges { endEditing() }
- else { fieldFocused = true }
}
}
private func display(_ cell: Cell?, style: CellStyle?, alignment: Alignment) -> some View {
diff --git a/native/apple/Sources/SheetsKit/SheetCellEditor.swift b/native/apple/Sources/SheetsKit/SheetCellEditor.swift
new file mode 100644
index 0000000..5871425
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetCellEditor.swift
@@ -0,0 +1,71 @@
+#if os(macOS)
+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 {
+ @Binding var text: String
+ let enabled: Bool
+ let label: String
+ let commit: (Bool) -> Void
+ let cancel: () -> Void
+ func makeCoordinator() -> Coordinator { Coordinator(self) }
+ func makeNSView(context: Context) -> CellTextField {
+ let field = CellTextField()
+ field.isBordered = false; field.drawsBackground = false
+ field.font = .systemFont(ofSize: 13)
+ field.delegate = context.coordinator
+ field.stringValue = text
+ 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))
+ }
+ }
+ 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)
+ }
+ }
+ 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))
+ }
+ 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
+ }
+ }
+}
+#endif
diff --git a/native/apple/Sources/SheetsKit/SheetGrid.swift b/native/apple/Sources/SheetsKit/SheetGrid.swift
index c9c63d6..11fa242 100644
--- a/native/apple/Sources/SheetsKit/SheetGrid.swift
+++ b/native/apple/Sources/SheetsKit/SheetGrid.swift
@@ -3,6 +3,9 @@ import SwiftUI
struct SheetGrid: View {
@Bindable var model: SheetModel
@State private var editing: Coordinate?
+ @State private var resizing: SheetResizePreview?
+ @State private var savingResize = false
+ @GestureState private var draggingResize = false
@FocusState private var focused: Bool
var beginEditing: () -> Void = {}
var body: some View {
@@ -11,15 +14,25 @@ struct SheetGrid: View {
Section {
ForEach(0..<model.rows, id: \.self) { row in
HStack(spacing: 0) {
- 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))
+ VStack(spacing: 0) {
+ Button { selectRow(row) } label: {
+ Text(String(row + 1)).frame(width: 48, height: max(0, height(row) - 8)).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
+ Rectangle().fill(.secondary.opacity(0.15)).frame(width: 48, height: 8)
+ .contentShape(.rect).gesture(resizeGesture(axis: "row", index: row))
+ .accessibilityHidden(true)
+ #endif
+ }
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 })
+ endEditing: { editing = nil; focused = true },
+ columnWidth: width(col), rowHeight: height(row))
}
}
}
@@ -35,11 +48,8 @@ struct SheetGrid: View {
}.buttonStyle(.plain)
.accessibilityLabel(String(format: sheetsMacString("Select column %@"), String(Coordinate(r: 0, c: col).ref.dropLast())))
#if os(macOS)
- Rectangle().fill(.secondary.opacity(0.15)).frame(width: 8, height: 32)
- .contentShape(.rect)
- .onTapGesture(count: 2) { Task { await model.fitColumn(col) } }
+ resizeHandle(axis: "col", index: col).frame(width: 8, height: 32)
.help(sheetsMacString("Double-click to fit column to contents"))
- .accessibilityHidden(true)
#else
Color.clear.frame(width: 8, height: 32)
#endif
@@ -56,9 +66,12 @@ struct SheetGrid: View {
.focusable()
.focusEffectDisabled()
.focused($focused)
- .onChange(of: model.selection.start) { _, _ in editing = nil }
+ .background { SheetSelectionObserver(model: model, editing: $editing) }
.onChange(of: focused) { _, value in model.gridFocused = value }
- .onDisappear { model.gridFocused = false }
+ .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) }
@@ -75,11 +88,18 @@ struct SheetGrid: View {
}
.onKeyPress(characters: .alphanumerics.union(.punctuationCharacters).union(.symbols).union(.whitespaces)) {
press in
- guard editing == nil, model.mayWrite, !model.busy, !model.hasUnconfirmedChanges,
+ guard model.mayWrite, !model.busy, !press.characters.contains(where: { $0 == "\t" || $0.isNewline }),
press.modifiers.intersection([.command, .control, .option]).isEmpty
else { return .ignored }
- model.draft = press.characters
- edit(model.selection.start)
+ 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 }
+ model.draft = press.characters
+ edit(model.selection.start)
+ }
return .handled
}
}
@@ -126,6 +146,77 @@ struct SheetGrid: View {
Coordinate(r: max(0, min(model.rows - 1, p.r + r)), c: max(0, min(model.cols - 1, p.c + c))))
return .handled
}
- private func width(_ col: Int) -> Double { model.sizes.cols?[String(col)] ?? 110 }
- private func height(_ row: Int) -> Double { model.sizes.rows?[String(row)] ?? 32 }
+ 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
+ }
+ private func height(_ row: Int) -> Double {
+ if let resizing, resizing.axis == "row", resizing.index == row { return resizing.size }
+ 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)
+
+ }
+ #endif
+ private func finishResize(axis: String, index: Int) {
+ guard let preview = resizing, preview.axis == axis, preview.index == index else { return }
+ savingResize = true
+ Task {
+ await model.resize(axis: axis, size: Int(preview.size), index: index)
+ resizing = nil
+ savingResize = false
+ }
+ }
+ private func resizeGesture(axis: String, index: Int) -> some Gesture {
+ DragGesture(minimumDistance: 2, coordinateSpace: .global)
+ .updating($draggingResize) { _, active, _ in active = true }
+ .onChanged { value in
+ guard model.mayWrite, !model.busy, !model.hasUnconfirmedChanges else { return }
+ if resizing == nil {
+ 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)
+ }
+ .onEnded { _ in finishResize(axis: axis, index: index) }
+ }
+}
+
+/// Observe selection in a leaf, so moving between cells does not invalidate the
+/// LazyVStack and every row's view construction.
+private struct SheetSelectionObserver: View {
+ let model: SheetModel
+ @Binding var editing: Coordinate?
+ var body: some View {
+ Color.clear.frame(width: 0, height: 0)
+ .onChange(of: model.selection.start) { _, point in
+ if editing != point { editing = nil }
+ }
+ }
+}
+
+struct SheetResizePreview {
+ let axis: String
+ let index: Int
+ 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
+ }
+ 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 81f2469..2decf12 100644
--- a/native/apple/Sources/SheetsKit/SheetModel.swift
+++ b/native/apple/Sources/SheetsKit/SheetModel.swift
@@ -118,13 +118,17 @@ import RastrilloApple
await refresh()
return
}
- var nextCells = cells
- for cell in response.cells ?? [] { nextCells[cell.coordinate] = cell }
- var nextStyles = styles
- nextStyles.merge(response.formats ?? [:]) { _, new in new }
+ if let changed = response.cells, !changed.isEmpty {
+ var nextCells = cells
+ for cell in changed { nextCells[cell.coordinate] = cell }
+ cells = nextCells
+ }
+ if let formats = response.formats, !formats.isEmpty {
+ var nextStyles = styles
+ nextStyles.merge(formats) { _, new in new }
+ styles = nextStyles
+ }
version = response.version
- cells = nextCells
- styles = nextStyles
if let sizes = response.sizes { self.sizes = sizes }
refreshDraftIfClean()
}
@@ -199,11 +203,15 @@ import RastrilloApple
busy = true
defer { busy = false }
do {
- let response: GridResponse = try await client.request(
+ 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,
- ]))
+ ]), maxBytes: 64 << 20)
+ guard (200..<300).contains(status) else {
+ throw NativeClientError.message(sheetsMacString("The server did not confirm the resize."))
+ }
+ let response = try await wire.decode(bytes)
await accept(response)
} catch { self.error = error.localizedDescription }
}
diff --git a/native/apple/Sources/SheetsKit/SheetResizeHandle.swift b/native/apple/Sources/SheetsKit/SheetResizeHandle.swift
new file mode 100644
index 0000000..36d1cc0
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetResizeHandle.swift
@@ -0,0 +1,60 @@
+#if os(macOS)
+import AppKit
+import SwiftUI
+
+struct SheetResizeHandle: NSViewRepresentable {
+ let vertical: Bool
+ let canBegin: () -> Bool
+ let change: (Double) -> Void
+ let finish: () -> Void
+ let cancel: () -> Void
+ var fit: (() -> Void)? = nil
+ func makeNSView(context: Context) -> Handle { Handle() }
+ func updateNSView(_ view: Handle, context: Context) { view.actions = self }
+ final class Handle: NSControl {
+ override func draw(_ dirtyRect: NSRect) {
+ NSColor.secondaryLabelColor.withAlphaComponent(0.15).setFill()
+ bounds.fill()
+ }
+ var actions: SheetResizeHandle?
+ private var start: NSPoint?
+ private var moved = false
+ private weak var previousResponder: NSResponder?
+ override var acceptsFirstResponder: Bool { true }
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
+ override func resetCursorRects() {
+ addCursorRect(bounds, cursor: actions?.vertical == true ? .resizeUpDown : .resizeLeftRight)
+ }
+ override func mouseDown(with event: NSEvent) {
+ guard actions?.canBegin() == true else { return }
+ if event.clickCount == 2, let fit = actions?.fit { fit(); return }
+ previousResponder = window?.firstResponder
+ window?.makeFirstResponder(self)
+ start = event.locationInWindow; moved = false
+ }
+ override func mouseDragged(with event: NSEvent) {
+ guard let start else { return }
+ let amount = actions?.vertical == true ? start.y - event.locationInWindow.y : event.locationInWindow.x - start.x
+ guard moved || abs(amount) >= 2 else { return }
+ moved = true; actions?.change(amount)
+ }
+ override func mouseUp(with event: NSEvent) {
+ guard start != nil else { return }
+ if moved { actions?.finish() }
+ reset()
+ }
+ override func keyDown(with event: NSEvent) {
+ if event.keyCode == 53 { actions?.cancel(); reset() } else { super.keyDown(with: event) }
+ }
+ override func viewWillMove(toWindow newWindow: NSWindow?) {
+ if newWindow == nil, start != nil { actions?.cancel(); reset() }
+ super.viewWillMove(toWindow: newWindow)
+ }
+ private func reset() {
+ start = nil; moved = false
+ if window?.firstResponder === self { window?.makeFirstResponder(previousResponder) }
+ previousResponder = nil
+ }
+ }
+}
+#endif
diff --git a/native/apple/Tests/SheetsKitTests/ResizeFixtureServer.swift b/native/apple/Tests/SheetsKitTests/ResizeFixtureServer.swift
new file mode 100644
index 0000000..2bbccd3
--- /dev/null
+++ b/native/apple/Tests/SheetsKitTests/ResizeFixtureServer.swift
@@ -0,0 +1,58 @@
+#if os(macOS)
+import Foundation
+import Network
+
+final class ResizeFixtureServer: @unchecked Sendable {
+ private let listener: NWListener
+ private let queue = DispatchQueue(label: "sheets.resize.fixture")
+ private let lock = NSLock()
+ private var received: [[String: Int]] = []
+ var requests: [[String: Int]] { lock.withLock { received } }
+ init() throws { listener = try NWListener(using: .tcp, on: .any) }
+ func start() async throws -> URL {
+ listener.newConnectionHandler = { [weak self] connection in
+ guard let self else { return }
+ connection.start(queue: self.queue)
+ self.read(connection, buffer: Data())
+ }
+ return try await withCheckedThrowingContinuation { continuation in
+ listener.stateUpdateHandler = { [weak self] state in
+ guard let self else { return }
+ switch state {
+ case .ready:
+ self.listener.stateUpdateHandler = nil
+ continuation.resume(returning: URL(string: "http://127.0.0.1:\(self.listener.port!.rawValue)")!)
+ case .failed(let error):
+ self.listener.stateUpdateHandler = nil
+ continuation.resume(throwing: error)
+ default: break
+ }
+ }
+ listener.start(queue: queue)
+ }
+ }
+ func stop() { listener.cancel() }
+ private func read(_ connection: NWConnection, buffer: Data) {
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, complete, error in
+ guard let self, let data, error == nil else { connection.cancel(); return }
+ var bytes = buffer; bytes.append(data)
+ guard let separator = bytes.range(of: Data("\r\n\r\n".utf8)),
+ let headers = String(data: bytes[..<separator.lowerBound], encoding: .utf8),
+ let lengthLine = headers.components(separatedBy: "\r\n").first(where: { $0.lowercased().hasPrefix("content-length:") }),
+ let length = Int(lengthLine.split(separator: ":", maxSplits: 1)[1].trimmingCharacters(in: .whitespaces)),
+ bytes.count - separator.upperBound >= length else {
+ if !complete { self.read(connection, buffer: bytes) } else { connection.cancel() }
+ return
+ }
+ let body = bytes.subdata(in: separator.upperBound..<(separator.upperBound + length))
+ guard let request = try? JSONDecoder().decode([String: Int].self, from: body), let size = request["size"] else { connection.cancel(); return }
+ self.lock.withLock { self.received.append(request) }
+ let axis = request["col"] == nil ? "rows" : "cols"
+ let index = request["col"] ?? request["row"] ?? 0
+ let response = "{\"version\":0,\"cells\":[],\"sizes\":{\"\(axis)\":{\"\(index)\":\(size)}}}"
+ let message = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: \(response.utf8.count)\r\nConnection: close\r\n\r\n\(response)"
+ connection.send(content: Data(message.utf8), completion: .contentProcessed { _ in connection.cancel() })
+ }
+ }
+}
+#endif
diff --git a/native/apple/Tests/SheetsKitTests/SheetNativeInteractionTests.swift b/native/apple/Tests/SheetsKitTests/SheetNativeInteractionTests.swift
index cbd7123..7b33d64 100644
--- a/native/apple/Tests/SheetsKitTests/SheetNativeInteractionTests.swift
+++ b/native/apple/Tests/SheetsKitTests/SheetNativeInteractionTests.swift
@@ -57,6 +57,94 @@ final class SheetNativeInteractionTests: XCTestCase {
XCTAssertEqual(model.selection.start, Coordinate(r: 1, c: 0))
XCTAssertEqual(model.selection.end, Coordinate(r: 1, c: 2))
}
+ @MainActor func testRapidTypingDuringFocusTransfer() async throws {
+ for delay in [0, 1, 5, 10, 25, 50] {
+ let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://127.0.0.1:1")!))
+ model.page = SheetPage(Sheet: SheetSummary(ID: 1, Title: "Fixture", TabID: 1, TabName: "Sheet 1"), Rows: 4, Cols: 3, Truncated: false, MayWrite: true, MayComment: true, MayShare: false, FillSwatches: [], InkSwatches: [], NumberFormats: [], Warned: nil)
+ let host = NSHostingView(rootView: SheetGrid(model: model))
+ let 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()
+ defer { window.orderOut(nil) }
+ try await Task.sleep(for: .milliseconds(200))
+ click(NSPoint(x: 100, y: 350), window: window)
+ try await Task.sleep(for: .milliseconds(100))
+ key("h", code: 4, window: window)
+ if delay > 0 { try await Task.sleep(for: .milliseconds(delay)) }
+ key("e", code: 14, window: window)
+ try await Task.sleep(for: .milliseconds(200))
+ XCTAssertEqual(model.draft, "he", "Typing during focus handoff (\(delay)ms)")
+ }
+ }
+ func testResizePreviewUsesOriginalSizeAndServerBounds() {
+ var preview = SheetResizePreview(axis: "col", index: 0, original: 110)
+ preview.update(translation: 30)
+ XCTAssertEqual(preview.size, 140)
+ preview.update(translation: 40)
+ XCTAssertEqual(preview.size, 150)
+ preview.update(translation: -1000)
+ XCTAssertEqual(preview.size, 16)
+ preview.update(translation: 5000)
+ XCTAssertEqual(preview.size, 2000)
+ }
+ @MainActor func testDragResizeWaitsForReleaseAndRollsBackOnFailure() async throws {
+ for axis in ["col", "row"] {
+ for succeeds in [false, true] {
+ let server = try ResizeFixtureServer()
+ let origin = try await server.start()
+ defer { server.stop() }
+ let model = SheetModel(client: NativeHTTP(origin: succeeds ? origin : URL(string: "http://127.0.0.1:1")!))
+ model.page = SheetPage(Sheet: SheetSummary(ID: 1, Title: "Fixture", TabID: 1, TabName: "Sheet 1"), Rows: 4, Cols: 3, Truncated: false, MayWrite: true, MayComment: true, MayShare: false, FillSwatches: [], InkSwatches: [], NumberFormats: [], Warned: nil)
+ let host = NSHostingView(rootView: SheetGrid(model: model))
+ let 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()
+ defer { window.orderOut(nil) }
+ try await Task.sleep(for: .milliseconds(200))
+ func handles(_ view: NSView) -> [SheetResizeHandle.Handle] {
+ (view as? SheetResizeHandle.Handle).map { [$0] } ?? view.subviews.flatMap(handles)
+ }
+ let handle = try XCTUnwrap(handles(host).first { $0.actions?.vertical == (axis == "row") })
+ let start = handle.convert(NSPoint(x: handle.bounds.midX, y: handle.bounds.midY), to: nil)
+ let end = NSPoint(x: start.x + (axis == "col" ? 40 : 0), y: start.y - (axis == "row" ? 40 : 0))
+ func send(_ type: NSEvent.EventType, _ point: NSPoint) {
+ window.sendEvent(NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)!)
+ }
+ // Cancel one drag first: no save, and geometry must return to its original position.
+ send(.leftMouseDown, start)
+ send(.leftMouseDragged, end)
+ try await Task.sleep(for: .milliseconds(100))
+ let preview = handle.convert(NSPoint(x: handle.bounds.midX, y: handle.bounds.midY), to: nil)
+ XCTAssertEqual(axis == "col" ? preview.x - start.x : start.y - preview.y, 40, accuracy: 1)
+ handle.keyDown(with: NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53)!)
+ send(.leftMouseUp, end)
+ try await Task.sleep(for: .milliseconds(100))
+ XCTAssertEqual(server.requests.count, 0)
+ XCTAssertEqual(handle.convert(NSPoint(x: handle.bounds.midX, y: handle.bounds.midY), to: nil), start)
+ send(.leftMouseDown, start)
+ try await Task.sleep(for: .milliseconds(50))
+ for step in 1...5 {
+ send(.leftMouseDragged, NSPoint(x: start.x + (end.x - start.x) * Double(step) / 5, y: start.y + (end.y - start.y) * Double(step) / 5))
+ try await Task.sleep(for: .milliseconds(30))
+ }
+ try await Task.sleep(for: .milliseconds(150))
+ XCTAssertFalse(model.busy)
+ XCTAssertEqual(server.requests.count, 0)
+ XCTAssertEqual(model.error, "", "Dragging must not send intermediate requests")
+ send(.leftMouseUp, end)
+ try await Task.sleep(for: .milliseconds(300))
+ if succeeds {
+ XCTAssertEqual(model.error, "")
+ XCTAssertEqual(server.requests, [[axis: 0, "size": axis == "col" ? 150 : 72]])
+ XCTAssertEqual(axis == "col" ? model.sizes.cols?["0"] : model.sizes.rows?["0"], axis == "col" ? 150 : 72)
+ } else {
+ XCTAssertFalse(model.error.isEmpty, "Releasing the \(axis) handle must attempt persistence")
+ XCTAssertNil(model.sizes.cols?["0"])
+ XCTAssertNil(model.sizes.rows?["0"])
+ XCTAssertEqual(handle.convert(NSPoint(x: handle.bounds.midX, y: handle.bounds.midY), to: nil), start)
+ }
+ XCTAssertFalse(model.busy)
+ }
+ }
+ }
@MainActor private func click(_ point: NSPoint, window: NSWindow) {
for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] {
let event = NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)!