@swiftbrowser/native 0.17.8 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +20 -9
  2. package/dist/bridge.d.ts +27 -1
  3. package/dist/bridge.d.ts.map +1 -1
  4. package/dist/bridge.js.map +1 -1
  5. package/dist/hostRequests.d.ts +10 -4
  6. package/dist/hostRequests.d.ts.map +1 -1
  7. package/dist/hostRequests.js +65 -3
  8. package/dist/hostRequests.js.map +1 -1
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/run.d.ts.map +1 -1
  14. package/dist/run.js +14 -1
  15. package/dist/run.js.map +1 -1
  16. package/dist/runtime.d.ts +1 -0
  17. package/dist/runtime.d.ts.map +1 -1
  18. package/dist/runtime.js +12 -1
  19. package/dist/runtime.js.map +1 -1
  20. package/dist/site.d.ts +3 -3
  21. package/dist/site.d.ts.map +1 -1
  22. package/dist/site.js +1 -1
  23. package/dist/site.js.map +1 -1
  24. package/dist/undo.d.ts +23 -0
  25. package/dist/undo.d.ts.map +1 -0
  26. package/dist/undo.js +44 -0
  27. package/dist/undo.js.map +1 -0
  28. package/package.json +3 -3
  29. package/src/bridge.ts +26 -1
  30. package/src/hostRequests.ts +72 -4
  31. package/src/index.ts +1 -0
  32. package/src/run.ts +11 -1
  33. package/src/runtime.ts +12 -1
  34. package/src/site.ts +3 -3
  35. package/src/undo.ts +47 -0
  36. package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +199 -0
  37. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +154 -0
  38. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +45 -0
  39. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +16 -2
  40. package/swift/Sources/SwiftBrowserNative/Host/TextMeasureBridge.swift +139 -0
  41. package/swift/Sources/SwiftBrowserNative/Host/UndoBridge.swift +170 -0
  42. package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +30 -0
  43. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +347 -12
  44. package/swift/Sources/SwiftBrowserNative/Views/Kinds/DeleteBackward.swift +147 -0
  45. package/swift/Sources/SwiftBrowserNative/Views/Kinds/KeyboardBar.swift +84 -0
  46. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +116 -11
  47. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +12 -1
  48. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +30 -9
  49. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +163 -10
  50. package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +17 -4
  51. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +8 -3
  52. package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +8 -0
  53. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +41 -0
  54. package/swift/Sources/SwiftBrowserProtocol/Op.swift +27 -4
  55. package/swift/Sources/SwiftBrowserProtocol/Props.swift +110 -3
  56. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +47 -0
  57. package/swift/Sources/SwiftBrowserProtocol/SVGPath.swift +260 -0
  58. package/swift/Sources/SwiftBrowserProtocol/Style.swift +58 -0
  59. package/swift/Sources/SwiftBrowserTree/OpTree.swift +22 -2
  60. package/wasm/SwiftBrowserNative.wasm +2 -2
@@ -45,9 +45,10 @@ struct ButtonElement: View {
45
45
  }
46
46
  }()
47
47
  switch props.style {
48
- // `.borderless` reaches the protocol as `plain` too; outside a list,
49
- // where a plain-looking text button is tinted on iOS, draw it so.
50
- case "plain": node.ancestor(ofKind: "list") != nil ? AnyView(sized.buttonStyle(.plain)) : AnyView(sized.buttonStyle(.borderless))
48
+ // `plain` is the label in the foreground around it, as the page draws
49
+ // it (Numbers' document cards); `borderless` the tinted text button.
50
+ case "plain": sized.buttonStyle(.plain)
51
+ case "borderless": sized.buttonStyle(.borderless)
51
52
  case "bordered": sized.buttonStyle(.bordered)
52
53
  case "borderedProminent", "glass": sized.buttonStyle(.borderedProminent)
53
54
  default: sized.buttonStyle(.automatic)
@@ -62,12 +63,17 @@ struct ToggleElement: View {
62
63
  @Environment(\.nativeApp) private var app
63
64
 
64
65
  var body: some View {
65
- Toggle(isOn: Binding(
66
+ let toggle = Toggle(isOn: Binding(
66
67
  get: { ToggleProps(props: node.props).isOn },
67
68
  set: { app?.dispatch(.toggle(node.id, $0)) }
68
69
  )) {
69
70
  ChildrenView(node: node)
70
71
  }
72
+ switch ToggleProps(props: node.props).style {
73
+ case "button": AnyView(toggle.toggleStyle(.button))
74
+ case "switch": AnyView(toggle.toggleStyle(.switch))
75
+ default: AnyView(toggle)
76
+ }
71
77
  }
72
78
  }
73
79
 
@@ -84,11 +90,21 @@ func textChange(of node: ElementNode, to value: String) -> RendererEvent? {
84
90
  /// `textfield`: a controlled field. Each keystroke sends `text` (a write of
85
91
  /// the text the field has sends nothing, `textChange`); the app's state is
86
92
  /// the truth the getter reads back, with no frame in between because the
87
- /// dispatch is synchronous.
93
+ /// dispatch is synchronous. So is `submit`: Return dismisses the keyboard
94
+ /// after `onSubmit`, and the app's answer decides whether it may. A bound
95
+ /// `focused` the answer leaves true is held (`keeping`): the resign that
96
+ /// follows is taken back, not reported; one it turned false lets the
97
+ /// resign through; another field's turned true takes the focus in its own
98
+ /// `onChange` of the prop.
88
99
  struct TextFieldElement: View {
89
100
  let node: ElementNode
90
101
  @Environment(\.nativeApp) private var app
91
102
  @FocusState private var focused: Bool
103
+ /// Set by a submit the app answered keeping the field focused, until the resign it holds against comes (or would have).
104
+ @State private var keeping = false
105
+
106
+ /// How long after a submit a resign is Return's: SwiftUI's follows within the turn; macOS sends none.
107
+ private static let resignWindow: Duration = .milliseconds(300)
92
108
 
93
109
  var body: some View {
94
110
  let props = TextFieldProps(props: node.props)
@@ -103,8 +119,16 @@ struct TextFieldElement: View {
103
119
  }()
104
120
  var styled = props.style == "roundedBorder" ? AnyView(field.textFieldStyle(.roundedBorder)) : AnyView(field.textFieldStyle(.plain))
105
121
  // Only what the app set: each of these is a trait of the field to SwiftUI.
106
- if props.submit { styled = AnyView(styled.onSubmit { app?.dispatch(.submit(node.id)) }) }
122
+ if props.submit {
123
+ styled = AnyView(styled.onSubmit {
124
+ app?.dispatch(.submit(node.id))
125
+ // The app's pass applied: a `focused` it left or set true wins over the resign on its way.
126
+ if TextFieldProps(props: node.props).focused == true { keep() }
127
+ })
128
+ }
107
129
  if let label = props.submitLabel { styled = AnyView(styled.submitLabel(SubmitLabel(name: label))) }
130
+ // Backspace at the start of the text (DeleteBackward.swift).
131
+ if props.deleteBackward { styled = AnyView(styled.onDeleteBackward(true) { app?.dispatch(.deleteBackward(node.id)) }) }
108
132
  if props.focused != nil {
109
133
  styled = AnyView(styled
110
134
  .focused($focused)
@@ -112,12 +136,29 @@ struct TextFieldElement: View {
112
136
  if let wanted, wanted != focused { focused = wanted }
113
137
  }
114
138
  .onChange(of: focused) { _, now in
115
- if props.focused != now { app?.dispatch(.focus(node.id, now)) }
139
+ if now {
140
+ keeping = false
141
+ } else if keeping {
142
+ // Return's resign of a field the app keeps: take the focus back and say nothing.
143
+ keeping = false
144
+ focused = true
145
+ return
146
+ }
147
+ if TextFieldProps(props: node.props).focused != now { app?.dispatch(.focus(node.id, now)) }
116
148
  }
117
149
  .onAppear { if props.focused == true { focused = true } })
118
150
  }
119
151
  return styled
120
152
  }
153
+
154
+ /// Holds the focus against the resign that follows Return, for the moment one can come.
155
+ private func keep() {
156
+ keeping = true
157
+ Task { @MainActor in
158
+ try? await Task.sleep(for: Self.resignWindow)
159
+ keeping = false
160
+ }
161
+ }
121
162
  }
122
163
 
123
164
  extension SubmitLabel {
@@ -136,19 +177,311 @@ extension SubmitLabel {
136
177
  }
137
178
  }
138
179
 
139
- /// `texteditor`: a multi-line controlled field.
180
+ /// `texteditor`: a multi-line controlled field. Each edit sends `text` (a
181
+ /// write of the text it has sends nothing, `textChange`). With `runs` it is
182
+ /// the rich editor: on iOS 26 a `TextEditor` of the runs as an
183
+ /// `AttributedString` (`RichTextEditorElement`), earlier the plain editor of
184
+ /// the same text. A bound `focused` moves the focus both ways, as a field's.
140
185
  struct TextEditorElement: View {
141
186
  let node: ElementNode
142
187
  @Environment(\.nativeApp) private var app
188
+ @FocusState private var focused: Bool
143
189
 
144
190
  var body: some View {
145
- TextEditor(text: Binding(
191
+ let props = TextEditorProps(props: node.props)
192
+ var editor = editorView(props)
193
+ // Backspace at the start of the text (DeleteBackward.swift).
194
+ if props.deleteBackward { editor = AnyView(editor.onDeleteBackward(true) { app?.dispatch(.deleteBackward(node.id)) }) }
195
+ if props.focused == nil { return editor }
196
+ return AnyView(editor
197
+ .focused($focused)
198
+ .onChange(of: props.focused) { _, wanted in
199
+ if let wanted, wanted != focused { focused = wanted }
200
+ }
201
+ .onChange(of: focused) { _, now in
202
+ if TextEditorProps(props: node.props).focused != now { app?.dispatch(.focus(node.id, now)) }
203
+ }
204
+ .onAppear { if props.focused == true { focused = true } })
205
+ }
206
+
207
+ private func editorView(_ props: TextEditorProps) -> AnyView {
208
+ #if !canImport(FoundationBridge) // not in the shim
209
+ if props.rich {
210
+ if #available(iOS 26, macOS 26, *) { return AnyView(RichTextEditorElement(node: node)) }
211
+ } else if props.selection != nil {
212
+ if #available(iOS 18, macOS 15, *) { return AnyView(PlainTextEditorElement(node: node)) }
213
+ }
214
+ #endif
215
+ return AnyView(TextEditor(text: Binding(
146
216
  get: { node.props.string("text") ?? "" },
147
217
  set: { if let event = textChange(of: node, to: $0) { app?.dispatch(event) } }
148
- ))
218
+ )))
219
+ }
220
+ }
221
+
222
+ #if !canImport(FoundationBridge)
223
+ /// The plain `texteditor` with `selection` (iOS 18, `TextEditor(text:selection:)`):
224
+ /// the viewer's selection reported in UTF-16 offsets, and the caret moved
225
+ /// to one the app sets, as the rich editor does.
226
+ @available(iOS 18, macOS 15, *)
227
+ struct PlainTextEditorElement: View {
228
+ let node: ElementNode
229
+ @Environment(\.nativeApp) private var app
230
+ @State private var selection: TextSelection?
231
+ @State private var shown = Shown()
232
+
233
+ /// The selection last sent, or last moved to (a class: set while drawing).
234
+ final class Shown {
235
+ var sent: TextSelectionSpec?
236
+ }
237
+
238
+ var body: some View {
239
+ TextEditor(
240
+ text: Binding(
241
+ get: { node.props.string("text") ?? "" },
242
+ set: { if let event = textChange(of: node, to: $0) { app?.dispatch(event) } }
243
+ ),
244
+ selection: Binding(
245
+ get: { selection },
246
+ set: { next in
247
+ selection = next
248
+ guard let next, let range = offsets(of: next, in: node.props.string("text") ?? "") else { return }
249
+ if range != shown.sent {
250
+ shown.sent = range
251
+ app?.dispatch(.selection(node.id, start: range.start, end: range.end))
252
+ }
253
+ }
254
+ )
255
+ )
256
+ .onChange(of: TextEditorProps(props: node.props).selection) { _, wanted in move(to: wanted) }
257
+ .onAppear { move(to: TextEditorProps(props: node.props).selection) }
258
+ }
259
+
260
+ /// A selection the app set (not the one this editor sent): the caret goes there.
261
+ private func move(to wanted: TextSelectionSpec?) {
262
+ guard let wanted, wanted != shown.sent else { return }
263
+ shown.sent = wanted
264
+ let text = node.props.string("text") ?? ""
265
+ let start = stringIndex(utf16: wanted.start, in: text)
266
+ let end = stringIndex(utf16: max(wanted.start, wanted.end), in: text)
267
+ selection = start == end ? TextSelection(insertionPoint: start) : TextSelection(range: start..<end)
268
+ }
269
+
270
+ private func offsets(of selection: TextSelection, in text: String) -> TextSelectionSpec? {
271
+ switch selection.indices {
272
+ case .selection(let range):
273
+ return TextSelectionSpec(start: utf16Offset(range.lowerBound, in: text), end: utf16Offset(range.upperBound, in: text))
274
+ case .multiSelection(let ranges):
275
+ guard let first = ranges.ranges.first, let last = ranges.ranges.last else { return nil }
276
+ return TextSelectionSpec(start: utf16Offset(first.lowerBound, in: text), end: utf16Offset(last.upperBound, in: text))
277
+ @unknown default:
278
+ return nil
279
+ }
280
+ }
281
+
282
+ private func utf16Offset(_ index: String.Index, in text: String) -> Int {
283
+ guard index >= text.startIndex, index <= text.endIndex else { return 0 }
284
+ return text.utf16.distance(from: text.utf16.startIndex, to: index)
149
285
  }
150
286
  }
151
287
 
288
+ /// The index `offset` UTF-16 units into `text` (its end, past it; a character's start, inside one).
289
+ private func stringIndex(utf16 offset: Int, in text: String) -> String.Index {
290
+ let utf16 = text.utf16
291
+ let index = utf16.index(utf16.startIndex, offsetBy: min(max(0, offset), utf16.count))
292
+ return index.samePosition(in: text) ?? text.endIndex
293
+ }
294
+
295
+ /// The rich `texteditor` (iOS 26): the runs as an `AttributedString`, each
296
+ /// with its weight and slant over the editor's font (or its own size and
297
+ /// design, `font`), its lines, its color and its background color.
298
+ /// What the viewer types goes to the app as plain `text`; the app keeps the
299
+ /// runs (the typed text taking the attributes around it, as here). While
300
+ /// the app's runs are the ones this editor's string stands for, the editor
301
+ /// keeps its own string, so an edit's echo never resets the text under the
302
+ /// caret. With `selection` bound it reports the viewer's in UTF-16 offsets,
303
+ /// and moves the caret to one the app sets. An edit of the attributes alone
304
+ /// (the editor's own Bold, Italic and Underline, from the edit menu) goes
305
+ /// to the app as `runs`, the app's runs cut where the formatting changed.
306
+ @available(iOS 26, macOS 26, *)
307
+ struct RichTextEditorElement: View {
308
+ let node: ElementNode
309
+ @Environment(\.nativeApp) private var app
310
+ @Environment(\.font) private var font
311
+ @Environment(\.fontResolutionContext) private var fontContext
312
+ @State private var selection = AttributedTextSelection()
313
+ @State private var shown = Shown()
314
+
315
+ /// The string the editor holds, the runs it stands for, and the selection
316
+ /// last sent (a class: the getter brings it up to date while drawing).
317
+ final class Shown {
318
+ var runs: [TextRunSpec]?
319
+ var string = AttributedString()
320
+ var sent: TextSelectionSpec?
321
+ }
322
+
323
+ var body: some View {
324
+ TextEditor(
325
+ text: Binding(
326
+ get: { current() },
327
+ set: { edited in
328
+ shown.string = edited
329
+ let text = String(edited.characters)
330
+ if let event = textChange(of: node, to: text) {
331
+ app?.dispatch(event)
332
+ } else if let runs = formatting(of: edited) {
333
+ app?.dispatch(.runs(node.id, runs))
334
+ }
335
+ // The app answered (synchronously): its runs are this string's,
336
+ // unless it changed the text (a newline taken elsewhere).
337
+ let props = TextEditorProps(props: node.props)
338
+ shown.runs = props.text == text ? props.runs : nil
339
+ }
340
+ ),
341
+ selection: Binding(
342
+ get: { selection },
343
+ set: { next in
344
+ selection = next
345
+ guard TextEditorProps(props: node.props).selection != nil else { return }
346
+ let range = offsets(of: next, in: shown.string)
347
+ if range != shown.sent {
348
+ shown.sent = range
349
+ app?.dispatch(.selection(node.id, start: range.start, end: range.end))
350
+ }
351
+ }
352
+ )
353
+ )
354
+ .onChange(of: TextEditorProps(props: node.props).selection) { _, wanted in move(to: wanted) }
355
+ .onAppear { move(to: TextEditorProps(props: node.props).selection) }
356
+ }
357
+
358
+ /// A selection the app set (not the one this editor sent): the caret goes there.
359
+ private func move(to wanted: TextSelectionSpec?) {
360
+ guard let wanted, wanted != shown.sent else { return }
361
+ shown.sent = wanted
362
+ let text = current()
363
+ let start = index(at: wanted.start, in: text)
364
+ let end = index(at: max(wanted.start, wanted.end), in: text)
365
+ selection = AttributedTextSelection(range: start..<end)
366
+ }
367
+
368
+ /// The index `offset` UTF-16 units into the text (its end, past it).
369
+ private func index(at offset: Int, in text: AttributedString) -> AttributedString.Index {
370
+ var index = text.startIndex
371
+ var count = 0
372
+ while index < text.endIndex, count < offset {
373
+ count += String(text.characters[index]).utf16.count
374
+ index = text.characters.index(after: index)
375
+ }
376
+ return index
377
+ }
378
+
379
+ private func current() -> AttributedString {
380
+ let runs = TextEditorProps(props: node.props).runs ?? []
381
+ if runs != shown.runs {
382
+ shown.runs = runs
383
+ shown.string = attributed(runs)
384
+ }
385
+ return shown.string
386
+ }
387
+
388
+ private func attributed(_ runs: [TextRunSpec]) -> AttributedString {
389
+ var string = AttributedString()
390
+ let base = font ?? .body
391
+ for run in runs {
392
+ var piece = AttributedString(run.text ?? "")
393
+ var runFont: Font?
394
+ let weight = run.font?.weight.map(Font.Weight.init(name:))
395
+ let design = run.font?.design.map(Font.Design.init(name:))
396
+ if let size = run.font?.size {
397
+ // A size of its own: a system font of it, with the run's weight and design.
398
+ runFont = .system(size: size, weight: weight, design: design)
399
+ } else if let design {
400
+ // A design over the editor's font: at the size the editor's resolves to.
401
+ runFont = .system(size: base.resolve(in: fontContext).pointSize, weight: weight, design: design)
402
+ } else if let weight {
403
+ runFont = base.weight(weight)
404
+ }
405
+ if run.italic { runFont = (runFont ?? base).italic() }
406
+ if let runFont { piece.font = runFont }
407
+ if run.underline { piece.underlineStyle = Text.LineStyle.single }
408
+ if run.strikethrough { piece.strikethroughStyle = Text.LineStyle.single }
409
+ if case .color(let color)? = run.foreground { piece.foregroundColor = Color(spec: color) }
410
+ if let background = run.background { piece.backgroundColor = Color(spec: background) }
411
+ string.append(piece)
412
+ }
413
+ return string
414
+ }
415
+
416
+ /// The weights the editor shows as bold (`Font.Resolved.isBold`: semibold and up).
417
+ private static let boldWeights: Set<String> = ["semibold", "bold", "heavy", "black"]
418
+
419
+ /// The runs `edited` stands for when its characters are the app's and
420
+ /// its formatting is not (the editor's own Bold, Italic or Underline):
421
+ /// the app's runs (their JSON, so a color, a background, a font's size
422
+ /// and design or a link stay as sent) cut where the editor's formatting
423
+ /// changes, each cut with the weight, slant and lines the editor shows.
424
+ /// Nil while the formatting is the app's.
425
+ private func formatting(of edited: AttributedString) -> [JSONValue]? {
426
+ guard let appRuns = node.props.array("runs") else { return nil }
427
+ let base = font ?? .body
428
+ let shows = edited.runs.map { run -> (range: Range<Int>, bold: Bool, italic: Bool, underline: Bool, strikethrough: Bool) in
429
+ let resolved = (run.font ?? base).resolve(in: fontContext)
430
+ let range = utf16Offset(run.range.lowerBound, in: edited)..<utf16Offset(run.range.upperBound, in: edited)
431
+ return (range, resolved.isBold, resolved.isItalic, run.underlineStyle != nil, run.strikethroughStyle != nil)
432
+ }
433
+ var out: [JSONValue] = []
434
+ var changed = false
435
+ var offset = 0
436
+ for json in appRuns {
437
+ guard let fields = json.object, let spec = TextRunSpec(json: json) else { continue }
438
+ let text = spec.text ?? ""
439
+ let range = offset..<(offset + text.utf16.count)
440
+ offset = range.upperBound
441
+ let bold = Self.boldWeights.contains(spec.font?.weight ?? "")
442
+ for show in shows {
443
+ let lower = max(show.range.lowerBound, range.lowerBound)
444
+ let upper = min(show.range.upperBound, range.upperBound)
445
+ guard lower < upper else { continue }
446
+ var cut = fields
447
+ cut["text"] = .string(String(text[stringIndex(utf16: lower - range.lowerBound, in: text)..<stringIndex(utf16: upper - range.lowerBound, in: text)]))
448
+ if show.bold != bold {
449
+ var fontFields = fields.object("font") ?? [:]
450
+ fontFields["weight"] = show.bold ? JSONValue.string("bold") : nil
451
+ cut["font"] = fontFields.isEmpty ? nil : JSONValue.object(fontFields)
452
+ }
453
+ if show.italic != spec.italic { cut["italic"] = show.italic ? JSONValue.bool(true) : nil }
454
+ if show.underline != spec.underline { cut["underline"] = show.underline ? JSONValue.object([:]) : nil }
455
+ if show.strikethrough != spec.strikethrough { cut["strikethrough"] = show.strikethrough ? JSONValue.object([:]) : nil }
456
+ if show.bold != bold || show.italic != spec.italic || show.underline != spec.underline || show.strikethrough != spec.strikethrough {
457
+ changed = true
458
+ }
459
+ out.append(.object(cut))
460
+ }
461
+ }
462
+ return changed ? out : nil
463
+ }
464
+
465
+ private func offsets(of selection: AttributedTextSelection, in text: AttributedString) -> TextSelectionSpec {
466
+ switch selection.indices(in: text) {
467
+ case .insertionPoint(let index):
468
+ let offset = utf16Offset(index, in: text)
469
+ return TextSelectionSpec(start: offset, end: offset)
470
+ case .ranges(let ranges):
471
+ guard let first = ranges.ranges.first, let last = ranges.ranges.last else { return TextSelectionSpec(start: 0, end: 0) }
472
+ return TextSelectionSpec(start: utf16Offset(first.lowerBound, in: text), end: utf16Offset(last.upperBound, in: text))
473
+ @unknown default:
474
+ return TextSelectionSpec(start: 0, end: 0)
475
+ }
476
+ }
477
+
478
+ private func utf16Offset(_ index: AttributedString.Index, in text: AttributedString) -> Int {
479
+ guard index >= text.startIndex, index <= text.endIndex else { return 0 }
480
+ return String(text[text.startIndex..<index].characters).utf16.count
481
+ }
482
+ }
483
+ #endif
484
+
152
485
  /// `picker`: the options are strings; choosing sends `select` with the index.
153
486
  struct PickerElement: View {
154
487
  let node: ElementNode
@@ -231,12 +564,14 @@ struct TapGestureElement: View {
231
564
  var body: some View {
232
565
  let props = TapGestureProps(props: node.props)
233
566
  let content = ChildrenView(node: node).contentShape(Rectangle())
567
+ // `count`: a double tap. SwiftUI delays a single tap attached outside
568
+ // (after) a double tap until the double tap can no longer land.
234
569
  if props.location {
235
- content.onTapGesture(coordinateSpace: .local) { point in
570
+ content.onTapGesture(count: props.count, coordinateSpace: .local) { point in
236
571
  app?.dispatch(.tap(node.id, x: point.x, y: point.y))
237
572
  }
238
573
  } else {
239
- content.onTapGesture { app?.dispatch(.tap(node.id)) }
574
+ content.onTapGesture(count: props.count) { app?.dispatch(.tap(node.id)) }
240
575
  }
241
576
  }
242
577
  }
@@ -0,0 +1,147 @@
1
+ #if canImport(SwiftUI)
2
+ import SwiftBrowserProtocol
3
+ import SwiftUI
4
+
5
+ #if os(iOS) && !canImport(FoundationBridge)
6
+ import ObjectiveC
7
+ import UIKit
8
+
9
+ /// `deleteBackward` on a `textfield` or `texteditor`: Backspace with nothing
10
+ /// selected and the caret at the start sends `deletebackward` and deletes
11
+ /// nothing (docs/ops-protocol.md, "The delete key at the start of a field").
12
+ ///
13
+ /// SwiftUI gives no hook for it: `.onKeyPress(.delete)` hears hardware
14
+ /// keyboards only, and the binding is not written when the text does not
15
+ /// change. The software keyboard (and a hardware Backspace, which UIKit
16
+ /// routes the same way) calls `UIKeyInput.deleteBackward()` on the text
17
+ /// view or text field behind SwiftUI's editor. So the editor carries a
18
+ /// `DeleteBackwardFinder` as its background: a `UIViewRepresentable` that,
19
+ /// once in a window, finds the nearest `UITextView` or `UITextField` beside
20
+ /// it in UIKit's hierarchy (up to six ancestors up, their subtrees down) and
21
+ /// marks it with the handler. `deleteBackward()` is swizzled once per class
22
+ /// (`UITextView`, `UITextField`): a marked input with an empty selection at
23
+ /// offset 0 runs the handler instead; everything else goes to UIKit's own.
24
+ ///
25
+ /// Limits: it relies on SwiftUI's fields staying UIKit-backed and on the
26
+ /// nearest text input being the editor's own (an editor's background is
27
+ /// nearer to it than to any other field); a private subclass that
28
+ /// overrides `deleteBackward()` without calling super would not be heard.
29
+ /// macOS (`NSTextView`) is not covered: the modifier is a no-op there.
30
+ extension View {
31
+ func onDeleteBackward(_ enabled: Bool, perform action: @escaping () -> Void) -> some View {
32
+ background {
33
+ if enabled { DeleteBackwardFinder(action: action) }
34
+ }
35
+ }
36
+ }
37
+
38
+ struct DeleteBackwardFinder: UIViewRepresentable {
39
+ let action: () -> Void
40
+
41
+ func makeUIView(context: Context) -> FinderView {
42
+ let view = FinderView()
43
+ view.isUserInteractionEnabled = false
44
+ return view
45
+ }
46
+
47
+ func updateUIView(_ view: FinderView, context: Context) {
48
+ view.action = action
49
+ view.attach()
50
+ }
51
+
52
+ final class FinderView: UIView {
53
+ var action: (() -> Void)?
54
+ /// The text input marked, while it is on screen.
55
+ private(set) weak var attached: UIView?
56
+
57
+ override func didMoveToWindow() {
58
+ super.didMoveToWindow()
59
+ attach()
60
+ // The text input may join the hierarchy after its background does.
61
+ DispatchQueue.main.async { [weak self] in self?.attach() }
62
+ }
63
+
64
+ func attach() {
65
+ guard window != nil, let action else { return }
66
+ if let attached, attached.window != nil {
67
+ DeleteBackwardHook.set(action, on: attached)
68
+ return
69
+ }
70
+ guard let input = nearestTextInput() else { return }
71
+ attached = input
72
+ DeleteBackwardHook.set(action, on: input)
73
+ }
74
+
75
+ private func nearestTextInput() -> UIView? {
76
+ var ancestor = superview
77
+ for _ in 0..<6 {
78
+ guard let candidate = ancestor else { return nil }
79
+ if let found = Self.textInput(in: candidate, skipping: self) { return found }
80
+ ancestor = candidate.superview
81
+ }
82
+ return nil
83
+ }
84
+
85
+ private static func textInput(in view: UIView, skipping: UIView) -> UIView? {
86
+ for subview in view.subviews where subview !== skipping {
87
+ if subview is UITextView || subview is UITextField { return subview }
88
+ if let found = textInput(in: subview, skipping: skipping) { return found }
89
+ }
90
+ return nil
91
+ }
92
+ }
93
+ }
94
+
95
+ /// The handler a text input carries, and the swizzle that runs it.
96
+ @MainActor
97
+ enum DeleteBackwardHook {
98
+ private final class Handler {
99
+ let action: () -> Void
100
+ init(_ action: @escaping () -> Void) { self.action = action }
101
+ }
102
+
103
+ private static var handlerKey = 0
104
+ private static var swizzled: Set<ObjectIdentifier> = []
105
+
106
+ static func set(_ action: @escaping () -> Void, on view: UIView) {
107
+ objc_setAssociatedObject(view, &handlerKey, Handler(action), .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
108
+ let cls: AnyClass = view is UITextView ? UITextView.self : UITextField.self
109
+ swizzle(cls)
110
+ }
111
+
112
+ static func handler(of view: UIView) -> (() -> Void)? {
113
+ (objc_getAssociatedObject(view, &handlerKey) as? Handler)?.action
114
+ }
115
+
116
+ /// Whether the input's selection is empty and at the start of its text.
117
+ static func isAtStart(_ input: UITextInput) -> Bool {
118
+ guard let range = input.selectedTextRange, range.isEmpty else { return false }
119
+ return input.offset(from: input.beginningOfDocument, to: range.start) == 0
120
+ }
121
+
122
+ private static func swizzle(_ cls: AnyClass) {
123
+ guard swizzled.insert(ObjectIdentifier(cls)).inserted else { return }
124
+ let selector = #selector(UIKeyInput.deleteBackward)
125
+ guard let method = class_getInstanceMethod(cls, selector) else { return }
126
+ typealias Original = @convention(c) (AnyObject, Selector) -> Void
127
+ let original = unsafeBitCast(method_getImplementation(method), to: Original.self)
128
+ let replacement: @convention(block) (AnyObject) -> Void = { object in
129
+ let handled = MainActor.assumeIsolated { () -> Bool in
130
+ guard let view = object as? UIView, let input = object as? UITextInput, let action = handler(of: view), isAtStart(input) else { return false }
131
+ action()
132
+ return true
133
+ }
134
+ if !handled { original(object, selector) }
135
+ }
136
+ method_setImplementation(method, imp_implementationWithBlock(replacement))
137
+ }
138
+ }
139
+ #else
140
+ extension View {
141
+ /// `deleteBackward` is heard on iOS only (DeleteBackward.swift); elsewhere the key deletes as it does.
142
+ func onDeleteBackward(_ enabled: Bool, perform action: @escaping () -> Void) -> some View {
143
+ self
144
+ }
145
+ }
146
+ #endif
147
+ #endif
@@ -0,0 +1,84 @@
1
+ #if canImport(SwiftUI)
2
+ import SwiftBrowserProtocol
3
+ import SwiftBrowserTree
4
+ import SwiftUI
5
+
6
+ /// A screen's `keyboard` toolbars (`ToolbarItemGroup(placement: .keyboard)`)
7
+ /// as a bottom safe-area inset of its content, drawn by `KeyboardBar` on
8
+ /// iOS; elsewhere, and against the shim, the content is left as it is and
9
+ /// the toolbars go to SwiftUI's own placement.
10
+ struct KeyboardBarInset: ViewModifier {
11
+ let toolbars: [ElementNode]
12
+
13
+ func body(content: Content) -> some View {
14
+ #if os(iOS) && !canImport(FoundationBridge)
15
+ if toolbars.contains(where: { ToolbarProps(props: $0.props).spacer == nil }) {
16
+ content.safeAreaInset(edge: .bottom, spacing: 0) { KeyboardBar(toolbars: toolbars) }
17
+ } else {
18
+ content
19
+ }
20
+ #else
21
+ content
22
+ #endif
23
+ }
24
+ }
25
+
26
+ #if os(iOS) && !canImport(FoundationBridge)
27
+ import Combine
28
+ import UIKit
29
+
30
+ /// The bar above the keyboard as Notes draws its format bar on iOS 26: a
31
+ /// glass capsule 48 tall, inset 10 from the screen's sides, floating 10
32
+ /// above the keyboard, there while the keyboard is up and gone with it.
33
+ /// SwiftUI's `.keyboard` placement is the input accessory view, a bar flush
34
+ /// with the keyboard across the screen, which the system's apps no longer
35
+ /// show. The items are the screen's `keyboard` toolbars' children in one
36
+ /// row, a `spacer` toolbar a `Spacer` (8 wide when fixed), as the page lays
37
+ /// them out (docs/ops-protocol.md, "The keyboard toolbar"). As a safe-area
38
+ /// inset of the content, the bar keeps the content above it, as the
39
+ /// keyboard does; with the keyboard down it has no height.
40
+ struct KeyboardBar: View {
41
+ let toolbars: [ElementNode]
42
+ @State private var shown = false
43
+
44
+ var body: some View {
45
+ Group {
46
+ if shown {
47
+ HStack(spacing: 0) {
48
+ ForEach(toolbars) { toolbar in
49
+ if let spacer = ToolbarProps(props: toolbar.props).spacer {
50
+ if spacer == "fixed" { Spacer().frame(width: 8) } else { Spacer(minLength: 0) }
51
+ } else {
52
+ ChildrenView(node: toolbar)
53
+ }
54
+ }
55
+ }
56
+ .padding(.horizontal, 16)
57
+ .frame(maxWidth: .infinity, minHeight: 48)
58
+ .modifier(CapsuleGlass())
59
+ .padding(.horizontal, 10)
60
+ .padding(.bottom, 10)
61
+ .transition(.move(edge: .bottom).combined(with: .opacity))
62
+ }
63
+ }
64
+ .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
65
+ withAnimation(.easeOut(duration: 0.25)) { shown = true }
66
+ }
67
+ .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
68
+ withAnimation(.easeOut(duration: 0.25)) { shown = false }
69
+ }
70
+ }
71
+ }
72
+
73
+ /// Liquid Glass from iOS 26; a material before it.
74
+ private struct CapsuleGlass: ViewModifier {
75
+ func body(content: Content) -> some View {
76
+ if #available(iOS 26, *) {
77
+ content.glassEffect(.regular, in: .capsule)
78
+ } else {
79
+ content.background(.regularMaterial, in: Capsule())
80
+ }
81
+ }
82
+ }
83
+ #endif
84
+ #endif