oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1import SwiftUI
2
3struct 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.
200private 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
211struct 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