| 1 | import SwiftUI |
| 2 | |
| 3 | struct SheetGrid: View { |
| 4 | @Bindable var model: SheetModel |
| 5 | @State private var editing: Coordinate? |
| 6 | @State private var resizing: SheetResizePreview? |
| 7 | @State private var savingResize = false |
| 8 | @GestureState private var draggingResize = false |
| 9 | @FocusState private var focused: Bool |
| 10 | var beginEditing: () -> Void = {} |
| 11 | var body: some View { |
| 12 | ScrollView([.horizontal, .vertical]) { |
| 13 | LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { |
| 14 | Section { |
| 15 | ForEach(0..<model.rows, id: \.self) { row in |
| 16 | HStack(spacing: 0) { |
| 17 | ZStack(alignment: .bottom) { |
| 18 | Button { selectRow(row) } label: { |
| 19 | Text(String(row + 1)).frame(width: 48, height: height(row)).background(.quaternary) |
| 20 | }.buttonStyle(.plain).accessibilityLabel(String(format: sheetsMacString("Select row %d"), row + 1)) |
| 21 | #if os(macOS) |
| 22 | resizeHandle(axis: "row", index: row).frame(width: 48, height: 8) |
| 23 | #else |
| 24 | Color.clear.frame(width: 48, height: 8) |
| 25 | .contentShape(.rect).gesture(resizeGesture(axis: "row", index: row)) |
| 26 | .accessibilityHidden(true) |
| 27 | #endif |
| 28 | } |
| 29 | ForEach(0..<model.cols, id: \.self) { col in |
| 30 | SheetCell( |
| 31 | model: model, point: Coordinate(r: row, c: col), editing: $editing, |
| 32 | select: { select(Coordinate(r: row, c: col)) }, |
| 33 | beginEditing: { edit(Coordinate(r: row, c: col)) }, |
| 34 | endEditing: { editing = nil; focused = true }, |
| 35 | columnWidth: width(col), rowHeight: height(row)) |
| 36 | } |
| 37 | } |
| 38 | } |
| 39 | } header: { |
| 40 | HStack(spacing: 0) { |
| 41 | Text("").frame(width: 48, height: 32) |
| 42 | ForEach(0..<model.cols, id: \.self) { col in |
| 43 | ZStack(alignment: .trailing) { |
| 44 | Button { selectColumn(col) } label: { |
| 45 | Text(Coordinate(r: 0, c: col).ref.dropLast()) |
| 46 | .frame(width: width(col), height: 32) |
| 47 | .contentShape(.rect) |
| 48 | }.buttonStyle(.plain) |
| 49 | .accessibilityLabel(String(format: sheetsMacString("Select column %@"), String(Coordinate(r: 0, c: col).ref.dropLast()))) |
| 50 | #if os(macOS) |
| 51 | resizeHandle(axis: "col", index: col).frame(width: 8, height: 32) |
| 52 | .help(sheetsMacString("Double-click to fit column to contents")) |
| 53 | #else |
| 54 | Color.clear.frame(width: 8, height: 32) |
| 55 | #endif |
| 56 | } |
| 57 | #if os(macOS) |
| 58 | .contextMenu { Button(sheetsMacString("Fit column to contents")) { Task { await model.fitColumn(col) } } } |
| 59 | #endif |
| 60 | } |
| 61 | }.background(.regularMaterial) |
| 62 | } |
| 63 | } |
| 64 | }.defaultScrollAnchor(.topLeading) |
| 65 | .accessibilityLabel("Spreadsheet grid") |
| 66 | .focusable() |
| 67 | .focusEffectDisabled() |
| 68 | .focused($focused) |
| 69 | .background { SheetSelectionObserver(model: model, editing: $editing) } |
| 70 | .onChange(of: focused) { _, value in model.gridFocused = value } |
| 71 | .onDisappear { model.gridFocused = false; if !savingResize { resizing = nil } } |
| 72 | .onChange(of: draggingResize) { _, active in |
| 73 | if !active, !savingResize { resizing = nil } |
| 74 | } |
| 75 | .onKeyPress(.leftArrow) { move(0, -1) } |
| 76 | .onKeyPress(.rightArrow) { move(0, 1) } |
| 77 | .onKeyPress(.upArrow) { move(-1, 0) } |
| 78 | .onKeyPress(.downArrow) { move(1, 0) } |
| 79 | .onKeyPress(.delete) { |
| 80 | guard editing == nil, model.mayWrite else { return .ignored } |
| 81 | Task { await model.clear() } |
| 82 | return .handled |
| 83 | } |
| 84 | .onKeyPress(.return) { |
| 85 | guard editing == nil, model.mayWrite else { return .ignored } |
| 86 | edit(model.selection.start) |
| 87 | return .handled |
| 88 | } |
| 89 | .onKeyPress(characters: .alphanumerics.union(.punctuationCharacters).union(.symbols).union(.whitespaces)) { |
| 90 | press in |
| 91 | guard model.mayWrite, !model.busy, !press.characters.contains(where: { $0 == "\t" || $0.isNewline }), |
| 92 | press.modifiers.intersection([.command, .control, .option]).isEmpty |
| 93 | else { return .ignored } |
| 94 | if editing != nil { |
| 95 | // Events arriving before the cell's native field gains focus still |
| 96 | // belong to this edit. Once focused, NSTextField handles them itself. |
| 97 | model.draft += press.characters |
| 98 | } else { |
| 99 | guard !model.hasUnconfirmedChanges else { return .ignored } |
| 100 | model.draft = press.characters |
| 101 | edit(model.selection.start) |
| 102 | } |
| 103 | return .handled |
| 104 | } |
| 105 | } |
| 106 | private func edit(_ point: Coordinate) { |
| 107 | guard model.mayWrite, !model.busy, model.failedWrites.isEmpty else { return } |
| 108 | #if os(macOS) |
| 109 | editing = point |
| 110 | #else |
| 111 | beginEditing() |
| 112 | #endif |
| 113 | } |
| 114 | private func select(_ point: Coordinate) { |
| 115 | guard !model.busy else { return } |
| 116 | if model.hasUnconfirmedChanges { |
| 117 | Task { |
| 118 | await model.saveAll() |
| 119 | guard !model.hasUnconfirmedChanges, !model.busy else { return } |
| 120 | editing = nil; model.select(point); focused = true |
| 121 | } |
| 122 | } else { |
| 123 | if editing != point { editing = nil } |
| 124 | model.select(point) |
| 125 | focused = true |
| 126 | } |
| 127 | } |
| 128 | private func selectRow(_ row: Int) { |
| 129 | guard !model.hasUnconfirmedChanges, !model.busy else { return } |
| 130 | editing = nil |
| 131 | model.rangeText = "A\(row + 1):" + Coordinate(r: row, c: model.cols - 1).ref |
| 132 | model.selectRange() |
| 133 | focused = true |
| 134 | } |
| 135 | private func selectColumn(_ col: Int) { |
| 136 | guard !model.hasUnconfirmedChanges, !model.busy else { return } |
| 137 | editing = nil |
| 138 | model.rangeText = Coordinate(r: 0, c: col).ref + ":" + Coordinate(r: model.rows - 1, c: col).ref |
| 139 | model.selectRange() |
| 140 | focused = true |
| 141 | } |
| 142 | private func move(_ r: Int, _ c: Int) -> KeyPress.Result { |
| 143 | guard editing == nil else { return .ignored } |
| 144 | let p = model.selection.start |
| 145 | model.select( |
| 146 | Coordinate(r: max(0, min(model.rows - 1, p.r + r)), c: max(0, min(model.cols - 1, p.c + c)))) |
| 147 | return .handled |
| 148 | } |
| 149 | private func width(_ col: Int) -> Double { |
| 150 | if let resizing, resizing.axis == "col", resizing.index == col { return resizing.size } |
| 151 | return model.sizes.cols?[String(col)] ?? 110 |
| 152 | } |
| 153 | private func height(_ row: Int) -> Double { |
| 154 | if let resizing, resizing.axis == "row", resizing.index == row { return resizing.size } |
| 155 | return model.sizes.rows?[String(row)] ?? 32 |
| 156 | } |
| 157 | #if os(macOS) |
| 158 | private func resizeHandle(axis: String, index: Int) -> some View { |
| 159 | SheetResizeHandle(vertical: axis == "row", |
| 160 | canBegin: { model.mayWrite && !model.busy && !model.hasUnconfirmedChanges && !savingResize }, |
| 161 | change: { translation in |
| 162 | if resizing == nil { |
| 163 | resizing = SheetResizePreview(axis: axis, index: index, |
| 164 | original: axis == "col" ? width(index) : height(index)) |
| 165 | } |
| 166 | resizing?.update(translation: translation) |
| 167 | }, finish: { finishResize(axis: axis, index: index) }, |
| 168 | cancel: { if !savingResize { resizing = nil } }, |
| 169 | fit: axis == "col" ? { Task { await model.fitColumn(index) } } : nil) |
| 170 | |
| 171 | } |
| 172 | #endif |
| 173 | private func finishResize(axis: String, index: Int) { |
| 174 | guard let preview = resizing, preview.axis == axis, preview.index == index else { return } |
| 175 | savingResize = true |
| 176 | Task { |
| 177 | await model.resize(axis: axis, size: Int(preview.size), index: index) |
| 178 | resizing = nil |
| 179 | savingResize = false |
| 180 | } |
| 181 | } |
| 182 | private func resizeGesture(axis: String, index: Int) -> some Gesture { |
| 183 | DragGesture(minimumDistance: 2, coordinateSpace: .global) |
| 184 | .updating($draggingResize) { _, active, _ in active = true } |
| 185 | .onChanged { value in |
| 186 | guard model.mayWrite, !model.busy, !model.hasUnconfirmedChanges else { return } |
| 187 | if resizing == nil { |
| 188 | resizing = SheetResizePreview(axis: axis, index: index, |
| 189 | original: axis == "col" ? width(index) : height(index)) |
| 190 | } |
| 191 | guard resizing?.axis == axis, resizing?.index == index else { return } |
| 192 | resizing?.update(translation: axis == "col" ? value.translation.width : value.translation.height) |
| 193 | } |
| 194 | .onEnded { _ in finishResize(axis: axis, index: index) } |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | /// Observe selection in a leaf, so moving between cells does not invalidate the |
| 199 | /// LazyVStack and every row's view construction. |
| 200 | private struct SheetSelectionObserver: View { |
| 201 | let model: SheetModel |
| 202 | @Binding var editing: Coordinate? |
| 203 | var body: some View { |
| 204 | Color.clear.frame(width: 0, height: 0) |
| 205 | .onChange(of: model.selection.start) { _, point in |
| 206 | if editing != point { editing = nil } |
| 207 | } |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | struct SheetResizePreview { |
| 212 | let axis: String |
| 213 | let index: Int |
| 214 | let original: Double |
| 215 | var size: Double |
| 216 | init(axis: String, index: Int, original: Double) { |
| 217 | self.axis = axis; self.index = index; self.original = original; self.size = original |
| 218 | } |
| 219 | mutating func update(translation: Double) { |
| 220 | size = min(2000, max(16, (original + translation).rounded())) |
| 221 | } |
| 222 | } |
| 223 | |