Preserve initial cell input and add native row and column resizing
20 files changed,
+444
−44
docs/superpowers/reviews/2026-09-22-native-input-resize.md+11 −0native/apple/Sources/SheetsKit/Resources/ar.lproj/Mac.strings+2 −0native/apple/Sources/SheetsKit/Resources/bn.lproj/Mac.strings+2 −0native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings+2 −0native/apple/Sources/SheetsKit/Resources/es.lproj/Mac.strings+2 −0native/apple/Sources/SheetsKit/Resources/ga.lproj/Mac.strings+2 −0native/apple/Sources/SheetsKit/Resources/hi.lproj/Mac.strings+2 −0native/apple/Sources/SheetsKit/Resources/ja.lproj/Mac.strings+2 −0native/apple/Sources/SheetsKit/Resources/pt.lproj/Mac.strings+2 −0native/apple/Sources/SheetsKit/Resources/ru.lproj/Mac.strings+2 −0native/apple/Sources/SheetsKit/Resources/vi.lproj/Mac.strings+2 −0native/apple/Sources/SheetsKit/Resources/yue.lproj/Mac.strings+2 −0native/apple/Sources/SheetsKit/Resources/zh-Hans.lproj/Mac.strings+2 −0native/apple/Sources/SheetsKit/SheetCell.swift+10 −21native/apple/Sources/SheetsKit/SheetCellEditor.swift+71 −0native/apple/Sources/SheetsKit/SheetGrid.swift+106 −15native/apple/Sources/SheetsKit/SheetModel.swift+16 −8native/apple/Sources/SheetsKit/SheetResizeHandle.swift+60 −0native/apple/Tests/SheetsKitTests/ResizeFixtureServer.swift+58 −0native/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)! |